fix(agent): bound synchronous tool execution with a configurable ceiling - #79570
fix(agent): bound synchronous tool execution with a configurable ceiling#79570sylbae wants to merge 1 commit into
Conversation
The concurrent tool path is bounded by HERMES_CONCURRENT_TOOL_TIMEOUT_S, but the sequential path (execute_tool_calls_sequential and the segmented executor's sequential segments) has no deadline: a tool whose awaitable never resolves parks the conversation turn in the event-loop selector forever — no log line, no error, ended_at/end_reason stay NULL, and the client spins on the busy indicator indefinitely. Observed in production with a wedged skill_view; confirmed with a faulthandler all-threads dump of the turn thread. Bound the synchronous execution funnel (relay_tools._run_awaitable) with asyncio.wait_for. Default 420s sits above every stock per-tool budget (web_extract 360s) so it only fires on genuinely wedged tools; tunable via HERMES_TOOL_EXECUTION_CEILING_S, 0 disables. On expiry the TimeoutError surfaces through existing error handling as a normal failed tool result and the turn continues. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
This was generated by AI during triage. Summary: Problems:
Solution: Checked against |
…r the relay_llm twin Follow-up to the salvaged NousResearch#79570 ceiling: - agent/relay_await.py: shared bounded runner for both Relay adapters. Layer 1 (cooperative): asyncio.wait_for at HERMES_TOOL_EXECUTION_CEILING_S (420s default, matching HERMES_CONCURRENT_TOOL_TIMEOUT_S; <=0/NaN disables, mirroring _resolve_concurrent_tool_timeout's contract). Layer 2 (hard): the awaitable runs on a daemon worker thread abandoned after 3x the ceiling — wait_for joins cancellation, so a wedge that swallows CancelledError or blocks the loop synchronously forever defeated the cooperative bound (empirically verified: bare wait_for hangs on both). Same abandon-never-join policy as the concurrent executor (c1784e9). Worker wrapped with tools.thread_context.propagate_context_to_thread so approval/sudo callbacks and turn ContextVars survive the thread shift. - agent/relay_tools.py, agent/relay_llm.py: both _run_awaitable twins now route through the shared runner (relay_llm's was byte-identical unbounded — same wedge class on the managed LLM path; whole-bug-class rule). The LLM twin gets an 1800s hard-deadline floor above the 1500s codex hard timeout. - tests: 18 tests replacing the original 5 — binding ceiling-zero test (fails if wait_for is used when disabled), negative/NaN disable, slow sync-block survives past the ceiling and keeps its result, cancel- swallowing wedge abandoned at the hard deadline, post-dispatch late TimeoutError absorbed by execute()'s fallback (real tool result returned, never discarded — the safety story for terminal's 600s foreground max exceeding the 420s ceiling), pre-dispatch wedge propagates, LLM twin bounded + floor applied. Hard-abandon and cooperative legs both mutation-checked (each guard test goes red when its layer is neutered). Salvaged from NousResearch#79570 by @sylbae; docstring's 'sits above every stock per-tool budget' claim corrected (terminal foreground max is 600s).
|
Salvaged via #79780 with your commit cherry-picked and authorship preserved — thank you for the excellent report and fix. The faulthandler dump in #79568 pinpointing the bare The salvage keeps your ceiling as commit 1 and adds a follow-up on top:
#79780 is open for maintainer review (rebase-merge planned, so your commit lands on main under your name). |
|
suggesting changes The configured ceiling is still cooperative rather than a hard execution bound. The focused tests passed with 5 tests in 1.06 seconds, and the positive control returned normally in 0.001 seconds. Security evidence:
Not checked:
Signed: GPT-5.6-sol-xhigh in Codex |
|
Closing: your commit was already carried forward (cherry-picked, authorship preserved) in the salvage PR #79780, which supersedes this branch — review continues there. Separately, the general sequential-tool gap this class of bug exposed is now bounded on main via |
What does this PR do?
Bounds every synchronous tool execution with a configurable ceiling
(
HERMES_TOOL_EXECUTION_CEILING_S, default 420s,0disables), closing a gap where thesequential tool path (
execute_tool_calls_sequentialand the segmented executor's sequentialsegments) has no deadline at all: a tool whose awaitable never resolves parks the conversation
turn in the event-loop selector forever — no log line, no error,
ended_at/end_reasonstay NULL, and the client spins on "ruminating…" indefinitely. The concurrent path already has
HERMES_CONCURRENT_TOOL_TIMEOUT_S; this gives the synchronous funnel an equivalent backstop.Related Issue
Fixes #79568
Type of Change
Changes Made
agent/relay_tools.py— new_tool_execution_ceiling_seconds()(env-read, default 420.0,warns and falls back on invalid values);
_run_awaitablewraps the awaitable inasyncio.wait_forwhen the ceiling is > 0.tests/agent/test_relay_run_awaitable_ceiling.py— 5 new tests: normal completion,non-awaitable passthrough, wedged awaitable raises
TimeoutError, ceiling0disables thebound, invalid env falls back to the default.
How to Test
pytest tests/agent/test_relay_run_awaitable_ceiling.py -v(5 passed; also executed insidethe official
nousresearch/hermes-agent:v2026.8.3container venv, Python 3.13.5).await asyncio.Event().wait(), invoke it on thesequential path with
HERMES_TOOL_EXECUTION_CEILING_S=5— the tool fails visibly in ~5s andthe turn continues, instead of hanging forever.
skill_viewthat previously hung the turn permanently instead surfaced astool skill_view failed (420.11s); the turn issued its next API call and completed withfinish_reason=stop.Platforms tested: Linux (Docker, linux/amd64). No platform-specific APIs touched
(stdlib
asyncio.wait_for/os.getenvonly).Checklist
_run_awaitableor sequential-path deadlines)the change is additive and behind an env-tunable default
if maintainers point me at the right page
Screenshots / Logs
Before (production, silent forever-hang; timestamps CEST):
After (same workload, fix applied):