Skip to content

fix(delegation): salvage a partial summary when a subagent times out - #123

Merged
juniperbevensee merged 2 commits into
mainfrom
dev/juniperbevensee/subagent-partial-summary
Jul 29, 2026
Merged

fix(delegation): salvage a partial summary when a subagent times out#123
juniperbevensee merged 2 commits into
mainfrom
dev/juniperbevensee/subagent-partial-summary

Conversation

@juniperbevensee

Copy link
Copy Markdown
Collaborator

The bug

When a child exceeds delegation.child_timeout_seconds, the parent abandons its future — so run_conversation never returns and its final_response is lost. The parent got summary: None regardless of how much work the child had completed.

Found while debugging a live agent. Three research subagents hit the 600s cap:

WARNING tools.delegate_tool: Subagent 1 timed out after 602.9s
WARNING tools.delegate_tool: Subagent 0 timed out after 602.9s
WARNING tools.delegate_tool: Subagent 2 timed out after 600.1s
  ✗ [1/3] Research whether Andreessen Horowitz (a1  (602.94s)
  ✗ [2/3] Research the full scope of the Rød-Larse  (602.93s)
  ✗ [3/3] Research the 'attorney-client privileged  (600.07s)

Each had completed dozens of tool calls. All three produced nothing.

It compounds: because no summary file is spilled on timeout, the parent's follow-up read_file fails too —

Tool read_file returned error: {"error": "File not found:
  /opt/data/cache/delegation/subagent-summary-0-20260729_120946_475..."}

So the model sees a missing-file error stacked on top of the timeout.

The fix

run_conversation publishes its live transcript as agent._session_messages at turn start. This is a one-line addition — the loop already re-published at the sole rebind site, so the reference stays valid for the whole turn (verified: exactly two binds in the function).

On timeout, _salvage_partial_summary rebuilds the child's trailing assistant prose plus a tool trace from that transcript, spills the full text to the delegation cache, and returns it as summary with partial: true. Summary-budget trimming is already status-agnostic, so the partial flows through the existing context-protection path unchanged.

On honesty of the salvaged output

The text is prefixed with an explicit banner:

[PARTIAL — subagent timed out before writing a final summary. 47 tool call(s): 41 ok, 6 errored. The text below is the child's last reasoning, not a considered wrap-up; treat conclusions as provisional and verify anything load-bearing.]

A timed-out child never wrote a wrap-up. Its last reasoning turn is mid-thought, and the parent must not read it as a conclusion — hence the tool-call tally (including error count) so the parent can judge how much to trust it.

Salvage is best-effort and never raises; a failure there must not mask the underlying timeout.

Testing

  • 15 new tests — trailing-turn retention, tool-call/result pairing by tool_call_id (deliberately out of order, to prove it isn't positional), the PARTIAL banner, trace-without-prose, and 7 malformed-transcript cases including a property that raises.
  • One test pins the _session_messages publish contract itself, so the salvage can't be silently broken by removing that line.
  • Ran every suite touching _session_messages plus the delegate suites: 1232 passed, 3 skipped.
  • test_approval.py::TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerous fails — it reproduces on a pristine origin/main and is unrelated to this change.

Related

Complements #14726 (0-API-call timeout diagnostics). That covers children that never started; this covers children that did substantial work and got cut off.

🤖 Generated with Claude Code

juniperbevensee and others added 2 commits July 30, 2026 09:11
A child that exceeds delegation.child_timeout_seconds had its entire run
discarded. The parent abandons the child's future, so run_conversation
never returns and its final_response is lost — the parent got
summary: None regardless of how much work the child had completed.

Observed on a live agent: three research subagents hit a 600s cap after
completing dozens of tool calls each. All three produced nothing. Worse,
because no summary file was spilled, the parent's follow-up read_file on
cache/delegation/subagent-summary-<idx>-<ts>.txt failed outright, so the
model saw a missing-file error on top of the timeout.

run_conversation now publishes its live transcript as
agent._session_messages at turn start (it already re-published this at
the sole rebind site, so the reference stays valid for the whole turn).
On timeout, _salvage_partial_summary rebuilds the child's trailing
assistant prose and a tool trace from that transcript, spills the full
text to the delegation cache, and returns it as the summary with
partial: true.

The salvaged text is prefixed with an explicit PARTIAL banner stating
the tool-call tally and that conclusions are provisional — a timed-out
child never wrote a considered wrap-up, and the parent must not read its
last reasoning as one. Salvage is best-effort and never raises: a
failure there must not mask the underlying timeout.

Tests: 15 new. Also ran every suite touching _session_messages
(1232 passed, 3 skipped). test_approval.py::TestDetectDangerousRm has a
failure that reproduces on a pristine origin/main and is unrelated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two blocking defects found by an independent audit of this PR. Both made the
salvage emit confidently WRONG data rather than fail, which is worse than the
missing-summary bug it set out to fix.

B1 — the published transcript went stale after compression.
My verification used `grep -E '^\s+messages = '`, which structurally cannot
match the tuple-unpacking form `messages, active_system_prompt =
agent._compress_context(...)`. An AST scan finds 7 binding sites for
`messages` in run_conversation, and 4 of them (lines 1114, 3264, 3519, 3760)
are exactly that form and did NOT re-publish. compress_context returns a NEW
list, so after any of those, agent._session_messages pointed at the abandoned
pre-compression list and never saw another append. The salvage then reported
only pre-compression turns AND a fabricated tally ("0 tool call(s): 0 ok, 0
errored") stated as confidently as a real one. The comment I added asserting
"the single rebind site below re-publishes" was false.

Fixed by re-publishing inside _compress_context itself rather than at each
call site: it is a thin forwarder and the single choke point, so a future
call site is covered automatically instead of silently reintroducing this.

B2 — the "(empty)" failure sentinel was salvaged as reasoning.
A child that gives up after repeated empty-LLM-response retries emits the
literal "(empty)" plus recovery scaffolding (_empty_recovery_synthetic /
_empty_terminal_sentinel). The success path treats that as failure; the
salvage accepted it, so a stuck child yielded a summary of "(empty)" three
times over while its real finding was pushed out of the tail window. Now
filtered, matching the success path's guard.

Also from the audit:
- S1: cap the salvaged body (12k) INSIDE the salvage. _apply_summary_budget
  protects the parent's model context but runs after the progress relay has
  already copied the string onto the gateway websocket, where three 500KB
  turns measured ~1.5 MB. Relay payload now [:500], matching the success path.
- S5: guard each tool_call individually. One non-dict entry raised inside the
  loop and was swallowed by the function-level except, discarding every
  recovered turn.
- N3: removed a claim from the test docstring about a pre-existing read_file
  failure on the spill path. The old timeout path never told the parent that
  path — I invented it.

Tests: 15 new regression tests, including a runtime proof that the salvage
sees post-compression work, and an AST-based guard (not regex) that every
`messages` rebind is accounted for. Verified non-vacuous: 6 of 15 fail
against the pre-fix code. 234 delegation + 647 compression tests pass.

Not addressed here, deliberately: timeout entries now share the parent summary
budget with successful children (audit S2), and the pre-spill can produce a
second file alongside _trim_summary_with_footer's (audit S3). Both are
pre-existing-shape issues worth their own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@juniperbevensee
juniperbevensee merged commit 671b7b7 into main Jul 29, 2026
32 checks passed
@juniperbevensee
juniperbevensee deleted the dev/juniperbevensee/subagent-partial-summary branch July 29, 2026 23:37
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