Skip to content
Merged
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
3 changes: 2 additions & 1 deletion tests/v1/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -231,11 +231,12 @@ async def test_acp_resume_with_tool(run_v1, harness, harness_runtime, tmp_path):
assert trace.ok, trace.errors
assert trace.stop_condition == "user_closed"
assert trace.rewards["resumed"].score == 1.0
assert trace.tools # ACP-native tools, or Pi's MCP adapter meta-tool
segments = trace.info["acp_segments"]
assert len(segments) == 2
assert segments[0]["terminated"] is False
assert segments[1]["terminated"] is False
# Native MCP tools need not appear in the intercepted model request that
# populates trace.tools; the ACP transcript is the source of truth for use.
assert "tool" in segments[1]["roles"]
assert segments[1]["tool_outputs"]
if harness == "rlm":
Expand Down
56 changes: 39 additions & 17 deletions verifiers/v1/acp/runner.py
Comment thread
mikasenghaas marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -36,36 +36,39 @@
)

MAX_PACKET_BYTES = 128 * 1024 * 1024
LATE_UPDATE_GRACE_SECONDS = 1.0


class VerifiersACPClient(Client):
def __init__(self) -> None:
self.visible_reply = ""
self.message_id: str | None = None
self.tool_calls: dict[str, str] = {}
self.output_changed = asyncio.Condition()

def reset(self) -> None:
self.visible_reply = ""
self.message_id = None
self.tool_calls = {}

async def session_update(self, session_id: str, update: Any, **kwargs: Any) -> None:
if isinstance(update, ToolCall):
self.tool_calls[update.tool_call_id] = update.status or "pending"
return
if isinstance(update, ToolCallUpdate):
if update.status:
self.tool_calls[update.tool_call_id] = update.status
return
if not isinstance(update, AgentMessageChunk) or not isinstance(
update.content, TextContentBlock
):
return
message_id = getattr(update, "message_id", None)
if message_id is not None and message_id != self.message_id:
self.visible_reply = ""
self.message_id = message_id
self.visible_reply += update.content.text
async with self.output_changed:
if isinstance(update, ToolCall):
self.tool_calls[update.tool_call_id] = update.status or "pending"
elif isinstance(update, ToolCallUpdate):
if update.status:
self.tool_calls[update.tool_call_id] = update.status
elif isinstance(update, AgentMessageChunk) and isinstance(
update.content, TextContentBlock
):
message_id = getattr(update, "message_id", None)
if message_id is not None and message_id != self.message_id:
self.visible_reply = ""
self.message_id = message_id
self.visible_reply += update.content.text
else:
return
self.output_changed.notify_all()

async def request_permission(
self,
Expand Down Expand Up @@ -172,14 +175,33 @@ async def prompt(
except RequestError as error:
detail = error.data.get("details") if isinstance(error.data, dict) else None
raise RuntimeError(detail or str(error)) from error

# ACP 0.11 dispatches notifications in background tasks but resolves a request
# response directly in its receive loop. An agent that sends its final
# session/update immediately before session/prompt returns can therefore wake
# this coroutine before the update handler has run. Wait specifically for text:
# a completed tool update may also arrive first and must not hide a later reply.
def has_visible_reply() -> bool:
return bool(client.visible_reply.strip())

if not has_visible_reply():
Comment thread
mikasenghaas marked this conversation as resolved.
async with client.output_changed:
try:
await asyncio.wait_for(
client.output_changed.wait_for(has_visible_reply),
timeout=LATE_UPDATE_GRACE_SECONDS,
)
except asyncio.TimeoutError: # noqa: UP041 - Python 3.10 compatibility
pass
Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

tool_statuses = list(client.tool_calls.values())
completed_tool_turn = (
config.get("allow_empty_tool_reply", False)
and response.stop_reason == "end_turn"
and bool(tool_statuses)
and all(status in ("completed", "failed") for status in tool_statuses)
)
if not client.visible_reply.strip() and not completed_tool_turn:
if not has_visible_reply() and not completed_tool_turn:
raise RuntimeError(
"ACP agent produced no visible reply "
f"(stop_reason={response.stop_reason}, tool_statuses={tool_statuses})"
Expand Down
Loading