From dc2fe99ecfb7408ab32e54d1772079b95cf82bae Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:19:15 -0700 Subject: [PATCH] feat(delegation): mark max_iterations-truncated subagent results for the parent (#86641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegated subagent that exhausts its per-child iteration budget (delegation.max_iterations) still returns a summary, so the result carries status='completed' even though the child's exit_reason is 'max_iterations' and its work was cut off mid-task. The parent then reads 'completed', trusts the partial summary, and only discovers the truncation by parsing the prose (where the child happens to mention 'hit the iteration limit'). That wastes parent turns and risks acting on incomplete work. exit_reason is already computed authoritatively and threaded to every parent-visible surface; it just wasn't reflected anywhere the parent reads at a glance. This surfaces it: - delegate_tool.py: add a parent-visible boolean 'truncated' (= exit_reason == 'max_iterations') to each task entry, alongside the existing exit_reason. - process_registry._format_async_delegation: for both the batch and single-task paths, when truncated -> use a warning icon, append 'TRUNCATED: hit max_iterations — work may be incomplete' to the header/Status line, and prefix the summary with an unmissable truncation notice. status semantics are left unchanged (stays 'completed') so existing icon/summary branch logic and ~10 tests asserting status=='completed' stay valid. Tests: single-task truncated -> banner; single-task clean -> no banner; batch marks only the truncated task, not its clean sibling. 23/23 in the async- delegation suite. Co-authored-by: Teknium --- tests/tools/test_async_delegation.py | 64 ++++++++++++++++++++++++++++ tools/delegate_tool.py | 7 +++ tools/process_registry.py | 21 ++++++++- 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/tests/tools/test_async_delegation.py b/tests/tools/test_async_delegation.py index 238ed6f159..d91ff87990 100644 --- a/tests/tools/test_async_delegation.py +++ b/tests/tools/test_async_delegation.py @@ -761,3 +761,67 @@ def test_gateway_cli_origin_event_left_unrouted(): runner._enrich_async_delegation_routing(evt) assert "platform" not in evt + +def test_single_task_truncation_banner_when_max_iterations(): + """A single async subagent that hit its iteration cap (exit_reason= + max_iterations) must surface a TRUNCATED marker in the formatted result, + even though status stays 'completed' (a summary exists).""" + evt = _make_async_evt( + status="completed", + summary="Did part of the work then ran out of budget.", + exit_reason="max_iterations", + ) + text = format_process_notification(evt) + assert text is not None + assert "TRUNCATED" in text + assert "max_iterations" in text + # The summary is still shown, just flagged. + assert "Did part of the work" in text + + +def test_single_task_no_banner_when_clean(): + """A cleanly-finished subagent must NOT get a truncation banner.""" + evt = _make_async_evt(status="completed", summary="All done.", exit_reason="completed") + text = format_process_notification(evt) + assert text is not None + assert "TRUNCATED" not in text + + +def test_batch_truncation_banner_marks_only_truncated_task(): + """In a batch, only the task that hit max_iterations gets the TRUNCATED + marker; a clean sibling keeps the normal check icon.""" + evt = _make_async_evt( + is_batch=True, + goals=["clean task", "truncated task"], + results=[ + { + "task_index": 0, + "status": "completed", + "summary": "finished cleanly", + "api_calls": 5, + "exit_reason": "completed", + "truncated": False, + }, + { + "task_index": 1, + "status": "completed", + "summary": "cut off mid-work", + "api_calls": 250, + "exit_reason": "max_iterations", + "truncated": True, + }, + ], + ) + text = format_process_notification(evt) + assert text is not None + assert "TRUNCATED" in text + # The clean task's summary and the truncated one's both render... + assert "finished cleanly" in text + assert "cut off mid-work" in text + # ...but the banner is tied to the truncated task, not the clean one. + trunc_pos = text.index("cut off mid-work") + clean_pos = text.index("finished cleanly") + banner_pos = text.index("TRUNCATED") + # The header banner for task 2 appears after task 1's summary. + assert banner_pos > clean_pos + diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 0477c9c921..c84c82592c 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -2870,6 +2870,13 @@ def _run_single_child( "duration_seconds": duration, "model": _model if isinstance(_model, str) else None, "exit_reason": exit_reason, + # Explicit, parent-visible truncation flag. A subagent that + # exhausts its per-child iteration budget still returns a summary, + # so `status` stays "completed" (see above) — without this the + # parent can't tell truncated-but-summarized from cleanly-finished + # work except by parsing the summary prose. exit_reason is computed + # authoritatively from the child's `completed` flag. + "truncated": exit_reason == "max_iterations", "tokens": { "input": ( _input_tokens if isinstance(_input_tokens, (int, float)) else 0 diff --git a/tools/process_registry.py b/tools/process_registry.py index 6babfe69df..309edf6350 100644 --- a/tools/process_registry.py +++ b/tools/process_registry.py @@ -2695,6 +2695,7 @@ def _format_async_delegation(evt: dict) -> str: error = evt.get("error") api_calls = evt.get("api_calls", 0) duration = evt.get("duration_seconds", "?") + truncated = evt.get("truncated") or evt.get("exit_reason") == "max_iterations" dispatched_at = evt.get("dispatched_at") completed_at = evt.get("completed_at") or _time.time() @@ -2735,7 +2736,8 @@ def _format_async_delegation(evt: dict) -> str: r_summary = r.get("summary") r_error = r.get("error") r_goal = goals[idx] if idx < len(goals) else r.get("goal", "") - icon = "✓" if r_status in ("completed", "success") else "✗" + r_truncated = r.get("truncated") or r.get("exit_reason") == "max_iterations" + icon = "⚠" if r_truncated else ("✓" if r_status in ("completed", "success") else "✗") lines.append("") header = f"--- {icon} TASK {idx + 1}/{n}" if r_goal: @@ -2745,9 +2747,17 @@ def _format_async_delegation(evt: dict) -> str: header += f", api_calls={r['api_calls']}" if r.get("duration_seconds") is not None: header += f", {r['duration_seconds']}s" + if r_truncated: + header += ", TRUNCATED: hit max_iterations — work may be incomplete" header += ") ---" lines.append(header) if r_status in ("completed", "success") and r_summary: + if r_truncated: + lines.append( + "[TRUNCATED — subagent hit its iteration cap; the " + "summary below may be incomplete. Verify before relying " + "on it, or re-dispatch the unfinished part.]" + ) lines.append(r_summary) elif r_summary: if r_error: @@ -2787,9 +2797,16 @@ def _format_async_delegation(evt: dict) -> str: if toolsets: lines.append(f"Toolsets: {', '.join(toolsets)}") lines.append(f"Role: {role} Model: {model}") - lines.append(f"Status: {status} API calls: {api_calls} Duration: {duration}s") + _trunc = " [TRUNCATED: hit max_iterations — work may be incomplete]" if truncated else "" + lines.append(f"Status: {status} API calls: {api_calls} Duration: {duration}s{_trunc}") lines.append("--- RESULT ---") if status in ("completed", "success") and summary: + if truncated: + lines.append( + "[TRUNCATED — subagent hit its iteration cap; the summary below " + "may be incomplete. Verify before relying on it, or re-dispatch " + "the unfinished part.]" + ) lines.append(summary) elif status == "interrupted": lines.append(