Emit bounded SCV reports

f19d329a69b3 · AtlantisPleb · · parent 9849f6c543fd

Emit bounded SCV reports

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified lib/openagents/scv/executor/open_code.ex
  • added lib/openagents/scv/open_code_report.ex
  • modified lib/openagents/scv/worker.ex
  • modified test/openagents/scv/open_code_executor_test.exs
  • added test/openagents/scv/open_code_report_test.exs

Diff

5 files changed, +168 -11

lib/openagents/scv/executor/open_code.ex modified +30 -9

@@ -9,6 +9,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

9 9
  """
10 10
11 11
  alias OpenAgents.SCV.OpenCodeEvents
12
  alias OpenAgents.SCV.OpenCodeReport
12 13
  alias OpenAgents.SCV.ResourceSampler
13 14
14 15
  @schema "openagents.scv.opencode.run.v1"

@@ -239,6 +240,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

239 240
240 241
    state = %{
241 242
      events: OpenCodeEvents.new(),
243
      report: OpenCodeReport.new(),
242 244
      line_buffer: "",
243 245
      observed_output_bytes: 0,
244 246
      captured_output_bytes: 0,

@@ -273,6 +275,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

273 275
        error_code: "process_start_failed",
274 276
        error_detail: Exception.message(error),
275 277
        events: OpenCodeEvents.new(),
278
        report: OpenCodeReport.new(),
276 279
        observed_output_bytes: 0,
277 280
        captured_output_bytes: 0,
278 281
        output_truncated?: false,

@@ -404,20 +407,27 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

404 407
    pieces = :binary.split(state.line_buffer <> data, "\n", [:global])
405 408
    {buffer, complete_lines} = List.pop_at(pieces, -1)
406 409
407
    events =
408
      Enum.reduce(complete_lines, state.events, fn line, events ->
410
    {events, report} =
411
      Enum.reduce(complete_lines, {state.events, state.report}, fn line, {events, report} ->
409 412
        redacted = redact(line, redactions)
410 413
        :ok = IO.binwrite(events_io, redacted <> "\n")
411 414
        :ok = observe_output_line(state.input, redacted)
412
        ingest_nonempty(events, redacted)
415
        {ingest_nonempty(events, redacted), ingest_report(report, redacted)}
413 416
      end)
414 417
415
    %{state | events: events, line_buffer: buffer || ""}
418
    %{state | events: events, report: report, line_buffer: buffer || ""}
416 419
  end
417 420
418 421
  defp finish_collection(state, events_io, redactions, status, exit_status, error_code) do
419
    {events, line_buffer} =
420
      flush_line(state.input, state.events, state.line_buffer, events_io, redactions)
422
    {events, report, line_buffer} =
423
      flush_line(
424
        state.input,
425
        state.events,
426
        state.report,
427
        state.line_buffer,
428
        events_io,
429
        redactions
430
      )
421 431
422 432
    :ok = :file.sync(events_io)
423 433

@@ -430,6 +440,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

430 440
431 441
    state
432 442
    |> Map.put(:events, events)
443
    |> Map.put(:report, report)
433 444
    |> Map.put(:line_buffer, line_buffer)
434 445
    |> Map.put(:status, status)
435 446
    |> Map.put(:exit_status, exit_status)

@@ -437,18 +448,27 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

437 448
    |> Map.put(:error_detail, nil)
438 449
  end
439 450
440
  defp flush_line(_input, events, "", _events_io, _redactions), do: {events, ""}
451
  defp flush_line(_input, events, report, "", _events_io, _redactions),
452
    do: {events, report, ""}
441 453
442
  defp flush_line(input, events, line, events_io, redactions) do
454
  defp flush_line(input, events, report, line, events_io, redactions) do
443 455
    redacted = redact(line, redactions)
444 456
    :ok = IO.binwrite(events_io, redacted)
445 457
    :ok = observe_output_line(input, redacted)
446
    {ingest_nonempty(events, redacted), ""}
458
459
    {
460
      ingest_nonempty(events, redacted),
461
      ingest_report(report, redacted),
462
      ""
463
    }
447 464
  end
448 465
449 466
  defp ingest_nonempty(events, ""), do: events
450 467
  defp ingest_nonempty(events, line), do: OpenCodeEvents.ingest(events, line)
451 468
469
  defp ingest_report(report, ""), do: report
470
  defp ingest_report(report, line), do: OpenCodeReport.ingest(report, line)
471
452 472
  defp observe_output_line(_input, ""), do: :ok
453 473
454 474
  defp observe_output_line(input, line) do

@@ -578,6 +598,7 @@ defmodule OpenAgents.SCV.Executor.OpenCode do

578 598
        maximum_output_bytes: input.maximum_output_bytes
579 599
      },
580 600
      events: OpenCodeEvents.summary(execution.events),
601
      report: OpenCodeReport.summary(execution.report),
581 602
      resources: %{
582 603
        wall_time_ms: duration_ms,
583 604
        sample_count: execution.sample_count,
lib/openagents/scv/open_code_report.ex added +81

@@ -0,0 +1,81 @@

1
defmodule OpenAgents.SCV.OpenCodeReport do
2
  @moduledoc """
3
  Collects a bounded prose report from OpenCode text events.
4
5
  The executor feeds this module only lines that have already passed through
6
  its run-specific redaction boundary. Tool output and diagnostic lines never
7
  enter the report.
8
  """
9
10
  @maximum_bytes 32_768
11
12
  @type t :: %{
13
          text: String.t(),
14
          bytes: non_neg_integer(),
15
          truncated?: boolean()
16
        }
17
18
  @spec new() :: t()
19
  def new do
20
    %{text: "", bytes: 0, truncated?: false}
21
  end
22
23
  @spec ingest(t(), binary()) :: t()
24
  def ingest(%{truncated?: true} = state, _line), do: state
25
26
  def ingest(state, line) when is_map(state) and is_binary(line) do
27
    case Jason.decode(line) do
28
      {:ok, %{"type" => "text", "part" => %{"text" => text}}} when is_binary(text) ->
29
        append(state, text)
30
31
      _other ->
32
        state
33
    end
34
  end
35
36
  @spec summary(t()) :: map()
37
  def summary(state) when is_map(state) do
38
    %{
39
      schema: "openagents.scv.report.v1",
40
      text: state.text,
41
      bytes: state.bytes,
42
      truncated: state.truncated?
43
    }
44
  end
45
46
  defp append(state, ""), do: state
47
48
  defp append(state, text) do
49
    separator = if state.text == "", do: "", else: "\n"
50
    addition = separator <> text
51
    available = max(@maximum_bytes - state.bytes, 0)
52
    captured = valid_prefix(addition, available)
53
54
    %{
55
      state
56
      | text: state.text <> captured,
57
        bytes: state.bytes + byte_size(captured),
58
        truncated?: byte_size(captured) < byte_size(addition)
59
    }
60
  end
61
62
  defp valid_prefix(_value, 0), do: ""
63
64
  defp valid_prefix(value, maximum_bytes) when byte_size(value) <= maximum_bytes, do: value
65
66
  defp valid_prefix(value, maximum_bytes) do
67
    value
68
    |> binary_part(0, maximum_bytes)
69
    |> remove_invalid_suffix()
70
  end
71
72
  defp remove_invalid_suffix(value) do
73
    if String.valid?(value) do
74
      value
75
    else
76
      value
77
      |> binary_part(0, byte_size(value) - 1)
78
      |> remove_invalid_suffix()
79
    end
80
  end
81
end
lib/openagents/scv/worker.ex modified +2 -1

@@ -201,7 +201,8 @@ defmodule OpenAgents.SCV.Worker do

201 201
      tool_calls: result.events.tool_calls,
202 202
      usage: result.events.usage,
203 203
      resources: result.resources,
204
      artifact_digest: result.artifacts.events_digest
204
      artifact_digest: result.artifacts.events_digest,
205
      report: result.report
205 206
    }
206 207
  end
207 208
test/openagents/scv/open_code_executor_test.exs modified +9 -1

@@ -55,6 +55,13 @@ defmodule OpenAgents.SCV.OpenCodeExecutorTest do

55 55
    assert result.events.tool_outcomes == %{"read:completed" => 1}
56 56
    assert result.events.text_event_count == 1
57 57
58
    assert result.report == %{
59
             schema: "openagents.scv.report.v1",
60
             text: "done [REDACTED]",
61
             bytes: 15,
62
             truncated: false
63
           }
64
58 65
    assert result.events.usage == %{
59 66
             input_tokens: 13,
60 67
             output_tokens: 8,

@@ -72,6 +79,7 @@ defmodule OpenAgents.SCV.OpenCodeExecutorTest do

72 79
    refute File.read!(result.artifacts.events_path) =~ "fixture-secret-key"
73 80
    assert File.read!(result.artifacts.events_path) =~ "[REDACTED]"
74 81
    refute File.read!(result.summary_path) =~ "READ_ONLY"
82
    refute File.read!(result.summary_path) =~ "fixture-secret-key"
75 83
    refute File.exists?(Path.join([Path.dirname(result.summary_path), "scratch"]))
76 84
77 85
    assert {:ok, %{mode: event_mode}} = File.stat(result.artifacts.events_path)

@@ -186,7 +194,7 @@ defmodule OpenAgents.SCV.OpenCodeExecutorTest do

186 194
    printf '%s\n' '{"type":"step_start","timestamp":1,"sessionID":"ses_fixture","part":{"type":"step-start"}}'
187 195
    printf '%s\n' '{"type":"tool_use","timestamp":2,"sessionID":"ses_fixture","part":{"tool":"read","state":{"status":"completed","output":"fixture-secret-key"}}}'
188 196
    printf '%s\n' '{"type":"step_finish","timestamp":3,"sessionID":"ses_fixture","part":{"type":"step-finish","cost":0.00125,"tokens":{"input":13,"output":8,"reasoning":2,"cache":{"read":3,"write":1}}}}'
189
    printf '%s\n' '{"type":"text","timestamp":4,"sessionID":"ses_fixture","part":{"type":"text","text":"done"}}'
197
    printf '%s\n' '{"type":"text","timestamp":4,"sessionID":"ses_fixture","part":{"type":"text","text":"done fixture-secret-key"}}'
190 198
    sleep 0.05
191 199
    """
192 200
  end
test/openagents/scv/open_code_report_test.exs added +46

@@ -0,0 +1,46 @@

1
defmodule OpenAgents.SCV.OpenCodeReportTest do
2
  use ExUnit.Case, async: true
3
4
  alias OpenAgents.SCV.OpenCodeReport
5
6
  test "collects only prose text events" do
7
    report =
8
      OpenCodeReport.new()
9
      |> OpenCodeReport.ingest(
10
        Jason.encode!(%{"type" => "tool_use", "part" => %{"output" => "private source"}})
11
      )
12
      |> OpenCodeReport.ingest(
13
        Jason.encode!(%{"type" => "text", "part" => %{"text" => "First finding"}})
14
      )
15
      |> OpenCodeReport.ingest("not-json")
16
      |> OpenCodeReport.ingest(
17
        Jason.encode!(%{"type" => "text", "part" => %{"text" => "Second finding"}})
18
      )
19
      |> OpenCodeReport.summary()
20
21
    assert report == %{
22
             schema: "openagents.scv.report.v1",
23
             text: "First finding\nSecond finding",
24
             bytes: 28,
25
             truncated: false
26
           }
27
28
    refute inspect(report) =~ "private source"
29
  end
30
31
  test "bounds a report without producing invalid UTF-8" do
32
    oversized = String.duplicate("🚀", 9_000)
33
34
    report =
35
      OpenCodeReport.new()
36
      |> OpenCodeReport.ingest(
37
        Jason.encode!(%{"type" => "text", "part" => %{"text" => oversized}})
38
      )
39
      |> OpenCodeReport.summary()
40
41
    assert report.bytes <= 32_768
42
    assert byte_size(report.text) == report.bytes
43
    assert String.valid?(report.text)
44
    assert report.truncated
45
  end
46
end

This page updates live while a promote is in flight · changelog