fix(agent): exempt clarify from sequential tool deadline

Clarify waits on a human for up to 3600s or unlimited. The generic sequential timeout was aborting that wait at 420s and leaving the prompt and worker active.
This commit is contained in:
fangliquanflq
2026-08-13 14:00:10 +08:00
committed by kshitij
parent 82a1b5a115
commit 61645cde82
2 changed files with 82 additions and 2 deletions

View File

@@ -33,6 +33,7 @@ from agent.display import (
_detect_tool_failure,
)
from agent.tool_dispatch_helpers import (
_NEVER_PARALLEL_TOOLS,
_is_destructive_command,
_is_multimodal_tool_result,
_multimodal_text_summary,
@@ -677,7 +678,13 @@ def _run_sequential_tool_execution_middleware(
display_index: int | None = None,
middleware_trace: list[dict[str, Any]] | None = None,
) -> _ManagedToolResult:
"""Run one sequential call with the concurrent executor's deadline."""
"""Run one sequential call with the concurrent executor's deadline.
Interactive input tools such as ``clarify`` wait on a human. Their own
timeout (``agent.clarify_timeout``: default 3600s, or unlimited when
``<= 0``) owns that wait. Applying the generic tool deadline here would
return ``tool_timeout`` while the prompt and worker stay active.
"""
timeout_s = _resolve_concurrent_tool_timeout()
kwargs = {
"function_name": function_name,
@@ -689,7 +696,7 @@ def _run_sequential_tool_execution_middleware(
"display_index": display_index,
"middleware_trace": middleware_trace,
}
if timeout_s is None:
if timeout_s is None or function_name in _NEVER_PARALLEL_TOOLS:
return _run_agent_tool_execution_middleware(agent, **kwargs)
from tools.daemon_pool import DaemonThreadPoolExecutor

View File

@@ -1,13 +1,17 @@
"""Sequential tool calls recover when one dispatch never returns."""
import json
import threading
import time
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from agent.tool_executor import execute_tool_calls_sequential
from run_agent import AIAgent
from tools.clarify_gateway import resolve_clarify_timeout
def _make_agent(tmp_path: Path) -> AIAgent:
@@ -57,6 +61,17 @@ def _tool_call(call_id: str):
)
def _clarify_call(call_id: str = "clarify-1"):
return SimpleNamespace(
id=call_id,
type="function",
function=SimpleNamespace(
name="clarify",
arguments='{"question": "Pick one?", "choices": ["A", "B"]}',
),
)
def test_sequential_tool_timeout_emits_result_and_continues(tmp_path, monkeypatch):
agent = _make_agent(tmp_path)
first_started = threading.Event()
@@ -153,3 +168,61 @@ def test_sequential_tool_timeout_suppresses_late_terminal_event(tmp_path, monkey
("hung", "tool_timeout"),
("next", None),
]
@pytest.mark.parametrize(
"clarify_timeout",
[resolve_clarify_timeout({}), 0],
ids=["default-3600s", "unlimited"],
)
def test_sequential_timeout_does_not_cut_clarify_human_wait(
tmp_path, monkeypatch, clarify_timeout
):
"""Clarify waits on a human; the generic sequential deadline must not fire.
Default ``agent.clarify_timeout`` is 3600s; ``<= 0`` is unlimited. Both
outlast ``HERMES_CONCURRENT_TOOL_TIMEOUT_S`` (default 420s).
"""
agent = _make_agent(tmp_path)
monkeypatch.setenv("HERMES_CONCURRENT_TOOL_TIMEOUT_S", "0.05")
monkeypatch.setattr(
"tools.clarify_gateway.get_clarify_timeout",
lambda: clarify_timeout,
)
def _callback(question, choices, multi_select=False):
time.sleep(0.15)
return "A"
agent.clarify_callback = _callback
terminal_events: list[dict] = []
def _dispatch(_name, _args, _task_id, *, tool_call_id, **_kwargs):
return "second result"
def _capture_terminal_event(*_args, **kwargs):
terminal_events.append(kwargs)
messages: list[dict] = []
started = time.monotonic()
with (
patch("run_agent.handle_function_call", side_effect=_dispatch),
patch(
"agent.tool_executor._emit_terminal_post_tool_call",
side_effect=_capture_terminal_event,
),
):
execute_tool_calls_sequential(
agent,
SimpleNamespace(tool_calls=[_clarify_call(), _tool_call("next")]),
messages,
"task",
)
assert time.monotonic() - started < 1.0
assert [message["tool_call_id"] for message in messages] == ["clarify-1", "next"]
payload = json.loads(messages[0]["content"])
assert payload["user_response"] == "A"
assert "timed out" not in messages[0]["content"]
assert messages[1]["content"] == "second result"
assert not any(event.get("error_type") == "tool_timeout" for event in terminal_events)