fix(delegation): preserve redacted partial output when a subagent times out (rebase of #65824 + background path) - #84085
Conversation
When a `delegate_task` child times out after completing real work, the
parent receives `{"status": "timeout", "summary": None}` and every
completed tool result is discarded. For a research child that finishes
several tool calls and then hangs on the last one, the parent is left
with nothing to continue from even though the work existed.
On timeout, snapshot the child's in-memory transcript and reuse the
existing bounded overlay extractor to attach up to 8 tool outputs
(600 chars each) as `partial: true` + `partial_output_tail[]`, and
render that tail in the async/background re-injection path so the
background workflow surfaces it too.
Security: `_extract_output_tail` now applies
`redact_sensitive_text(..., force=True)` BEFORE truncation, so a
credential cannot be split at the boundary into an unrecognisable —
and therefore unredacted — fragment. Any redaction failure drops the
entire tail (fail closed) and preserves the legacy timeout schema.
Scope guards:
- Only the timeout path is touched; ordinary exceptions keep the
existing error schema.
- The zero-API-call (`before_first_llm_call`) diagnostic behaviour is
unchanged.
- The success-path result entry is untouched, so the wire shape stays
byte-identical for completed children.
This is a rebase of NousResearch#65824 onto current main, plus the background
rendering gap identified in review on that PR. Original implementation
by Charles Cha (@ypwcharles); the conflict resolution keeps main's
`_late_pending_steer` handling on the timeout entry alongside the new
partial fields.
Verified by reproduction, not by reading the diff. Against a stub
OpenAI-compatible endpoint that lets a real child complete one real
`terminal` tool call and then hangs until `child_timeout_seconds`
fires in the `after_llm_calls` phase:
before (origin/main): status=timeout api_calls=2 summary=None
partial=None tail=0
after (this branch): status=timeout api_calls=2 summary=None
partial=True tail=1
[terminal] {"output": "...", "exit_code": 0}
The same captured result entry rendered through
`_format_async_delegation()` (the background re-injection path)
contains the child's real output and the "Partial output (redacted)"
label with this change, and neither without it.
Tests: `tests/tools/test_delegate.py`,
`tests/tools/test_delegate_subagent_timeout_diagnostic.py`,
`tests/tools/test_process_registry.py` — 166 passed, 0 failed.
Reverting `tools/delegate_tool.py` + `tools/process_registry.py` to
the merge base fails 14 of them, so the regressions bite. Full
`tests/tools/` run: 6055 passed / 73 failed, where the identical 73
also fail on unmodified origin/main on this host (Daytona, Discord
and other environment-dependent suites) — no new failures.
Co-authored-by: Charles Cha <92324143+ypwcharles@users.noreply.github.com>
Lupin
left a comment
There was a problem hiding this comment.
Code review — partial redacted output on timeout
Reviewed the full diff (4 files: delegate_tool.py, process_registry.py + 2 test files) against current main. Verified against the source tree: redact_sensitive_text(..., force=True) is a real parameter (agent/redact.py:772-775), and _session_messages is a genuine attribute assigned to the child agent in production (conversation_loop.py, run_agent.py), so getattr(child, "_session_messages", None) is legitimate — the new tail isn't dead code that only works in the stub.
What's good
- Redact before truncating — the comment at delegate_tool.py is exactly right: truncating first could split a credential at the max_chars boundary and leave an unredacted fragment. Correct ordering.
is_erroris computed from the raw content before redaction, so error detection still works on markers buried in block-wrapped content while the preview is sanitized. Good ordering.- Fail-closed everywhere: malformed live messages,
_session_messagesraising, extractor throwing, and non-timeout exceptions all degrade to the legacy schema without leaking (tests cover each). - Non-timeout exceptions never expose partial output — matches the security boundary the docstring promises.
- Bounded tail (8 entries / 600 chars) and dedup of preview into both the progress event and the error entry from one source variable.
Findings
WARNING — File:Line tools/delegate_tool.py:638-643 — _extract_output_tail is shared: it powers the normal delegation overlay "Output" section (cc-swarm-parity feature, non-timeout path), not just the new timeout tail. The new try/except returns [] on any redaction failure. That means a single redactor hiccup (config issue, pattern edge case) now blanks the entire existing output overlay for all delegations — not just the timeout case the PR targets. The timeout call-site explicitly wants fail-closed ("observability damage must never become exposure"), but the ordinary path previously degraded gracefully and now silently loses its whole Output section. Suggest scoping the fail-closed return [] to the timeout caller (or passing a fail_closed flag), keeping the normal path best-effort.
SUGGESTION — File:Line tools/process_registry.py:746-750 — _render_partial_output_tail uses entry.get("tool") / entry.get("preview") with defaults, but every consumer upstream already filters/validates these fields (delegate_tool.py:665-675). The defensive defaults are harmless, just slightly redundant; fine as-is.
SUGGESTION — Test gap — test_timeout_redacts_secrets_from_partial_tail and test_output_tail_preserves_plain_output_and_redacts_secrets both use a single sk- style secret. Consider one case with a secret split across the truncation boundary (e.g. prefix + max_chars - 3 + secret tail) to lock in the "redact-before-truncate" behavior that the implementation comment explicitly calls out — that's the subtle case that motivated the ordering and would otherwise regress silently.
Verdict
Solid, well-tested PR. The security ordering is correct and the fail-closed discipline is consistent. The one substantive point is the shared-function side effect in the Warning — worth confirming the non-timeout overlay path still shows output when the redactor is healthy, and ideally scoping the hard-fail to the timeout path.
COMMENT
Review on NousResearch#84085 caught a real regression in the previous commit. `_extract_output_tail` is SHARED. It powers the ordinary delegation Output overlay on the non-timeout success path (delegate_tool.py, the cc-swarm-parity feature), not only the new timeout tail. The blanket `return []` on redaction failure meant a single redactor hiccup — a config issue or one pattern edge case — silently blanked the entire Output section for ALL delegations, where upstream degraded gracefully. Confirmed by reproduction before changing anything: with a redactor patched to raise, `_extract_output_tail` returned `[]` and the overlay lost output it had shown moments earlier with a healthy redactor. Add a `fail_closed` parameter, default False: - Default (display callers, ordinary overlay): best-effort. Skip only the tool result that could not be redacted and keep rendering the rest, so one bad entry cannot destroy a working feature. - `fail_closed=True` (timeout evidence call-site): unchanged hard fail. Evidence surfaced from a FAILED child is a security boundary, not a display nicety, so it emits nothing rather than risk an unredacted preview. Verified with a three-result fixture where the redactor raises on the middle one: the overlay keeps both healthy findings and drops the unredactable one, while the fail-closed caller emits nothing. Also address the review's test-gap suggestion with a truncation-boundary case. The first attempt was VACUOUS — it passed even under truncate-before-redact sabotage, because `sk-`/`ghp_`-style patterns still match after truncation. Searched for a case that actually bites: only patterns with a minimum-length quantifier are vulnerable. The Codex pattern `gAAAA[A-Za-z0-9_=-]{20,}` cut to 25 chars drops below the 20-char body minimum, stops matching, and leaks `credential gAAAABBBBBBBBB` in the clear. Note the redactor elides the middle of a matched token (`gAAAAB...BBBB`) rather than deleting it, so the test asserts on the surviving raw body run rather than absence of the prefix. Non-vacuity is proven: sabotaging the implementation to `redact(content[:max_chars])` fails this test. Tests: the previous fail-closed test is split into a default best-effort case and an explicit `fail_closed=True` case, plus the boundary test. `tests/tools/test_delegate.py` 81 passed; focused trio 168 passed / 0 failed. Full `tests/tools/` 6057 passed / 73 failed, the identical 73 failing on unmodified origin/main on this host — no new failures. The end-to-end timeout reproduction still returns `partial=True` with the child's real tool output. Co-authored-by: Charles Cha <92324143+ypwcharles@users.noreply.github.com>
|
Thanks @Lupin — both findings actioned in 8dbecec, and the WARNING was a genuine regression, not a theoretical one. WARNING (shared function) — fixedConfirmed by reproduction before touching anything. With a redactor patched to raise, Went with the
Verified with a three-result fixture where the redactor raises on the middle one: The overlay keeps both healthy findings, drops the unredactable one, and the security-boundary caller still emits nothing. Test gap (boundary-split secret) — added, after a false start worth reportingMy first attempt at this test was vacuous. It used an Only patterns with a minimum-length quantifier are actually vulnerable. Searched for one that bites: the Codex pattern One further wrinkle: the redactor elides the middle of a matched token rather than deleting it, so Non-vacuity is now proven both ways: green with the fix, and sabotaging the ordering fails it with SUGGESTION (redundant defaults in
|
|
Canonical consolidation is now on #65824. I rebased the original implementation onto current
Final related canonical validation on #65824: 205 passed, 0 failed, 4 Windows-only skipped; static/Windows-footgun gates passed across all 6 changed files. Full-suite status and exact head are recorded on #65824. Thank you for doing the rebase and for catching the fail-closed scoping regression. Since #65824 now contains both contributions with git authorship intact, please close #84085 in favor of the canonical PR. |
Review on NousResearch#84085 caught a real regression in the previous commit. `_extract_output_tail` is SHARED. It powers the ordinary delegation Output overlay on the non-timeout success path (delegate_tool.py, the cc-swarm-parity feature), not only the new timeout tail. The blanket `return []` on redaction failure meant a single redactor hiccup — a config issue or one pattern edge case — silently blanked the entire Output section for ALL delegations, where upstream degraded gracefully. Confirmed by reproduction before changing anything: with a redactor patched to raise, `_extract_output_tail` returned `[]` and the overlay lost output it had shown moments earlier with a healthy redactor. Add a `fail_closed` parameter, default False: - Default (display callers, ordinary overlay): best-effort. Skip only the tool result that could not be redacted and keep rendering the rest, so one bad entry cannot destroy a working feature. - `fail_closed=True` (timeout evidence call-site): unchanged hard fail. Evidence surfaced from a FAILED child is a security boundary, not a display nicety, so it emits nothing rather than risk an unredacted preview. Verified with a three-result fixture where the redactor raises on the middle one: the overlay keeps both healthy findings and drops the unredactable one, while the fail-closed caller emits nothing. Also address the review's test-gap suggestion with a truncation-boundary case. The first attempt was VACUOUS — it passed even under truncate-before-redact sabotage, because `sk-`/`ghp_`-style patterns still match after truncation. Searched for a case that actually bites: only patterns with a minimum-length quantifier are vulnerable. The Codex pattern `gAAAA[A-Za-z0-9_=-]{20,}` cut to 25 chars drops below the 20-char body minimum, stops matching, and leaks `credential gAAAABBBBBBBBB` in the clear. Note the redactor elides the middle of a matched token (`gAAAAB...BBBB`) rather than deleting it, so the test asserts on the surviving raw body run rather than absence of the prefix. Non-vacuity is proven: sabotaging the implementation to `redact(content[:max_chars])` fails this test. Tests: the previous fail-closed test is split into a default best-effort case and an explicit `fail_closed=True` case, plus the boundary test. `tests/tools/test_delegate.py` 81 passed; focused trio 168 passed / 0 failed. Full `tests/tools/` 6057 passed / 73 failed, the identical 73 failing on unmodified origin/main on this host — no new failures. The end-to-end timeout reproduction still returns `partial=True` with the child's real tool output. Co-authored-by: Charles Cha <92324143+ypwcharles@users.noreply.github.com>
fix(delegation): preserve redacted partial output when a subagent times out — the redact-before-truncate fix and the fail-open/fail-closed split are well thought out, and the non-vacuity of the boundary test is demonstrated. Observations:
|
What does this PR do?
Rebases #65824 onto current
mainand closes the one gap raised in review on it.When a
delegate_taskchild times out after doing real work, the parent gets{"status": "timeout", "summary": None}and every completed tool result is thrown away. A research child that finishes several tool calls and then hangs on the last one leaves the parent with nothing to continue from, even though the work existed.tools/delegate_tool.pyhard-sets"summary": Noneon the timeout path and never looks at what the child produced.This attaches the child's completed tool outputs to the timeout entry as
partial: true+partial_output_tail[](bounded to 8 entries × 600 chars), and renders that tail in the async re-injection path sodelegate_task(background=true)surfaces it too.Security:
_extract_output_tailappliesredact_sensitive_text(..., force=True)before truncation, so a credential can't be split at the boundary into an unrecognisable — and therefore unredacted — fragment. Any redaction failure drops the entire tail (fail closed) and preserves the legacy timeout schema.Attribution
The implementation is Charles Cha's (@ypwcharles) from #65824 — authorship and
Co-authored-byare preserved on the commit. That PR has beenCONFLICTINGsince early August. This branch is the rebase plus the missing background rendering, opened so the work isn't lost. Happy to close this in favour of #65824 if @ypwcharles would rather push the rebase there.Relative to #65824
main. Conflict resolution keeps main's_late_pending_steerhandling on the timeout entry alongside the newpartial/partial_output_tailfields — neither side's behaviour is dropped._format_async_delegation()rendered timeout tasks fromsummary+erroronly and never readpartial_output_tail, so the field existed on the entry but never reached the parent on thebackground=truepath._render_partial_output_tail()is now applied in both the batch and single-result branches.Type of Change
Scope guards
before_first_llm_call) diagnostic behaviour is unchanged.How to Test
Verified by reproduction, not by reading the diff.
A stub OpenAI-compatible endpoint lets a real child complete one real
terminaltool call, then hangs, sodelegation.child_timeout_secondsfires in theafter_llm_callsphase with genuine completed work in the transcript. Same harness, same input, onlytools/swapped:Feeding that captured result entry through
_format_async_delegation()— the exact pathbackground=trueuses to re-inject a finished fan-out — the rendered block contains the child's real output under aPartial output (redacted):label with this change, and neither without it.Test runs:
scripts/run_tests.sh tests/tools/test_delegate.py tests/tools/test_delegate_subagent_timeout_diagnostic.py tests/tools/test_process_registry.py -q→ 166 passed, 0 failed.tools/delegate_tool.py+tools/process_registry.pyto the merge base fails 14 of those tests, so the regressions genuinely bite.scripts/run_tests.sh tests/tools/ -q→ 6055 passed / 73 failed. The identical 73 also fail on unmodifiedorigin/mainon this host (Daytona, Discord and other environment-dependent suites) — no new failures.Checklist