Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions .pr/acp_subagent_parent_live_check.py
Original file line number Diff line number Diff line change
@@ -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}")
15 changes: 15 additions & 0 deletions openhands-sdk/openhands/sdk/agent/acp_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*.

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
9 changes: 9 additions & 0 deletions openhands-sdk/openhands/sdk/event/acp_tool_call.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from typing import Any

from pydantic import Field
from rich.text import Text

from openhands.sdk.event.base import Event
Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading