feat(sdk): journal a redacted, bounded transcript digest for every agent and llm attempt

Merged
KH
1mo ago
+1813-33

The defect

An agent step shows nothing about what it did. The SDK buffers the whole stream-json transcript in memory (packages/sdk/src/worker-cli.ts:253 before this change) and reduces it to the final text and two token counts (packages/sdk/src/worker-usage.ts:18, :29 before this change). Model, provider cost, tool calls, and the failure reason never reach the journal; the raw transcript is discarded when the process exits. An llm step's only evidence was trajectory_tail.error with the entire stderr, unbounded (packages/sdk/src/llm-worker.ts:81 before), which past 16 KiB made the kernel refuse the completion (kernel/relayflowd/src/server.rs:26, :369).

What the run now reports

Every completion of an agent or llm attempt — success and failure alike — carries trajectory_tail.transcript (packages/sdk/src/worker.ts:158, packages/sdk/src/llm-worker.ts:83), built from the frames the usage decoder already parses (packages/sdk/src/worker-usage.ts:46, packages/sdk/src/agent-transcript.ts:347):

FieldSource
attempt, exit_codethe dispatch and the spawn (worker.ts:114, :158) — the JSON output path drops the wrapper by design, so this is the only place they survive
result{provider, model, claude_code_version, session_id, subtype, is_error, stop_reason, num_turns, duration_ms, duration_api_ms, total_cost_usd, usage{input, output, cache_read, cache_creation, thinking}, permission_denials}the init and result frames; total_cost_usd is the provider's own number beside the metered one, which today ignores cache tokens
tools{counts[{name, calls, errors}], last_calls[{seq, name, input_excerpt, result_bytes, is_error}], total_calls, shown_calls, complete}tool_use paired with tool_result by tool_use_id (agent-transcript.ts:421-422); Codex is counted by item.type with complete: false until a fixture exists
final_textresult.result
failure{kind, excerpt}in order: the result frame when is_error/non-success subtype, else the last tool_result with is_error, else the last 1 KiB of stderr (agent-transcript.ts:434-438); a decoder refusal (missing usage on a priced model) replaces it (worker-usage.ts:17)
artifacts{count, paths ≤ 50}the worker's artifact diff
file{path, bytes_total, bytes_kept, frames_total, frames_kept, truncated, sha256}the raw transcript, written beside the PTY socket at <dataDir>/runs/<runId>/steps/<stepId>/attempt-<n>.transcript.jsonl (agent-transcript.ts:97, worker-cli.ts:253, :356, :305); agent steps only — llm steps have no data dir

The digest is evidence, never output: it is stripped from the wrapper before output is journaled (worker.ts:150), and the transcript file is excluded from the step's artifacts when the data dir sits under the cwd (worker-cli.ts:121). flows run prefers the digest's failure excerpt for Detail: and names the transcript file next to the flows replay hint (packages/sdk/src/cli/step-failure.ts:137, :149; packages/sdk/src/cli/run.ts:812); the daemon's process-shaped render still supplies exit code and tails, so the digest cannot shadow it (step-failure.ts:126).

No kernel change. step.complete params are deny_unknown_fields (kernel/relayflowd/src/server/wire.rs:85), so the digest nests under the trajectory_tail field the kernel already accepts (wire.rs:106) and stores verbatim.

Size and truncation rules — enforced, and stated where applied

BoundValueEnforced atHow it is stated
transcript file1 MiB per attempt: first 128 KiB whole, then a rolling 896 KiB tailagent-transcript.ts:17-19, writerone {"type":"relayflow.truncated","bytes_dropped","frames_dropped"} line at the seam (:313); file.truncated, bytes_total vs bytes_kept, frames_total vs frames_kept on the digest
string leaf in a frame64 KiBagent-transcript.ts:21value cut with suffix …[relayflow: N bytes truncated]
system/init framewhitelist {type, subtype, model, claude_code_version, permissionMode, session_id, tools_count, mcp_servers_count}agent-transcript.ts:213relayflow_reduced: true on the frame
digest8 KiB serialized — half the kernel's 16 KiB cap, leaving room for the relay_task siblingagent-transcript.ts:27, :506-508; shrink order last_calls 20→10→0, artifacts.paths 50→10→0, final_text 2 KiB→512 B→gone, failure.excerpt 1 KiB→256 B, counts 32→8shown_calls < total_calls, artifacts.count > paths.length, final_text_truncated, failure.truncated
llm trajectory_tail.error4 KiBagent-transcript.ts:29, llm-worker.ts:85…[N bytes truncated] suffix

Redaction

Applied before any byte is written to the file or lifted into the digest (agent-transcript.ts:140): values of secret-named env vars (:109), NAME=VALUE env dumps by shape whatever the name (:131, :153), relay / sk-ant- / ghp_ github_pat_ / xox*- / AKIA token shapes (:116), PEM private-key blocks, and authorization / x-api-key / x-callback-token / cookie header values with the name kept (:128). redactRelayError now uses the same rules (worker-cli.ts:223).

Left as is: the worker's output wrapper (stdout_tail/stderr_tail) is the pre-existing, unredacted path the daemon renders into verification.detail on failure; the live test pins that nothing this change journals or writes carries the seeded secret (packages/sdk/tests/agent-transcript-live.test.ts:159).

Tests

  • packages/sdk/tests/agent-transcript.test.ts — redaction (:65, :71), init reduction, a 3 MiB synthetic stream kept under 1 MiB with the marker at the seam (:123), digest from a real stream-json capture (tests/fixtures/claude-stream-json-probe.jsonl), failure order, 500 tool calls + 50 artifacts ≤ 8 KiB with every cut stated (:258).
  • packages/sdk/tests/worker-transcript.test.ts — file beside the PTY socket named by attempt, journal payload shape with transcript absent from output (:131), llm error cut to 4 KiB and redacted (:149).
  • packages/sdk/tests/agent-transcript-live.test.ts — built CLI + real daemon + local agent: step.complete accepted with the digest, file where the digest says, terminal diagnostic names it (:124, :159).
  • packages/sdk/tests/step-failure-diagnostic.test.ts:174, :203 — the digest rides beside the daemon render, and a stderr excerpt is not printed twice.
  • kernel/relayflowd/src/server/tests/agent/contract.rs:247 — a digest padded to exactly 8 KiB, beside a relay_task sibling, is admitted and journaled verbatim (:306).

Run: the suites above plus worker-cli, worker-cli-result-exit, worker-cli-cwd, worker-cli-abort, pty-sidechannel, artifact-gates, agent-artifacts(-live), authored-agent-artifacts, agent-relay-*, budget-*, cli-replay, direct-run-failure, resume-failure, stop-process-group, yaml-local-agent-live, flow-executor-chain, live-kernel, provider-trigger-executor, webhook-live, mcp — all green with RELAYFLOWD_BIN set; cargo test -p relayflowd --lib server::tests::agent::contract green. authored-node-runtime.test.ts fails on this machine for a Bun version pin (1.4.0 vs 1.3.14), unrelated.

One regression caught and fixed during the work: opening the writer after openSidechannel put an await between onReady and spawn, so a drive peer's HELLO arrived before canDrive was armed; the writer is opened first (worker-cli.ts:246-253), pinned by pty-sidechannel.test.ts.

Left to the cloud PR

  • Executor: read completed.event.payload.trajectory_tail.transcript in the per-step replay, fill outputSummary, error, model, costUsd, detail (≤ 16 KiB, detailTruncated), validate and stat file.path, return transcripts[].
  • Bootstrap: assemble attempts ≤ 1 MiB with relayflow.attempt / relayflow.attempt.omitted markers, upload to <stepName>/agent.log, then set sandboxId = stepName.
  • Schema + replaceSteps + stepRowToMetadata + RunStepRow: carry model, tokensInput, tokensOutput, costUsd, detail, detailTruncated.
  • Views (existing only): Steps-table collapsible, SelectedStepPanel <dl> entries, agent/[sandboxId] xterm panel lighting up on the uploaded object. No new page, route, tab, or component-as-feature.

🤖 Generated with Claude Code


[!NOTE]
Medium Risk
Touches completion journaling, secret redaction, and failure diagnostics on every agent/llm step; bounded sizes and tests reduce kernel refusal risk, but mis-redaction or digest shape bugs could affect operators and downstream consumers.

Overview
Agent and LLM attempts now leave durable, redacted evidence in the journal and on disk instead of discarding provider stream-json after token extraction.

Workers write a capped per-attempt transcript file beside the PTY socket (attempt-<n>.transcript.jsonl) and journal a ≤ 8 KiB digest under trajectory_tail.transcript on every completion (with relay_task / bounded error siblings unchanged). The digest carries model, cost, tool summaries, failure excerpts, artifact paths, and a pointer to the file; it is never merged into step output. LLM failures use redacted, 4 KiB-capped trajectory_tail.error so completions stay under the kernel’s 16 KiB tail limit.

flows run step-failure rendering prefers the digest’s failure excerpt for Detail: and prints a Transcript: path when present, without duplicating stderr-shaped excerpts. A new kernel contract test asserts an 8 KiB digest plus relay_task is admitted verbatim—no relayflowd changes.

Reviewed by Cursor Bugbot for commit fc1f25c2069ab7d1bb5e6b6e589e65479513b87c. Bugbot is set up for automated code reviews on this repo. Configure here.


Summary by cubic

Journals a redacted, bounded transcript digest with every agent and llm attempt, so runs show what the model did instead of only final text and token counts or unbounded failure stderr.

What changed

  • trajectory_tail.transcript now carries provider metadata, tool call summaries, final text, failure excerpt, attempt/exit code, artifact paths, and the transcript file pointer.
  • Agent steps with a data dir also write the raw transcript beside the PTY socket, capped at 1 MiB with a relayflow.truncated marker.
  • The digest is stripped before output is journaled; the transcript file is excluded from artifacts when it sits under the cwd.
  • flows run prefers the digest's failure excerpt for Detail: and prints the transcript file path next to the replay hint.

Bounds and redaction

  • The digest is capped at 8 KiB by bounding provider strings as they enter and falling back to fixed-size core fields; every cut is recorded on the digest, and the fallback marks itself core_only.
  • Secret-named env vars, token shapes, PEM private keys, and header values are redacted before any write or journal entry, including before truncation cuts.
  • The transcript writer refuses symlinks and opens before the PTY sidechannel, fixing a race where HELLO arrived before canDrive was armed.
  • llm error tails are bounded to 4 KiB; no kernel change, since the digest rides under trajectory_tail, which the kernel already stores verbatim.