Skip to content

fix(coding-agent): bound subagent live updates instead of re-sending the transcript - #314

Closed
liuedcson wants to merge 8 commits into
mainfrom
fix/subagent-live-update-amplification
Closed

liuedcson wants to merge 8 commits into
mainfrom
fix/subagent-live-update-amplification

Conversation

@liuedcson

@liuedcson liuedcson commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A blocking subagent call re-sent the child's entire transcript on every child event, and every event is one persisted line.

parseJsonEvent calls onUpdate for each streamed delta and each tool chunk; execute.emit then built details.results[].messages = [...record.messages]; the Agent Loop turns that into a tool_execution_update; and --mode json / --mode rpc write one JSONL line per event. messages only changes at message_end, so every update in between repeats an identical array byte for byte.

Local reproduction (offline, no benchmark harness, no network)

docs/research/2026-10-27-subagent-live-update-amplification/ — a fake --mode rpc child streams turns/deltas/tool results; the parent is a real AgentSession with the real step-subagent extension, and the log is the real toJsonEvent output. Only the model (faux provider) and the child's token stream are test doubles.

child before after updates largest update
20 turns x 60 deltas, 20 KB tool results 268.05 MiB 0.32 MiB 1367 -> 2 0.42 MiB -> ~0
40 turns x 150 deltas, 40 KB tool results 4992.61 MiB 0.22 MiB 6327 -> 2 1.63 MiB -> ~0

Before: 99.8% of the bytes sit inside messages, from 82 distinct snapshots emitted 6,327 times. That matches the benchmark logs (single files up to 11.7 GiB, P95 4.28 GiB, 2.8 MB updates).

Change

Four bounds, each closing a different part of the hole:

  1. Explicit allowlist projection for both live and final records. Nothing is spread from the runner, so an unknown field cannot smuggle unbounded state. messages never appears in a live update; a chain's {previous}-expanded task is clipped.
  2. Coalescing to one publish per 100 ms — the cadence the bash tool already uses. Status transitions are coalesced too: publishing them immediately made a parallel batch O(tasks^2) (measured: 600 tasks, 530 MiB of live payload). The terminal state is flushed before the call returns.
  3. Per-update roster budget (48 KiB + omittedResults) because MAX_PARALLEL_TASKS is 1000; the background lane recomputes the omitted count from each update's own total so the header always reports the real task count.
  4. Per-call final budget (256 KiB of heavy fields + O(tasks) metadata). A field the budget rejects is dropped, never kept at full size; activeText falls back to the full transcript so trimming can never render (no output). Model-facing content is untouched and still goes through the existing 50 KiB retention.

modes/json-event.ts adds a generic backstop: one tool_execution_update over 256 KiB of serialized bytes keeps only the tail of its text plus a marker. It measures after JSON escaping (a control character is six bytes), clips a pathological id so the loop always converges, and never applies to tool_execution_end.

Same treatment competitors already apply: ephemeral/durable event typing, a transient blacklist at the storage boundary, history updates only at message_end/tool_execution_end, and a child transcript that lives in its own session rather than inside the parent's progress events.

Blast radius

  • Blocking calls and background lanes both go through execute.emit; the lane widget updates at 10 Hz and its header counts every task.
  • A status transition shorter than 100 ms may not reach the wire. Real subagents take seconds; the terminal state is always flushed. Three existing tests asserted the old immediate-publish timing and now wait one tick, with identical assertions.
  • History rendering prefers activeText then the newest assistant text; both are retained (50k characters, the renderer's own truncation point).
  • StepSubagentDetails gains optional omittedResults and messagesTruncated.
  • docs/json.md / docs/rpc.md document the oversized-update shape (stdout only; extension handlers see the original event).

Validation

  • packages/coding-agent: 5002 passed / 58 skipped. Only stable failures are 6 completion-check-cli cases that fail identically on pristine main here (git worktree precondition). subagent-invocation is a known intermittent CLI-startup timeout; it passes standalone.
  • New: end-to-end subagent-live-update-rpc.test.ts (real spawned rpc child), subagent-live-update-budget.test.ts, json-event-live-update-budget.test.ts, plus roster-count regressions in subagent-list-widget.test.ts and subagent-background-tui.test.ts. 44 cases across the five files.
  • Reviewer-measured worst cases after the change: 200 tasks x 300 KB answers -> 313 KiB of details (was 48.94 MiB mid-review); 1000 tasks with control-character tool output -> 457 KiB (was 9.13 MiB); 600-task background lane reports /600 complete throughout.
  • pnpm run check passes (biome, boundary checks, tsgo --noEmit, browser smoke); git diff --check clean.

Deliberately not in this PR

  • The final result is still echoed by ~5 event types, so one call costs ~5 x 100 KiB rather than a single copy. Compressing that means changing message-level event shapes.
  • The last flushed live frame uses the live roster, so it can show "N not shown" for one frame before tool_execution_end carries the complete result.
  • The stdout/agent-loop queues are still unbounded; this fixes payload size, not backpressure.

…the transcript

A blocking `subagent` call mirrored every child event into one
`tool_execution_update`. `parseJsonEvent` fires on each streamed delta, and
each update carried a copy of the child's whole `messages` array. Only
`message_end` changes that array, so every update between two of them was a
byte-identical repeat, and the `json`/`rpc` modes write one JSONL line per
event — the parent's event log became the transcript squared.

Measured with a local rpc child streaming 40 turns x 150 deltas with 40 KB
tool results: 4,992 MiB of JSONL, 6,327 updates, 82 distinct snapshots, 99.8%
of the bytes inside `messages`, largest single line 1.63 MiB. That matches the
benchmark logs (P95 4.28 GiB, max 11.7 GiB, 2.8 MB updates).

Three bounds, each closing a different part of the hole:

- The live projection carries no `messages`; it keeps only a short tail of the
  streamed text and tool output. The transcript is published once, in the final
  result.
- Updates are coalesced to one per 100 ms (the bash tool's cadence). Status
  transitions still publish immediately, so a live list never misses a change,
  and a trailing update is flushed before the call returns.
- The final result keeps the newest messages that fit 64 KiB. `activeText`,
  `sessionId` and `sessionFile` keep the outcome reachable.

`toJsonEvent` also caps a single `tool_execution_update` at 256 KiB and
replaces an oversized one with the tail of its text plus a marker. That is a
generic backstop for any tool that re-sends accumulated state; the terminal
event is never bounded.

Same scenario after the change: 0.31 MiB, 3 updates, 0.06 MiB final result.
…ery field

Review follow-up. The first pass removed `messages` from live updates and
coalesced them, but three paths still carried data the bound did not cover:

- The live record was `{...record, ...}` with a few fields patched, so any
  unbounded field passed through. A chain splices the previous step's whole
  answer into the next step's task, and every update during that step repeated
  it: 10 Hz x 500 KB is unbounded regardless of the transcript fix.
- The final record bounded only `messages`. A runner reporting a 300 KB final
  answer put it in `activeText`, which several terminal events then echo.
- The wire backstop counted characters instead of serialized bytes, so a string
  of control characters (six-byte escapes) still exceeded 256 KiB.

Live and final records are now explicit allowlists with per-field budgets, the
final `activeText` falls back to the full transcript so trimming can never render
as "(no output)", and the wire bound measures the serialized event and halves
the retained tail until it fits (`tool_execution_end` is still never bounded).

Emitter ownership is closed too: a consumer that throws is attributed to the
call instead of escaping a timer, late callbacks after return are dropped, and
`mapWithConcurrency` settles every worker before returning so a fail-fast
rejection cannot leave lanes publishing into a closed emitter.

Tests now cover the real path (a spawned `--mode rpc` child through the rpc
adapter, parser, orchestrator, agent loop and json projection) plus chain task
splicing, coalescing cadence, the truncation marker, the single-oversized-message
fallback, late callbacks, and consumer failures. 19 cases total.
…bounds

Second review round. Three findings were real:

- `boundLiveUpdate` could spin forever. At `kept.length === 1`, `slice(-0)`
  returns the whole string, so the halving loop never advanced. Truncating the
  envelope was also missing, so the loop could not converge at all once
  `toolCallId` alone exceeded the budget.
- Status transitions bypassed the coalescing window. Each transition re-sent the
  whole roster, so a parallel batch was O(tasks^2): 600 tasks wrote 530 MiB of
  live payload in one call. Every update now shares the 100ms tick, the cadence
  the bash tool uses, and the final flush still guarantees the terminal state.
  Measured after the change: 200 tasks, < 60 publishes, < 128 KiB each,
  < 2 MiB total.
- The roster itself was unbounded (`MAX_PARALLEL_TASKS` is 1000). Live records
  now stop at 48 KiB of serialized bytes and report `omittedResults`; the
  background lane merges by index so an omitted tail never erases known lanes.

Also from review: `finalRecord` was still a spread, so unbounded identity/
path fields could reach the terminal details — it is now bounded too, records
share one 256 KiB per-call budget, and `activeText` goes back to 50k characters
so Ctrl+O does not silently shrink. The wire cap clips ids, measures serialized
bytes, and the dispatch/flush error order keeps the real failure.

Contract docs updated (`json.md`, `rpc.md`): oversized updates drop
`args`/`details` and keep a text tail, stdout-only, extension handlers unaffected.

Three existing tests asserted the old immediate-publish timing; they now wait
for one tick, which is the new documented cadence, with the same assertions.
Third review round. Two regressions came from the previous commit:

- `tailChars(value, max)` returned the whole value when `max <= 1`
  (`slice(-(max - 1))` is `slice(-0)` is the whole string). The final details
  share one per-call budget, so after the first records consume it the allowance
  drops to zero and every later record kept its full text: 200 tasks with 300 KB
  answers produced 48.94 MiB of details, and 1000 tasks extrapolate to ~1 GiB.
  A zero allowance now yields an empty value, and a field the budget rejects is
  dropped rather than kept anyway.
- The final record was still a spread, so unbounded identity/path fields reached
  the details. It is now the same explicit allowlist as the live projection.

`omittedResults` was written but never read: a 600-task batch displayed
"174/174 complete" because renderers counted the truncated roster. The widget
header, the "more" footer and `subagentListSignature` now count the real total,
and the background lane drops the flag after index-merging (its roster is
complete again, so keeping it would double-count).

Also corrected the docs that still described the previous behaviour: status
transitions are coalesced (a sub-100ms transition may not reach the wire), the
final text cap is 50k, flush happens outside `finally`, and the final bound is
256 KiB of heavy fields plus O(tasks) metadata.

New regressions: 60 tasks x 300 KB answers stay inside the per-call budget (the
exhausted-allowance path), and a byte-bounded roster reports the real task count
and republishes when the count changes.
…ring it

A live roster stops at its byte budget, and `mergeResults` can only recover
records the lane already saw — never the ones it has not been sent yet. Clearing
`omittedResults` after merging therefore froze a 600-task lane at its published
prefix: the background header reported "0/202 complete" for 600 tasks, and the
shared multi-lane widget under-counted for the same reason.

The count is now recomputed as `updateTotal - merged.length`, and the combined
lane widget sums it across lanes. A 200-task background lane now reports
"/200 complete" in every published header, with the rest shown as "not shown"
rather than counted as failures.

Also: the live projection had lost `cancelledPrompts` in the allowlist rewrite,
`task`/`stderr` now honour the budget's rejection instead of keeping the full
value, and the widget's failed count no longer treats an omitted record as one.
…reaches zero

`rosterFields` returned only `results` once the merge recovered the whole
roster, but the lane spreads the update's own details underneath it. The
update's `omittedResults` therefore survived and counted records that had just
been recovered: a complete 3-task roster followed by a "1 record + 2 omitted"
update reported 5 tasks.

The key is now always written, with `undefined` at zero, and the merged roster is
capped at the current turn's total so an older, larger roster cannot leak in on a
reused lane. Covered by a unit test for both directions: merge recovers
everything (count cleared) and lane starts empty (count recomputed).
…budget

Two fields were still rendered from a bare `tailChars` and never charged to the
shared budget, so a batch with large control-character tool output produced
details that ignored the bound: 1000 tasks reached 9.13 MiB. They now go through
the same `take()` gate as every other heavy field (1000 tasks: 456 KiB).

Failed records also claim their `stderr`/`errorMessage` before the progress text.
Previously the budget was spent in record order, so later failures carried no
reason at all and the lane summary printed "no error detail captured".
…ount

CI caught two test problems, no product change:

- The parallel-batch regression asserted a fixed publish count, but the batch
  spends one tick per 100ms of wall clock, so a 2-core runner legitimately
  publishes more than a laptop for the same 200 tasks (121 vs <60). It now
  bounds publishes by `elapsed / 100ms + slack`, which still catches the
  quadratic regression on any hardware, and a second case asserts that an
  instantly finished batch publishes at most a handful of times. That second
  case is clock-independent and is the direct guard for per-status publishing.
- `settle` in the idle-TTL test waited on virtual time only, but the lane's
  notification awaits real file I/O that virtual time cannot advance, so the
  500 fast iterations could finish before the read resolved. It now also holds a
  real deadline and hands control back to the event loop between ticks. The
  assertion is unchanged: the notification must arrive.

The idle-TTL test already failed this way on `main` (CI run 119736908986), so the
hardening is not a behaviour change.
@liuedcson

Copy link
Copy Markdown
Collaborator Author

Superseded by #318.

That PR kept live progress on the persisted stream and bounded each producer's payload: per-field allowlists, a 100ms window, a 48 KiB roster cap and a 256 KiB terminal budget. It took the log from 4,992 MiB to 0.22 MiB, but the mechanism itself produced four review-blocking defects — a infinite loop, an O(tasks^2) publish path, a budget that restored full values once exhausted, and a lane counter that froze. All four were in the throttling machinery, not in the data it was throttling.

#318 removes the need for it: and are declared non-durable frames, and / write facts only by default. Progress keeps its live audience through an explicit opt-in, which the subagent runner uses for its own child. The same reproduction now writes 0.008 MiB for a 20-, 40- or 80-turn child — independent of the child's size rather than proportional to it.

@liuedcson liuedcson closed this Oct 28, 2026
@liuedcson

Copy link
Copy Markdown
Collaborator Author

Superseded by #318.

That PR kept live progress on the persisted stream and bounded each producer's payload: per-field allowlists, a 100ms window, a 48 KiB roster cap and a 256 KiB terminal budget. It took the log from 4,992 MiB to 0.22 MiB, but the mechanism itself produced four review-blocking defects: a slice(-0) infinite loop, an O(tasks^2) publish path, a budget that restored full values once exhausted, and a lane counter that froze. All four were in the throttling machinery, not in the data it was throttling.

#318 removes the need for it: message_update and tool_execution_update are declared non-durable frames, and --mode json / --mode rpc write facts only by default. Progress keeps its live audience through an explicit opt-in, which the subagent runner uses for its own child. The same reproduction now writes 0.008 MiB for a 20-, 40- or 80-turn child, independent of the child's size rather than proportional to it.

liuedcson added a commit that referenced this pull request Oct 28, 2026
Review follow-ups on the frame/fact split. The classification and the
transport filter were right; three things around them were not.

The final details share one 128 KiB budget, so plain in-order allocation let
early records spend it: 10 of 20 agents in a review batch lost `activeText`
entirely, and because `messages` no longer exists on the projection the
renderer fell through to `(no output)` while the heading still read
"20/20 complete". At 200 tasks, 158 records also lost their task, so a large
batch showed unnamed rows. Each record now reserves a small floor for its task,
output, and failure diagnostics outside the shared budget, and the rest is divided
across the records still to project (`remaining / recordsLeft`) instead of being
claimed first-come. 20 x 12 KB and 200 x 5 KB batches keep every identity, keep
output on every record that reported it, keep a reason on every failure, and
stay bounded at 134 KiB / 190 KiB with the total independent of child output.

The 256 KiB wire backstop was documented in `docs/json.md`, `docs/rpc.md` and
the research note but never moved out of the closed #314, so an opted-in frame
was unbounded (a 4 MiB update produced a 12 MiB line). `toJsonEvent` now bounds
only `tool_execution_update`, measures serialized bytes after JSON escaping,
keeps the text tail with `[live output truncated]`, drops `args`/`details`, and
clips both identifiers so the shrink loop always converges. Terminal results are
untouched, and in-process subscribers never pass through it.

The opt-in had no entry point: `--include-live-events` was absent from
`printHelp`, `RpcClient` had no typed way to request frames, and the RPC example
plus its README snippets spawn `--mode rpc` directly and had silently stopped
streaming. All four now request it explicitly, and the RPC event table marks the
two frame types as opt-in rather than listing them as ordinary events.

Also: export `sessionEventDurability` / `isEphemeralSessionEvent` and state the
"every serialized transport filters on this" invariant where the next author
looks, so a new host cannot reimplement it; count omitted live records in lane
headlines and progress without inventing statuses for them; observe a background
frame-delivery rejection and report it through the awaited tool result instead of
leaving an unhandled promise; document that a tool update is a replacement
snapshot; and drop the unrelated `generatedAt` churn in the provider manifest.
liuedcson added a commit that referenced this pull request Oct 28, 2026
Review follow-ups on the frame/fact split. The classification and the
transport filter were right; three things around them were not.

The final details share one 128 KiB budget, so plain in-order allocation let
early records spend it: 10 of 20 agents in a review batch lost `activeText`
entirely, and because `messages` no longer exists on the projection the
renderer fell through to `(no output)` while the heading still read
"20/20 complete". At 200 tasks, 158 records also lost their task, so a large
batch showed unnamed rows. Each record now reserves a small floor for its task,
output, and failure diagnostics outside the shared budget, and the rest is divided
across the records still to project (`remaining / recordsLeft`) instead of being
claimed first-come. 20 x 12 KB and 200 x 5 KB batches keep every identity, keep
output on every record that reported it, keep a reason on every failure, and
stay bounded at 134 KiB / 190 KiB with the total independent of child output.

The 256 KiB wire backstop was documented in `docs/json.md`, `docs/rpc.md` and
the research note but never moved out of the closed #314, so an opted-in frame
was unbounded (a 4 MiB update produced a 12 MiB line). `toJsonEvent` now bounds
only `tool_execution_update`, measures serialized bytes after JSON escaping,
keeps the text tail with `[live output truncated]`, drops `args`/`details`, and
clips both identifiers so the shrink loop always converges. Terminal results are
untouched, and in-process subscribers never pass through it.

The opt-in had no entry point: `--include-live-events` was absent from
`printHelp`, `RpcClient` had no typed way to request frames, and the RPC example
plus its README snippets spawn `--mode rpc` directly and had silently stopped
streaming. All four now request it explicitly, and the RPC event table marks the
two frame types as opt-in rather than listing them as ordinary events.

The record also stops repeating what a boundary only closes over. `turn_end`
carries the assistant message and its tool results, `agent_end` carries every
message of the run, and all of it had already travelled as its own
`message_start`/`message_end` (a tool result also as `tool_execution_end`).
Projected both ways on the same runs, those two events were 40.2% of a 100-task
record (713,553 -> 426,615 bytes) and 51.6% of a 20-task one whose answer is
40,000 characters. They now carry `{ messageRole, toolResultCount }` and
`{ willRetry, messageCount }`. `messageCount` is the length of that run's
messages array and is documented as informational, not a checksum: the loop can
emit more `message_end` events than it lists. It is not an exactly-once record —
a tool result still travels on `tool_execution_end` plus its message pair, which
the docs state rather than hide.

The projection now owns the policy. `toJsonEvent(event, { includeLiveEvents })`
returns `undefined` for a frame the caller did not opt into, and both transports
write `if (projected)` instead of filtering first. Two call-site filters were
tolerable; a third pair is how one policy silently becomes two, so
`check:session-event-transports` fails the build when `toJsonEvent` is called
outside a known transport, when a transport's own call does not pass
`{ includeLiveEvents }` as its second argument, or when the classification is
referenced outside the projection. It is a "forgot to classify" guard, not a
security boundary, and says so.

Also: export `sessionEventDurability` / `isEphemeralSessionEvent` and state the
"every serialized transport filters on this" invariant where the next author
looks; count omitted live records in lane headlines and progress without
inventing statuses for them; observe a background frame-delivery rejection and
report it through the awaited tool result instead of leaving an unhandled
promise; document that a tool update is a replacement snapshot; and drop the
unrelated `generatedAt` churn in the provider manifest.

Tests that claimed to prove more than they did were tightened. The live-channel
regression accepted any `activeText` containing a token, which a durable
`message_end` also provides, and the fixture emitted frames regardless of the
flag; it now asserts a marker that exists only in a progress frame, the fixture
honours `--include-live-events` like the CLI and can pace itself past the 100ms
repaint window, and removing the flag from the subagent runner fails the test
deterministically. The projection test measures a local tool instead of a child
process that never spawned, and pins the marginal bytes per added character
instead of a loose multiple.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant