From cd9f5cc6718050819df17d4a3c07e199faf6e284 Mon Sep 17 00:00:00 2001 From: Markus Phan Date: Tue, 30 Jun 2026 03:02:03 -0700 Subject: [PATCH] fix(delegate): route subagent progress lines through _safe_print for ACP stdio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit delegate_task's per-task completion display emitted lines like "✓ [1/3] Research done (17.92s)" via a bare print(). Under ACP (and any headless JSON-RPC stdio host where AIAgent routes human output to stderr via a custom _print_fn), these landed on stdout and corrupted the protocol frame stream, surfacing as "Failed to parse JSON message: ✓ [3/3] …" in the ACP adapter. Add _emit_parent_console() which prefers parent_agent._safe_print (the same hook AIAgent uses for every other user-facing print) and falls back to print() only when no router is wired up or it raises. CLI behavior is unchanged. The PR's other fix (preset toolset expansion) is already covered on main by _expand_parent_toolsets(), so only the stdio-safe printing change is salvaged here. --- scripts/release.py | 1 + tests/tools/test_delegate_toolset_scope.py | 43 +++++++++++++++++++++- tools/delegate_tool.py | 22 ++++++++++- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/scripts/release.py b/scripts/release.py index a76dc642c27..084e960f495 100755 --- a/scripts/release.py +++ b/scripts/release.py @@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json" # Auto-extracted from noreply emails + manual overrides AUTHOR_MAP = { + "phanvanhoa@gmail.com": "theAgenticBuilder", # PR #14180 salvage (route delegate_task progress lines through _safe_print so ACP stdio JSON-RPC frames stay clean) "cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown) "tenoryang@outlook.com": "MarioYounger", # PR #9028 salvage (bash/sh heredoc approval, NFKC homograph fold, execute_code CREDS/BEARER/APIKEY env filter) "peet.wannasarnmetha@gmail.com": "peetwan", # PR #51841 salvage (loopback ws-ping tuning + token-frame coalescing + loop heartbeat; #48445/#50005) diff --git a/tests/tools/test_delegate_toolset_scope.py b/tests/tools/test_delegate_toolset_scope.py index 175cd8f6485..fd90dc1b561 100644 --- a/tests/tools/test_delegate_toolset_scope.py +++ b/tests/tools/test_delegate_toolset_scope.py @@ -8,7 +8,7 @@ arbitrary toolsets. from types import SimpleNamespace -from tools.delegate_tool import _strip_blocked_tools +from tools.delegate_tool import _strip_blocked_tools, _emit_parent_console class TestToolsetIntersection: @@ -63,3 +63,44 @@ class TestToolsetIntersection: scoped = [t for t in requested if t in parent_toolsets] assert scoped == [] + + +class TestEmitParentConsole: + """Progress lines (e.g. ``✓ [N/M] …``) must route through the parent's + configured ``_safe_print`` in headless stdio hosts (ACP, gateway) so + they don't land on stdout and corrupt JSON-RPC frames. Regression for a + bug where delegate_task completion lines pushed to stdout caused + ``Failed to parse JSON message: ✓ [3/3] …`` errors in the ACP adapter.""" + + def test_routes_through_parent_safe_print_when_available(self, capsys): + captured_lines = [] + parent = SimpleNamespace(_safe_print=lambda line: captured_lines.append(line)) + + _emit_parent_console(parent, " ✓ [1/3] Research done (11.55s)") + + assert captured_lines == [" ✓ [1/3] Research done (11.55s)"] + stdout_stderr = capsys.readouterr() + assert stdout_stderr.out == "" + assert stdout_stderr.err == "" + + def test_falls_back_to_stdout_when_no_safe_print(self, capsys): + parent = SimpleNamespace() + _emit_parent_console(parent, " ✓ [1/3] fallback path") + captured = capsys.readouterr() + assert "fallback path" in captured.out + + def test_falls_back_to_stdout_when_safe_print_raises(self, capsys): + def raiser(_line): + raise RuntimeError("boom") + + parent = SimpleNamespace(_safe_print=raiser) + _emit_parent_console(parent, " ✓ [2/3] fallback on exception") + captured = capsys.readouterr() + assert "fallback on exception" in captured.out + + def test_non_callable_safe_print_is_ignored(self, capsys): + """Defensive: if _safe_print is set but not callable, fall back.""" + parent = SimpleNamespace(_safe_print="not-a-function") + _emit_parent_console(parent, " ✓ [3/3] non-callable guard") + captured = capsys.readouterr() + assert "non-callable guard" in captured.out diff --git a/tools/delegate_tool.py b/tools/delegate_tool.py index 49a76318254..7bd510d7f5f 100644 --- a/tools/delegate_tool.py +++ b/tools/delegate_tool.py @@ -800,6 +800,24 @@ def _strip_blocked_tools(toolsets: List[str]) -> List[str]: return [t for t in toolsets if t not in blocked_toolset_names] +def _emit_parent_console(parent_agent, line: str) -> None: + """Emit a human-readable progress line to the parent's console. + + Routes through ``parent_agent._safe_print`` when available so headless + stdio hosts (ACP, gateway API) can redirect non-protocol output to + stderr via their configured ``_print_fn``. A bare ``print()`` would + otherwise land on stdout and corrupt JSON-RPC framing. + """ + printer = getattr(parent_agent, "_safe_print", None) + if callable(printer): + try: + printer(line) + return + except Exception: + pass + print(line) + + def _build_child_progress_callback( task_index: int, goal: str, @@ -2610,9 +2628,9 @@ def delegate_task( try: spinner_ref.print_above(completion_line) except Exception: - print(f" {completion_line}") + _emit_parent_console(parent_agent, f" {completion_line}") else: - print(f" {completion_line}") + _emit_parent_console(parent_agent, f" {completion_line}") # Update spinner text to show remaining count if spinner_ref and remaining > 0: