fix(delegate): route subagent progress lines through _safe_print for ACP stdio

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.
This commit is contained in:
Markus Phan 2026-06-30 03:02:03 -07:00 committed by Teknium
parent eeb4735078
commit cd9f5cc671
3 changed files with 63 additions and 3 deletions

View file

@ -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)

View file

@ -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

View file

@ -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: