fix(harness): add timeout to invoke_acp_agent to prevent indefinite hangs (#4238)

invoke_acp_agent had no timeout anywhere in its call path, and
ACPAgentConfig had no timeout field. If the ACP agent subprocess answers
initialize/new_session correctly but then hangs inside prompt(), the tool
call - and therefore the whole agent turn - blocks indefinitely, with the
child process left running. MCP stdio servers already guard against this
class of hang via tool_call_timeout; ACP agent invocations had no
equivalent.

Add ACPAgentConfig.timeout_seconds (default 1800, ge=1), mirroring the
shape/default of subagents.timeout_seconds, and wrap the conn.prompt()
call in asyncio.wait_for(). On TimeoutError, return a clear error instead
of hanging; exiting the spawn_agent_process context block triggers the
ACP library's own graceful-then-forceful subprocess cleanup, so the hung
process is actually terminated.
This commit is contained in:
Daoyuan Li 2026-07-21 23:47:08 -07:00 committed by GitHub
parent 01a89f2379
commit 09d9cf53d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 167 additions and 4 deletions

View File

@ -24,6 +24,17 @@ class ACPAgentConfig(BaseModel):
"are denied — the agent must be configured to operate without requesting permissions."
),
)
timeout_seconds: int = Field(
default=1800,
ge=1,
description=(
"Maximum time in seconds to wait for the agent to respond to a single invoke_acp_agent "
"call before the invocation is aborted and the subprocess is terminated. Mirrors "
"subagents.timeout_seconds (default: 1800 = 30 minutes) — without this backstop, an ACP "
"agent subprocess that hangs after initialize/new_session blocks the tool call, and "
"therefore the whole agent turn, indefinitely."
),
)
_acp_agents: dict[str, ACPAgentConfig] = {}

View File

@ -1,5 +1,6 @@
"""Built-in tool for invoking external ACP-compatible agents."""
import asyncio
import logging
import os
import shutil
@ -237,10 +238,25 @@ def build_invoke_acp_agent_tool(agents: dict) -> BaseTool:
if agent_config.model:
session_kwargs["model"] = agent_config.model
session = await conn.new_session(**session_kwargs)
await conn.prompt(
session_id=session.session_id,
prompt=[text_block(prompt)],
)
try:
await asyncio.wait_for(
conn.prompt(
session_id=session.session_id,
prompt=[text_block(prompt)],
),
timeout=agent_config.timeout_seconds,
)
except TimeoutError:
logger.error(
"ACP agent '%s' timed out after %s seconds without responding to prompt; terminating subprocess",
agent,
agent_config.timeout_seconds,
)
return (
f"Error: ACP agent '{agent}' timed out after {agent_config.timeout_seconds} seconds "
"without responding. The agent subprocess has been terminated. If this agent handles "
f"long-running tasks, increase acp_agents.{agent}.timeout_seconds in config.yaml."
)
result = client.collected_text
logger.info("ACP agent '%s' returned %s", agent, result[:1000])
logger.info("ACP agent '%s' returned %d characters", agent, len(result))

View File

@ -105,6 +105,38 @@ def test_acp_agent_config_auto_approve_permissions():
assert cfg.auto_approve_permissions is True
def test_acp_agent_config_timeout_seconds_default():
"""Default mirrors subagents.timeout_seconds (1800s = 30 min) so an ACP agent
subprocess that hangs after initialize/new_session cannot block the tool call
(and therefore the whole agent turn) indefinitely."""
cfg = ACPAgentConfig(command="my-agent", description="desc")
assert cfg.timeout_seconds == 1800
def test_acp_agent_config_timeout_seconds_override():
cfg = ACPAgentConfig(command="my-agent", description="desc", timeout_seconds=45)
assert cfg.timeout_seconds == 45
def test_acp_agent_config_timeout_seconds_rejects_non_positive():
with pytest.raises(ValidationError):
ACPAgentConfig(command="my-agent", description="desc", timeout_seconds=0)
def test_load_acp_config_preserves_timeout_seconds():
load_acp_config_from_dict(
{
"codex": {
"command": "codex-acp",
"args": [],
"description": "Codex CLI",
"timeout_seconds": 60,
}
}
)
assert get_acp_agents()["codex"].timeout_seconds == 60
def test_acp_agent_config_missing_command_raises():
with pytest.raises(ValidationError):
ACPAgentConfig(description="No command provided")

View File

@ -1,6 +1,9 @@
"""Tests for the built-in ACP invocation tool."""
import asyncio
import contextlib
import sys
import time
from types import SimpleNamespace
import pytest
@ -813,3 +816,103 @@ def test_get_available_tools_uses_explicit_app_config_for_acp_agents(monkeypatch
assert captured["agents"] is explicit_agents
assert "invoke_acp_agent" in [tool.name for tool in tools]
# ---------------------------------------------------------------------------
# Regression: invoke_acp_agent must not hang forever on a stuck prompt() call
# ---------------------------------------------------------------------------
#
# A minimal, real ACP agent subprocess that answers `initialize`/`new_session`
# correctly but then hangs forever inside `prompt()` (never responds). This
# reproduces an agent that has finished the ACP handshake but then wedges —
# e.g. stuck on a runaway internal step — instead of a mocked `acp` module,
# so the regression test below exercises the real spawn/kill subprocess path.
_HUNG_ACP_AGENT_SCRIPT = """\
import asyncio
import acp
from acp.schema import InitializeResponse, NewSessionResponse
class _HungAgent:
async def initialize(self, protocol_version, client_capabilities=None, client_info=None, **kwargs):
return InitializeResponse(protocol_version=protocol_version)
async def new_session(self, cwd, additional_directories=None, mcp_servers=None, **kwargs):
return NewSessionResponse(session_id="hung-session")
async def prompt(self, session_id, prompt, **kwargs):
# Deliberately never respond: simulates an ACP agent that completes the
# handshake but then hangs instead of answering session/prompt.
await asyncio.Event().wait()
asyncio.run(acp.run_agent(_HungAgent()))
"""
@pytest.mark.anyio
async def test_invoke_acp_agent_times_out_and_kills_hung_subprocess(monkeypatch, tmp_path):
"""invoke_acp_agent must time out and kill the subprocess instead of hanging
forever when the agent answers initialize/new_session but then never
responds to session/prompt.
Before the timeout_seconds fix, neither this tool nor ACPAgentConfig had
any timeout, so this exact scenario blocked the tool call — and therefore
the whole agent turn — indefinitely, with the child process left running.
`timeout_seconds` is configured small (2s) so the pass-after run completes
in a couple of seconds. The outer `asyncio.wait_for(..., timeout=20)` is
only a test-level safety net: it must never fire in the pass-after case
(elapsed stays well under it), but bounds this test to ~20s instead of
hanging the whole suite forever if the fix regresses.
"""
import acp as acp_module
from deerflow.config import paths as paths_module
monkeypatch.setattr(paths_module, "get_paths", lambda: paths_module.Paths(base_dir=tmp_path))
monkeypatch.setattr(
"deerflow.config.extensions_config.ExtensionsConfig.from_file",
classmethod(lambda cls: ExtensionsConfig(mcp_servers={}, skills={})),
)
script_path = tmp_path / "hung_acp_agent.py"
script_path.write_text(_HUNG_ACP_AGENT_SCRIPT, encoding="utf-8")
captured: dict[str, object] = {}
real_spawn_agent_process = acp_module.spawn_agent_process
# Spy on the real spawn_agent_process (not a fake) so we can inspect the
# actual asyncio.subprocess.Process afterwards, while every bit of real
# spawn/handshake/cleanup behavior stays exactly as production uses it.
@contextlib.asynccontextmanager
async def _spying_spawn_agent_process(client, cmd, *args, env=None, cwd=None):
async with real_spawn_agent_process(client, cmd, *args, env=env, cwd=cwd) as (conn, proc):
captured["proc"] = proc
yield conn, proc
monkeypatch.setattr(acp_module, "spawn_agent_process", _spying_spawn_agent_process)
tool = build_invoke_acp_agent_tool(
{
"hung": ACPAgentConfig(
command=sys.executable,
args=[str(script_path)],
description="Hung test agent",
timeout_seconds=2,
)
}
)
start = time.monotonic()
result = await asyncio.wait_for(tool.coroutine(agent="hung", prompt="do work"), timeout=20)
elapsed = time.monotonic() - start
assert elapsed < 10, f"expected the configured 2s timeout to fire quickly, took {elapsed:.1f}s"
assert "timed out" in result.lower()
assert "hung" in result
proc = captured.get("proc")
assert proc is not None, "spawn_agent_process spy did not capture the subprocess"
assert proc.returncode is not None, "subprocess must be terminated, not left running after a timeout"

View File

@ -1445,6 +1445,7 @@ sandbox:
# description: Claude Code for implementation, refactoring, and debugging
# model: null
# # auto_approve_permissions: false # Set to true to auto-approve ACP permission requests
# # timeout_seconds: 1800 # Abort + kill the subprocess if it doesn't respond in time (default: 1800 = 30 min)
# # env: # Optional: inject environment variables into the agent subprocess
# # ANTHROPIC_API_KEY: $ANTHROPIC_API_KEY # $VAR resolves from host environment
#