From dc38cf4893973b8665e952d29c9e7ecfb1f16811 Mon Sep 17 00:00:00 2001 From: JoaVirtudes19 Date: Fri, 2 Oct 2026 11:33:12 +0200 Subject: [PATCH] feat(acp): surface Claude Code subagent parent on ACPToolCallEvent claude-agent-acp stamps _meta.claudeCode.parentToolUseId on every update a subagent produces. ACPToolCallEvent now carries it as parent_tool_call_id, read on ToolCallStart and ToolCallProgress and kept on the terminal and supersede events, so consumers can tell a subagent's tool calls from the main agent's. Co-Authored-By: Claude Opus 5.5 --- .pr/acp_subagent_parent_live_check.py | 74 +++++++++++++++ .../openhands/sdk/agent/acp_agent.py | 15 ++++ .../openhands/sdk/event/acp_tool_call.py | 9 ++ tests/sdk/agent/test_acp_agent.py | 90 +++++++++++++++++++ tests/sdk/agent/test_acp_tracing.py | 1 + 5 files changed, 189 insertions(+) create mode 100644 .pr/acp_subagent_parent_live_check.py diff --git a/.pr/acp_subagent_parent_live_check.py b/.pr/acp_subagent_parent_live_check.py new file mode 100644 index 0000000000..62eded61de --- /dev/null +++ b/.pr/acp_subagent_parent_live_check.py @@ -0,0 +1,74 @@ +"""Live check: Claude Code subagent tool calls carry ``parent_tool_call_id``. + +The main agent runs one command itself, then spawns two subagents that each run +one command. Every ACPToolCallEvent is printed with its parent. The subagents' +calls must point at the two Task calls that spawned them; the main agent's own +calls carry no parent. + +Prerequisites: + - Node.js / npx available + - ANTHROPIC_BASE_URL and ANTHROPIC_API_KEY set (can point to LiteLLM proxy) + - Optional ACP_MODEL to pick the model (e.g. a LiteLLM model name) + +Usage: + uv run python .pr/acp_subagent_parent_live_check.py +""" + +import os +import tempfile + +from openhands.sdk.agent import ACPAgent +from openhands.sdk.conversation import Conversation +from openhands.sdk.event import ACPToolCallEvent +from openhands.sdk.settings.acp_providers import ACP_PROVIDERS + + +PROMPT = ( + "Follow exactly.\n" + "1. Run the shell command `echo main-agent` yourself, once.\n" + "2. In ONE message, spawn TWO general-purpose subagents in parallel with " + "your Task/Agent tool. Subagent A runs `echo subagent-a`; subagent B runs " + "`echo subagent-b`. Each replies with its command's output.\n" + "3. When both are back, reply DONE." +) + +agent = ACPAgent( + acp_command=list(ACP_PROVIDERS["claude-code"].default_command), + acp_model=os.environ.get("ACP_MODEL"), +) + +try: + conversation = Conversation(agent=agent, workspace=tempfile.mkdtemp()) + conversation.send_message(PROMPT) + conversation.run() +finally: + agent.close() + +calls = [e for e in conversation.state.events if isinstance(e, ACPToolCallEvent)] +print(f"\n{'tool_call_id':<34} {'status':<11} {'parent_tool_call_id':<34} title") +for call in calls: + print( + f"{call.tool_call_id:<34} {str(call.status):<11} " + f"{str(call.parent_tool_call_id):<34} {call.title}" + ) + +terminal = {c.tool_call_id: c for c in calls} +main_calls = [c for c in terminal.values() if c.parent_tool_call_id is None] +subagent_calls = [c for c in terminal.values() if c.parent_tool_call_id is not None] +parents = {c.parent_tool_call_id for c in subagent_calls} +own_command = next(c for c in main_calls if "main-agent" in c.title) +print(f"\nsubagent calls: {len(subagent_calls)}, distinct parents: {len(parents)}") +assert len(parents) == 2, "expected the calls of exactly two subagents" +assert parents <= {c.tool_call_id for c in main_calls}, "parent is not a main call" +assert own_command.tool_call_id not in parents, "main agent's own command as parent" + +print("\nOn the wire (exclude_none=True):") +for call in (own_command, subagent_calls[0]): + print( + call.model_dump_json( + exclude_none=True, + include={"kind", "tool_call_id", "status", "title", "parent_tool_call_id"}, + ) + ) +cost = conversation.conversation_stats.get_combined_metrics().accumulated_cost +print(f"cost: {cost:.4f}") diff --git a/openhands-sdk/openhands/sdk/agent/acp_agent.py b/openhands-sdk/openhands/sdk/agent/acp_agent.py index b09c6c2af2..4352de4733 100644 --- a/openhands-sdk/openhands/sdk/agent/acp_agent.py +++ b/openhands-sdk/openhands/sdk/agent/acp_agent.py @@ -1044,6 +1044,15 @@ def _serialize_tool_content(content: list[Any] | None) -> list[dict[str, Any]] | return result +def _parent_tool_call_id(meta: dict[str, Any] | None) -> str | None: + """Return the spawning tool call id claude-agent-acp stamps on subagent updates.""" + claude_code = (meta or {}).get("claudeCode") + if not isinstance(claude_code, dict): + return None + parent = claude_code.get("parentToolUseId") + return parent if isinstance(parent, str) else None + + async def _filter_jsonrpc_lines(source: Any, dest: Any) -> None: """Read lines from *source* and forward only JSON-RPC lines to *dest*. @@ -1485,6 +1494,7 @@ async def session_update( "raw_input": update.raw_input, "raw_output": update.raw_output, "content": _serialize_tool_content(update.content), + "parent_tool_call_id": _parent_tool_call_id(update.field_meta), } self._mask_tool_call_entry(entry) self.accumulated_tool_calls.append(entry) @@ -1523,6 +1533,9 @@ async def session_update( updated["raw_output"] = update.raw_output if update.content is not None: updated["content"] = _serialize_tool_content(update.content) + parent = _parent_tool_call_id(update.field_meta) + if parent is not None: + updated["parent_tool_call_id"] = parent self._mask_tool_call_entry(updated) self.accumulated_tool_calls[index] = updated target = updated @@ -1573,6 +1586,7 @@ def _emit_tool_call_event(self, tc: dict[str, Any]) -> None: raw_output=raw_output, content=tc.get("content"), is_error=tc.get("status") == "failed", + parent_tool_call_id=tc.get("parent_tool_call_id"), ) self.on_event(event) except Exception: @@ -3412,6 +3426,7 @@ def _cancel_inflight_tool_calls(self) -> None: raw_output=tc.get("raw_output"), content=tc.get("content"), is_error=True, + parent_tool_call_id=tc.get("parent_tool_call_id"), ) ) except Exception: diff --git a/openhands-sdk/openhands/sdk/event/acp_tool_call.py b/openhands-sdk/openhands/sdk/event/acp_tool_call.py index f9d40ab778..bcc68a3130 100644 --- a/openhands-sdk/openhands/sdk/event/acp_tool_call.py +++ b/openhands-sdk/openhands/sdk/event/acp_tool_call.py @@ -4,6 +4,7 @@ from typing import Any +from pydantic import Field from rich.text import Text from openhands.sdk.event.base import Event @@ -63,6 +64,14 @@ class ACPToolCallEvent(Event): raw_output: Any | None = None content: list[Any] | None = None is_error: bool = False + parent_tool_call_id: str | None = Field( + default=None, + description=( + "``tool_call_id`` of the tool call that spawned the subagent making " + "this call. None when the ACP server reports no parent, as for the " + "main agent's own calls." + ), + ) @property def is_patch_edit(self) -> bool: diff --git a/tests/sdk/agent/test_acp_agent.py b/tests/sdk/agent/test_acp_agent.py index c6fb46a0f4..9b5fa3746c 100644 --- a/tests/sdk/agent/test_acp_agent.py +++ b/tests/sdk/agent/test_acp_agent.py @@ -1292,10 +1292,12 @@ def _mk_tool_start( raw_input: Any | None = None, raw_output: Any | None = None, content: Any | None = None, + field_meta: dict[str, Any] | None = None, ) -> Any: from acp.schema import ToolCallStart start = MagicMock(spec=ToolCallStart) + start.field_meta = field_meta start.tool_call_id = tool_call_id start.title = title start.kind = kind @@ -1315,10 +1317,12 @@ def _mk_tool_progress( raw_input: Any | None = None, raw_output: Any | None = None, content: Any | None = None, + field_meta: dict[str, Any] | None = None, ) -> Any: from acp.schema import ToolCallProgress progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = field_meta progress.tool_call_id = tool_call_id progress.title = title progress.kind = kind @@ -1482,6 +1486,45 @@ def test_finalize_flush_completes_orphaned_tool_calls(self) -> None: assert client.accumulated_tool_calls[0]["status"] == "completed" +@pytest.mark.asyncio +async def test_subagent_tool_call_events_carry_parent_tool_call_id() -> None: + client = _OpenHandsACPBridge() + events: list[Any] = [] + client.on_event = events.append + subagent_meta = {"claudeCode": {"parentToolUseId": "task-1"}} + + await client.session_update("s1", _mk_tool_start("task-1", title="Task")) + await client.session_update( + "s1", _mk_tool_start("tc-sub", field_meta=subagent_meta) + ) + await client.session_update("s1", _mk_tool_progress("tc-sub", status="completed")) + + assert [(e.tool_call_id, e.parent_tool_call_id) for e in events] == [ + ("task-1", None), + ("tc-sub", "task-1"), + ("tc-sub", "task-1"), + ] + + +@pytest.mark.asyncio +async def test_parent_tool_call_id_reported_on_progress_reaches_terminal() -> None: + client = _OpenHandsACPBridge() + events: list[Any] = [] + client.on_event = events.append + + await client.session_update("s1", _mk_tool_start("tc-sub")) + await client.session_update( + "s1", + _mk_tool_progress( + "tc-sub", + status="completed", + field_meta={"claudeCode": {"parentToolUseId": "task-1"}}, + ), + ) + + assert [e.parent_tool_call_id for e in events] == [None, "task-1"] + + # --------------------------------------------------------------------------- # Activity heartbeat # --------------------------------------------------------------------------- @@ -1546,6 +1589,7 @@ async def test_tool_call_start_signals_activity(self): client.on_activity = lambda: signals.append(True) start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read file" start.kind = "read" @@ -1567,6 +1611,7 @@ async def test_tool_call_progress_signals_activity(self): # Need a ToolCallStart first start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read" start.kind = "read" @@ -1581,6 +1626,7 @@ async def test_tool_call_progress_signals_activity(self): signals.clear() progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = None progress.tool_call_id = "tc-1" progress.title = None progress.kind = None @@ -1617,6 +1663,7 @@ async def test_activity_signal_is_throttled(self): for i in range(5): start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = f"tc-{i}" start.title = f"Tool {i}" start.kind = "read" @@ -1638,6 +1685,7 @@ async def test_no_signal_without_callback(self): assert client.on_activity is None start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Tool" start.kind = "read" @@ -1657,6 +1705,7 @@ async def test_activity_callback_error_is_swallowed(self): client.on_activity = MagicMock(side_effect=RuntimeError("boom")) start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Tool" start.kind = "read" @@ -4082,6 +4131,7 @@ async def test_session_update_accumulates_tool_call_start(self): client = _OpenHandsACPBridge() start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read file" start.kind = "read" @@ -4111,6 +4161,7 @@ async def test_session_update_merges_tool_call_progress(self): # Start start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-2" start.title = "Execute command" start.kind = "execute" @@ -4123,6 +4174,7 @@ async def test_session_update_merges_tool_call_progress(self): # Progress progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = None progress.tool_call_id = "tc-2" progress.title = None # not updated progress.kind = None # not updated @@ -4149,6 +4201,7 @@ async def test_multiple_tool_calls_accumulated(self): for i in range(3): start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = f"tc-{i}" start.title = f"Tool {i}" start.kind = "read" @@ -4202,6 +4255,7 @@ async def test_session_update_fires_on_event_live(self): client.on_event = events.append start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read file" start.kind = "read" @@ -4219,6 +4273,7 @@ async def test_session_update_fires_on_event_live(self): assert events[0].raw_output is None progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = None progress.tool_call_id = "tc-1" progress.title = None progress.kind = None @@ -4261,6 +4316,7 @@ async def test_session_update_preserves_interleaved_order(self): def make_start(tc_id: str) -> Any: s = MagicMock(spec=ToolCallStart) + s.field_meta = None s.tool_call_id = tc_id s.title = f"Tool {tc_id}" s.kind = "read" @@ -4272,6 +4328,7 @@ def make_start(tc_id: str) -> Any: def make_progress(tc_id: str, status: str) -> Any: p = MagicMock(spec=ToolCallProgress) + p.field_meta = None p.tool_call_id = tc_id p.title = None p.kind = None @@ -4339,6 +4396,7 @@ async def test_session_update_no_on_event_when_unset(self): assert client.on_event is None start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read" start.kind = "read" @@ -4361,6 +4419,7 @@ async def test_on_event_errors_are_swallowed(self): client.on_event = MagicMock(side_effect=RuntimeError("boom")) start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Read" start.kind = "read" @@ -4437,6 +4496,28 @@ def test_skips_already_terminal_entries(self, tmp_path): # Only the pending one gets a synthetic terminal event. assert [e.tool_call_id for e in emitted] == ["tc-live"] + @pytest.mark.asyncio + async def test_superseded_subagent_call_keeps_parent_tool_call_id(self): + agent = _make_agent() + agent._client = _OpenHandsACPBridge() + emitted: list = [] + agent._client.on_event = emitted.append + await agent._client.session_update( + "s1", + _mk_tool_start( + "tc-sub", + status="pending", + field_meta={"claudeCode": {"parentToolUseId": "task-1"}}, + ), + ) + + agent._cancel_inflight_tool_calls() + + assert [(e.status, e.parent_tool_call_id) for e in emitted] == [ + ("pending", "task-1"), + ("failed", "task-1"), + ] + def test_callback_errors_are_swallowed(self): """A raising on_event during cancellation must not break the retry path.""" agent = _make_agent() @@ -4501,6 +4582,7 @@ def _fake_run_async(_coro, **_kwargs): if call_count == 1: # First attempt: stream a pending tool call, then fail start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "toolu_AAA" start.title = "Read file" start.kind = "read" @@ -4512,6 +4594,7 @@ def _fake_run_async(_coro, **_kwargs): raise ConnectionError("reset by peer") # Retry: fresh tool call id reaches terminal state start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "toolu_BBB" start.title = "Read file" start.kind = "read" @@ -4612,6 +4695,7 @@ def _fake_run_async(_coro, **_kwargs): ("tc-2", "Execute bash", "execute", "failed", {"command": "ls"}, None), ]: start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = tool_call_id start.title = title start.kind = kind @@ -4683,6 +4767,7 @@ def _fake_run_async(_coro, **_kwargs): pre_count = len(events) trailing = MagicMock(spec=ToolCallStart) + trailing.field_meta = None trailing.tool_call_id = "tc-late" trailing.title = "Late arrival" trailing.kind = "read" @@ -9894,6 +9979,7 @@ async def test_tool_call_start_masks_raw_fields(self): client.on_event = events.append start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Running: echo SEKRET" start.kind = "execute" @@ -9926,6 +10012,7 @@ async def test_tool_call_progress_masks_terminal_output(self): client.on_event = events.append start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Run" start.kind = "execute" @@ -9937,6 +10024,7 @@ async def test_tool_call_progress_masks_terminal_output(self): # Terminal progress frame carries the secret in its cumulative output. progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = None progress.tool_call_id = "tc-1" progress.title = None progress.kind = None @@ -9964,6 +10052,7 @@ async def test_failed_progress_mask_keeps_accumulator_safe(self): client.mask = _redacting_mask start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-1" start.title = "Run" start.kind = "execute" @@ -9977,6 +10066,7 @@ async def test_failed_progress_mask_keeps_accumulator_safe(self): side_effect=ACPFileCredentialSyncError("writeback failed") ) progress = MagicMock(spec=ToolCallProgress) + progress.field_meta = None progress.tool_call_id = "tc-1" progress.title = None progress.kind = None diff --git a/tests/sdk/agent/test_acp_tracing.py b/tests/sdk/agent/test_acp_tracing.py index b8cfaef54b..3d03a47988 100644 --- a/tests/sdk/agent/test_acp_tracing.py +++ b/tests/sdk/agent/test_acp_tracing.py @@ -309,6 +309,7 @@ async def test_a_call_that_starts_terminal_closes_at_that_notification(exported) from openhands.sdk.agent.acp_agent import _OpenHandsACPBridge start = MagicMock(spec=ToolCallStart) + start.field_meta = None start.tool_call_id = "tc-terminal" start.title = "git status" start.kind = "execute"