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:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user