2a95791992
run_agent.py: delete the `# noqa: F401` re-export block (agent.process_bootstrap
OpenAI/_SafeWriter/_get_proxy_*, model_tools get_tool_definitions/
handle_function_call/check_toolset_requirements, FailoverReason,
_qwen_portal_headers/_routermint_headers, session_persistence names,
estimate_request_tokens_rough, ContextCompressor + friends, jittered_backoff,
prompt_builder names, message_sanitization names, tool_dispatch_helpers
names) — 41 names run_agent never used itself — and the `_STREAM_DIAG_HEADERS`
back-compat class alias (no in-tree reader). run_agent now imports only what
it uses (get_toolset_for_tool, is_local_endpoint, coalesce/uniquify tool-call
ids, cleanup_vm/get_active_env from terminal_tool_lifecycle).
agent/*: `_ra().X` late-binds that only reached a re-export now import the
defining module directly (agent_runtime_helpers -> process_bootstrap.OpenAI,
model_tools.handle_function_call, session_persistence._safe_session_filename_component;
agent_init -> model_tools.get_tool_definitions/check_toolset_requirements,
_lazy_headers("agent.client_lifecycle", ...) for qwen/routermint;
system_prompt -> agent.prompt_builder / model_tools directly, dropping its
own _ra() shim and the `_r` parameter threading). `_ra()` stays for
run_agent-resident names (logger, AIAgent, _hermes_home, _set_interrupt, ...).
toolsets.py: remove resolve_multiple_toolsets (shim-only, restored by
34abf954bd); tests/test_toolsets.py pins the same union behavior via
resolve_toolset over each name.
providers/__init__.py: drop the OMIT_TEMPERATURE re-export (no callers via the
package); ProviderProfile stays because __init__ uses it for annotations —
2 tests repointed to providers.base.
agent/iteration_budget.py: drop the "run_agent re-exports the class"
docstring pointer; 4 tests import IterationBudget from its home.
model_tools.py (arg_coercion names), agent/tool_executor.py, and
hermes_cli/cli_session_mixin.py repoints landed via a sibling commit on this
shared worktree.
Callers repointed: gateway/run.py, hermes_cli/cli_chat_turn_mixin.py,
hermes_cli/cli_tui_mixin.py, tui_gateway/session_workdir.py,
agent/transports/codex.py (one-line imports) + comment pointers in
tools/file_state.py, tools/schema_sanitizer.py, scripts/tool_search_livetest.py.
Tests: patch("run_agent.X") / monkeypatch.setattr(run_agent, "X") /
`from run_agent import X` -> defining module across 99 test files.
469 lines
18 KiB
Python
469 lines
18 KiB
Python
"""Regression guard for #80759 — the inline non-streaming call must be bounded.
|
|
|
|
Cron turns (and delegated children) are routed by ``should_use_direct_api_call``
|
|
onto ``direct_api_call``, which ran the request inline with no stale detector:
|
|
the abort plumbing was registered but nothing ever invoked it. A provider that
|
|
accepted the request and then went silent — connection held open, zero bytes,
|
|
no error — hung the cron run until an external actor killed it (which also
|
|
orphaned the execution row). The only other bound was the 1800s-default httpx
|
|
read timeout, and the job-level inactivity monitor was observed not to fire.
|
|
|
|
These tests pin the watchdog contract: it aborts the in-flight sockets through
|
|
the already-registered abort hook, surfaces a retryable ``TimeoutError`` (never
|
|
``InterruptedError``), feeds the cross-turn stale circuit breaker, and stays
|
|
out of the way of a healthy call. They also pin #85252: the keepalive httpx
|
|
client uses ``read=None``, so a stranger-thread abort that finds no sockets
|
|
must not leave the call unbounded — ``direct_api_call`` injects a per-call
|
|
read timeout matching the stale budget as a hard backstop.
|
|
"""
|
|
|
|
import sys
|
|
import threading
|
|
import time
|
|
import types
|
|
from types import SimpleNamespace
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
sys.modules.setdefault("fire", types.SimpleNamespace(Fire=lambda *a, **k: None))
|
|
sys.modules.setdefault("firecrawl", types.SimpleNamespace(Firecrawl=object))
|
|
sys.modules.setdefault("fal_client", types.SimpleNamespace())
|
|
|
|
import run_agent
|
|
|
|
from agent.chat_completion_helpers import direct_api_call
|
|
|
|
|
|
def _make_agent(*, stale_timeout, platform="cron"):
|
|
agent = MagicMock()
|
|
agent.platform = platform
|
|
agent.api_mode = "chat_completions"
|
|
agent.provider = "openrouter"
|
|
agent._interrupt_requested = False
|
|
agent._consecutive_stale_streams = 0
|
|
agent._touch_activity = MagicMock()
|
|
agent._buffer_status = MagicMock()
|
|
agent._create_request_openai_client = MagicMock()
|
|
agent._close_request_openai_client = MagicMock()
|
|
agent._abort_request_openai_client = MagicMock()
|
|
agent._compute_non_stream_stale_timeout = lambda api_payload: stale_timeout
|
|
return agent
|
|
|
|
|
|
def _stalling_client(agent, *, aborted, release_after=5.0):
|
|
"""A client whose request blocks until the watchdog aborts its sockets."""
|
|
fake_client = MagicMock()
|
|
release_after_abort = threading.Event()
|
|
|
|
def _abort(client, reason):
|
|
aborted.append(reason)
|
|
release_after_abort.set()
|
|
|
|
def _stalled_request(**_kwargs):
|
|
# The provider accepted the request and went silent. The socket
|
|
# shutdown is what unblocks it, exactly as in production.
|
|
if not release_after_abort.wait(timeout=release_after):
|
|
raise AssertionError("watchdog never aborted the stalled request")
|
|
raise ConnectionError("socket shut down")
|
|
|
|
fake_client.chat.completions.create.side_effect = _stalled_request
|
|
agent._abort_request_openai_client.side_effect = _abort
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
return fake_client
|
|
|
|
|
|
def test_stalled_inline_call_is_aborted_and_raises_retryable_timeout():
|
|
agent = _make_agent(stale_timeout=0.2)
|
|
aborted: list[str] = []
|
|
_stalling_client(agent, aborted=aborted)
|
|
|
|
started = time.time()
|
|
with pytest.raises(TimeoutError) as excinfo:
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
elapsed = time.time() - started
|
|
|
|
assert aborted == ["stale_call_kill"]
|
|
assert "no response" in str(excinfo.value)
|
|
assert elapsed < 4.0, "watchdog did not bound the call"
|
|
|
|
|
|
def test_watchdog_abort_never_surfaces_as_interrupted_error():
|
|
"""InterruptedError means "the user wants to stop" — the outer loop does
|
|
not retry it. A watchdog abort must stay retryable."""
|
|
agent = _make_agent(stale_timeout=0.2)
|
|
_stalling_client(agent, aborted=[])
|
|
|
|
with pytest.raises(TimeoutError):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
|
|
def test_watchdog_kill_feeds_the_cross_turn_stale_circuit_breaker():
|
|
"""Without a bump the #58962 breaker can never trip for cron sessions."""
|
|
agent = _make_agent(stale_timeout=0.2)
|
|
_stalling_client(agent, aborted=[])
|
|
|
|
with pytest.raises(TimeoutError):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
assert agent._consecutive_stale_streams == 1
|
|
|
|
|
|
def test_retry_after_a_watchdog_kill_gets_a_fresh_pool_and_succeeds():
|
|
agent = _make_agent(stale_timeout=0.2)
|
|
_stalling_client(agent, aborted=[])
|
|
|
|
with pytest.raises(TimeoutError):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
# The kill must really close the wire client so the retry rebuilds it.
|
|
assert agent._close_request_openai_client.call_args.kwargs["reason"] == (
|
|
"request_error_cleanup"
|
|
)
|
|
|
|
healthy_client = MagicMock()
|
|
healthy_client.chat.completions.create.return_value = SimpleNamespace(id="ok")
|
|
agent._create_request_openai_client.side_effect = None
|
|
agent._create_request_openai_client.return_value = healthy_client
|
|
|
|
assert direct_api_call(agent, {"model": "m", "messages": []}).id == "ok"
|
|
assert agent._consecutive_stale_streams == 0
|
|
|
|
|
|
def test_healthy_call_is_untouched_by_the_watchdog():
|
|
agent = _make_agent(stale_timeout=30.0)
|
|
fake_client = MagicMock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(id="fast")
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
assert direct_api_call(agent, {"model": "m", "messages": []}).id == "fast"
|
|
agent._abort_request_openai_client.assert_not_called()
|
|
assert agent._close_request_openai_client.call_args.kwargs["reason"] == (
|
|
"request_complete"
|
|
)
|
|
|
|
|
|
def test_local_endpoint_infinite_budget_leaves_the_watchdog_disarmed():
|
|
"""``_compute_non_stream_stale_timeout`` returns inf for a local endpoint
|
|
on the implicit default — that opt-out must survive on this path too."""
|
|
agent = _make_agent(stale_timeout=float("inf"))
|
|
fake_client = MagicMock()
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def _slow(**_kwargs):
|
|
started.set()
|
|
assert release.wait(timeout=2.0)
|
|
return SimpleNamespace(id="slow-but-healthy")
|
|
|
|
fake_client.chat.completions.create.side_effect = _slow
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
box = {}
|
|
|
|
def _run():
|
|
box["response"] = direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
worker = threading.Thread(target=_run, daemon=True)
|
|
worker.start()
|
|
assert started.wait(timeout=2.0)
|
|
time.sleep(0.3)
|
|
release.set()
|
|
worker.join(timeout=3.0)
|
|
|
|
assert box["response"].id == "slow-but-healthy"
|
|
agent._abort_request_openai_client.assert_not_called()
|
|
|
|
|
|
def test_watchdog_uses_the_same_budget_as_the_interrupt_worker_path():
|
|
"""The budget comes from ``_compute_non_stream_stale_timeout`` — the same
|
|
resolver the worker path's stale detector uses — with the live request
|
|
payload, so provider config and context scaling both apply."""
|
|
seen: list[dict] = []
|
|
agent = _make_agent(stale_timeout=30.0)
|
|
agent._compute_non_stream_stale_timeout = lambda payload: (
|
|
seen.append(payload) or 30.0
|
|
)
|
|
fake_client = MagicMock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(id="ok")
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
payload = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
|
|
direct_api_call(agent, payload)
|
|
|
|
assert seen == [payload]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# End-to-end: a real AIAgent on the real cron routing + client lifecycle.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _StallingWireClient:
|
|
"""An OpenAI-shaped client whose request stalls until its sockets die."""
|
|
|
|
def __init__(self):
|
|
self._client = SimpleNamespace(is_closed=False)
|
|
self.chat = SimpleNamespace(completions=SimpleNamespace(create=self._create))
|
|
self.responses = SimpleNamespace()
|
|
self.close_calls = 0
|
|
self.sockets_shut_down = threading.Event()
|
|
|
|
def _create(self, **_kwargs):
|
|
if not self.sockets_shut_down.wait(timeout=5.0):
|
|
raise AssertionError("watchdog never shut the stalled request down")
|
|
raise ConnectionError("socket shut down")
|
|
|
|
def close(self):
|
|
self.close_calls += 1
|
|
self._client.is_closed = True
|
|
|
|
|
|
def _build_cron_agent(monkeypatch):
|
|
agent = run_agent.AIAgent.__new__(run_agent.AIAgent)
|
|
agent.platform = "cron"
|
|
agent.api_mode = "chat_completions"
|
|
agent.provider = "openrouter"
|
|
agent.base_url = "https://openrouter.ai/api/v1"
|
|
agent._base_url = agent.base_url
|
|
agent.model = "some/model"
|
|
agent.log_prefix = ""
|
|
agent.quiet_mode = True
|
|
agent._interrupt_requested = False
|
|
agent._interrupt_message = None
|
|
agent._client_lock = threading.RLock()
|
|
agent._client_kwargs = {"api_key": "***", "base_url": agent.base_url}
|
|
agent.stream_delta_callback = None
|
|
agent._stream_callback = None
|
|
agent.reasoning_callback = None
|
|
agent.status_callback = None
|
|
monkeypatch.setenv("HERMES_API_CALL_STALE_TIMEOUT", "0.3")
|
|
return agent
|
|
|
|
|
|
def test_e2e_cron_turn_is_bounded_through_the_real_agent_routing(monkeypatch):
|
|
"""The real chain: cron platform → ``should_use_direct_api_call`` →
|
|
``direct_api_call`` → the agent's own stale-timeout resolver → the real
|
|
cross-thread abort → a retryable ``TimeoutError`` on the caller."""
|
|
wire = _StallingWireClient()
|
|
agent = _build_cron_agent(monkeypatch)
|
|
agent.client = wire
|
|
monkeypatch.setattr("agent.process_bootstrap.OpenAI", lambda **_kwargs: wire)
|
|
monkeypatch.setattr(
|
|
run_agent.AIAgent,
|
|
"_force_close_tcp_sockets",
|
|
lambda self, client: (client.sockets_shut_down.set(), 1)[1],
|
|
)
|
|
|
|
started = time.time()
|
|
with pytest.raises(TimeoutError):
|
|
agent._interruptible_api_call({"model": agent.model, "messages": []})
|
|
elapsed = time.time() - started
|
|
|
|
assert elapsed < 4.0, "cron turn was not bounded by the watchdog"
|
|
# The aborted pool is poisoned, so the killed client is really closed
|
|
# instead of being cached for the retry.
|
|
assert wire.close_calls == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Salvage follow-up (#75301 state discipline): locked lifecycle transitions.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_interrupt_abort_is_not_misclassified_as_provider_staleness():
|
|
"""A user/monitor interrupt that kills the call must not advance the
|
|
cross-turn stale circuit breaker, even if the stale timer fires right
|
|
after — the ``cancelled`` flag owns the outcome (#75301 design)."""
|
|
agent = _make_agent(stale_timeout=30.0)
|
|
release_after_abort = threading.Event()
|
|
fake_client = MagicMock()
|
|
|
|
def _stalled_request(**_kwargs):
|
|
assert release_after_abort.wait(timeout=5.0)
|
|
raise ConnectionError("socket shut down")
|
|
|
|
fake_client.chat.completions.create.side_effect = _stalled_request
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
def _abort(client, reason):
|
|
release_after_abort.set()
|
|
|
|
agent._abort_request_openai_client.side_effect = _abort
|
|
|
|
interrupt_fired = threading.Event()
|
|
|
|
def _interrupt_soon():
|
|
# Wait until the abort hook is registered, then interrupt like
|
|
# run_agent.interrupt() does (registered under the same name).
|
|
deadline = time.time() + 2.0
|
|
while time.time() < deadline:
|
|
hook = agent._active_request_abort
|
|
if callable(hook) and not isinstance(hook, MagicMock):
|
|
agent._interrupt_requested = True
|
|
# interrupt owns the outcome...
|
|
assert hook("interrupt_abort") is False
|
|
# ...so a stale timer racing in afterwards is inert:
|
|
assert hook("stale_call_kill") is False
|
|
interrupt_fired.set()
|
|
return
|
|
time.sleep(0.005)
|
|
|
|
worker = threading.Thread(target=_interrupt_soon, daemon=True)
|
|
worker.start()
|
|
|
|
with pytest.raises(InterruptedError):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
assert interrupt_fired.wait(timeout=1.0)
|
|
assert agent._consecutive_stale_streams == 0, (
|
|
"interrupt was misclassified as provider staleness"
|
|
)
|
|
|
|
|
|
def test_late_stale_timer_after_completion_is_inert():
|
|
"""A timer callback that loses the race to a completed request must not
|
|
bump the streak: ``done`` is set under the lock before the unwind."""
|
|
agent = _make_agent(stale_timeout=30.0)
|
|
fake_client = MagicMock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(id="ok")
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
captured = {}
|
|
real_timer = threading.Timer
|
|
|
|
class CapturingTimer(real_timer):
|
|
def __init__(self, interval, function, *a, **k):
|
|
captured["fn"] = function
|
|
super().__init__(interval, function, *a, **k)
|
|
|
|
with pytest.MonkeyPatch.context() as mp:
|
|
mp.setattr(threading, "Timer", CapturingTimer)
|
|
assert direct_api_call(agent, {"model": "m", "messages": []}).id == "ok"
|
|
|
|
# Fire the (already-cancelled) timer callback manually, simulating a
|
|
# timer thread that had already dequeued before cancel().
|
|
captured["fn"]()
|
|
assert agent._consecutive_stale_streams == 0
|
|
agent._abort_request_openai_client.assert_not_called()
|
|
|
|
|
|
def test_timer_firing_before_client_registration_fails_the_dispatch():
|
|
"""Registration race: if the budget expires while the client is still
|
|
being constructed, the freshly-registered client is aborted and the call
|
|
fails with a retryable TimeoutError instead of opening a new socket
|
|
after the only watchdog was spent."""
|
|
agent = _make_agent(stale_timeout=0.05)
|
|
fake_client = MagicMock()
|
|
fake_client.chat.completions.create.return_value = SimpleNamespace(id="late")
|
|
|
|
def _slow_create(*, reason, api_kwargs):
|
|
# The timer (50ms budget) fires while construction is in flight.
|
|
time.sleep(0.4)
|
|
return fake_client
|
|
|
|
agent._create_request_openai_client.side_effect = _slow_create
|
|
|
|
with pytest.raises(TimeoutError):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
fake_client.chat.completions.create.assert_not_called()
|
|
agent._abort_request_openai_client.assert_called_once_with(
|
|
fake_client, reason="stale_call_kill"
|
|
)
|
|
|
|
|
|
def test_resolver_exception_propagates_instead_of_disarming_the_watchdog():
|
|
"""A raising stale-timeout resolver must propagate (fail closed), not be
|
|
swallowed into an infinite budget that silently reinstates the hang."""
|
|
agent = _make_agent(stale_timeout=0.2)
|
|
|
|
def _broken_resolver(api_payload):
|
|
raise RuntimeError("resolver regression")
|
|
|
|
agent._compute_non_stream_stale_timeout = _broken_resolver
|
|
|
|
with pytest.raises(RuntimeError, match="resolver regression"):
|
|
direct_api_call(agent, {"model": "m", "messages": []})
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# #85252: hard socket bound when stranger-thread abort cannot kill the recv.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_inline_hard_timeout_matches_stale_budget():
|
|
"""Keepalive httpx uses read=None. The injected timeout's read budget
|
|
must equal the stale watchdog so a no-op abort cannot hang for hours."""
|
|
from agent.chat_completion_helpers import _inline_nonstream_hard_timeout
|
|
|
|
timeout = _inline_nonstream_hard_timeout(600.0)
|
|
assert timeout is not None
|
|
assert timeout.read == 600.0
|
|
assert timeout.connect == 60.0
|
|
assert timeout.write == 60.0
|
|
assert timeout.pool == 60.0
|
|
|
|
|
|
def test_inline_hard_timeout_disarmed_when_watchdog_is_disarmed():
|
|
from agent.chat_completion_helpers import _inline_nonstream_hard_timeout
|
|
|
|
assert _inline_nonstream_hard_timeout(float("inf")) is None
|
|
assert _inline_nonstream_hard_timeout(0) is None
|
|
assert _inline_nonstream_hard_timeout(-1) is None
|
|
|
|
|
|
def test_inline_call_passes_hard_read_timeout_to_the_sdk():
|
|
"""The bound has to actually reach chat.completions.create — a helper
|
|
that is never wired in would leave cron on read=None (#85252)."""
|
|
agent = _make_agent(stale_timeout=0.5)
|
|
fake_client = MagicMock()
|
|
captured = {}
|
|
|
|
def _create(**kwargs):
|
|
captured["timeout"] = kwargs.get("timeout")
|
|
return SimpleNamespace(id="ok")
|
|
|
|
fake_client.chat.completions.create.side_effect = _create
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
assert direct_api_call(agent, {"model": "m", "messages": []}).id == "ok"
|
|
timeout = captured["timeout"]
|
|
assert timeout is not None
|
|
assert timeout.read == 0.5
|
|
|
|
|
|
def test_inline_call_does_not_override_explicit_timeout():
|
|
"""A transport/provider that already set timeout= must keep it."""
|
|
agent = _make_agent(stale_timeout=30.0)
|
|
fake_client = MagicMock()
|
|
captured = {}
|
|
|
|
def _create(**kwargs):
|
|
captured["timeout"] = kwargs.get("timeout")
|
|
return SimpleNamespace(id="ok")
|
|
|
|
fake_client.chat.completions.create.side_effect = _create
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
assert direct_api_call(
|
|
agent, {"model": "m", "messages": [], "timeout": 12.0}
|
|
).id == "ok"
|
|
assert captured["timeout"] == 12.0
|
|
|
|
|
|
def test_infinite_budget_does_not_inject_a_hard_timeout():
|
|
agent = _make_agent(stale_timeout=float("inf"))
|
|
fake_client = MagicMock()
|
|
captured = {}
|
|
|
|
def _create(**kwargs):
|
|
captured["timeout"] = kwargs.get("timeout")
|
|
return SimpleNamespace(id="ok")
|
|
|
|
fake_client.chat.completions.create.side_effect = _create
|
|
agent._create_request_openai_client.return_value = fake_client
|
|
|
|
assert direct_api_call(agent, {"model": "m", "messages": []}).id == "ok"
|
|
assert "timeout" not in captured or captured["timeout"] is None
|