feat(sdk): journal a redacted, bounded transcript digest for every agent and llm attempt
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):
| Field | Source |
|---|---|
attempt, exit_code | the 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_text | result.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
| Bound | Value | Enforced at | How it is stated |
|---|---|---|---|
| transcript file | 1 MiB per attempt: first 128 KiB whole, then a rolling 896 KiB tail | agent-transcript.ts:17-19, writer | one {"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 frame | 64 KiB | agent-transcript.ts:21 | value cut with suffix …[relayflow: N bytes truncated] |
system/init frame | whitelist {type, subtype, model, claude_code_version, permissionMode, session_id, tools_count, mcp_servers_count} | agent-transcript.ts:213 | relayflow_reduced: true on the frame |
| digest | 8 KiB serialized — half the kernel's 16 KiB cap, leaving room for the relay_task sibling | agent-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→8 | shown_calls < total_calls, artifacts.count > paths.length, final_text_truncated, failure.truncated |
llm trajectory_tail.error | 4 KiB | agent-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 realstream-jsoncapture (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 withtranscriptabsent fromoutput(: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.completeaccepted 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 arelay_tasksibling, 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.transcriptin the per-step replay, filloutputSummary,error,model,costUsd,detail(≤ 16 KiB,detailTruncated), validate andstatfile.path, returntranscripts[]. - Bootstrap: assemble attempts ≤ 1 MiB with
relayflow.attempt/relayflow.attempt.omittedmarkers, upload to<stepName>/agent.log, then setsandboxId = stepName. - Schema +
replaceSteps+stepRowToMetadata+RunStepRow: carrymodel,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.transcriptnow 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.truncatedmarker. - The digest is stripped before
outputis journaled; the transcript file is excluded from artifacts when it sits under the cwd. flows runprefers the digest's failure excerpt forDetail: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
canDrivewas 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.