fix(delegation): salvage a partial summary when a subagent times out - #123
Merged
juniperbevensee merged 2 commits intoJul 29, 2026
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
When a child exceeds
delegation.child_timeout_seconds, the parent abandons its future — sorun_conversationnever returns and itsfinal_responseis lost. The parent gotsummary: Noneregardless of how much work the child had completed.Found while debugging a live agent. Three research subagents hit the 600s cap:
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_filefails too —So the model sees a missing-file error stacked on top of the timeout.
The fix
run_conversationpublishes its live transcript asagent._session_messagesat 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_summaryrebuilds the child's trailing assistant prose plus a tool trace from that transcript, spills the full text to the delegation cache, and returns it assummarywithpartial: 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:
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
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._session_messagespublish contract itself, so the salvage can't be silently broken by removing that line._session_messagesplus the delegate suites: 1232 passed, 3 skipped.test_approval.py::TestDetectDangerousRm::test_nonrecursive_verification_artifact_cleanup_is_not_dangerousfails — it reproduces on a pristineorigin/mainand 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