fix(agent): delegated children and cron turns stream again — inline, no worker

should_use_direct_api_call() contexts (gateway cron turns #62151, delegate_task
children #60203) were short-circuited onto the NON-streaming wire because the
interrupt worker wedges inside their nested thread pools. That dropped every
liveness property streaming provides: edge proxies kill the silent POST
(z.ai HTTP 524 — three retries later the child dies as "max_iterations"), and
the non-stream stale watchdog cannot tell a reasoning model's thinking phase
from a hung provider, so children die at exactly stale_timeout (#100260).

Keep those contexts on interruptible_streaming_api_call. The request now runs
INLINE on the conversation thread (no worker → the deadlock class stays
closed) while the existing poll loop — 30s heartbeat, stale-stream detector,
cross-thread interrupt abort — moves onto a monitor thread that only ever
aborts sockets, never dispatches (same shape as direct_api_call's watchdog
timer). Interactive sessions are unchanged: worker + poll loop as before.

should_use_direct_api_call() itself is untouched; only what it routes to.

Live A/B (real SSE server, real AIAgent.run_conversation):
  before: subagent/cron wire stream=None, request on conversation thread
  after:  subagent/cron wire stream=True, request on conversation thread
          cli unchanged (stream=True, spawned worker)
  inline stale detector kills a one-chunk-then-silence stream at budget;
  AIAgent.interrupt() from another thread unwinds the inline stream in 0.6s.

Co-authored-by: Expri-commits <184641533+Expri-commits@users.noreply.github.com>
This commit is contained in:
Teknium
2026-09-01 21:27:54 -07:00
parent 273461f139
commit c5b99a3ee5
3 changed files with 469 additions and 168 deletions
+223 -167
View File
@@ -3546,14 +3546,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if emit is not None:
emit(final_text=final_text, finished=finished, error=error)
# Cron and other non-interactive, nested-pool contexts deadlock on the
# spawned worker thread (#62151). They also have no stream consumer, so the
# deltas this path produces go nowhere. Delegate to the non-streaming entry
# (which runs inline via should_use_direct_api_call) exactly like the codex
# branch below — routing through the _interruptible_api_call method keeps the
# outer loop's per-request retry/refresh seam intact.
if should_use_direct_api_call(agent):
return agent._interruptible_api_call(api_kwargs)
# Cron turns and delegated children (should_use_direct_api_call) used to be
# short-circuited here onto the NON-streaming wire. They now stay on this
# streaming path and run the request inline — see the ``_inline`` block
# before the poll loop below. Only the codex branch still detours through
# _interruptible_api_call (it streams internally).
if agent.api_mode == "codex_responses":
# Codex streams internally via _run_codex_stream. The main dispatch
@@ -5371,8 +5368,42 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if _reasoning_floor is not None:
_stream_stale_timeout = max(_stream_stale_timeout, _reasoning_floor)
t = threading.Thread(target=_context_thread_target(_call), daemon=True)
t.start()
# Delegated children and gateway cron turns run the streaming request
# INLINE on the conversation thread: spawning the interrupt worker inside
# their nested thread pools wedges before the socket opens (#62151,
# #60203). They used to be routed to the non-streaming wire for that
# reason — but streaming is also the transport keepalive and the
# liveness signal: a non-streaming POST that stays silent through a
# reasoning model's thinking phase is killed by edge proxies (z.ai 524,
# #90202) and by our own stale watchdog, which cannot tell thinking from
# a hang when no bytes ever arrive (#100260). Inline mode keeps the
# stream (per-token liveness) and moves ONLY the lightweight poll loop
# below — heartbeat, stale detector, interrupt abort — onto a monitor
# thread. The monitor never issues a request, so the no-worker property
# that fixes the deadlock class is preserved (same shape as the
# direct_api_call watchdog timer).
_inline = should_use_direct_api_call(agent)
_call_done = threading.Event()
_monitor_interrupted = {"yes": False}
def _run_call():
try:
_call()
finally:
_call_done.set()
if _inline:
t = None
else:
t = threading.Thread(target=_context_thread_target(_run_call), daemon=True)
t.start()
def _call_alive() -> bool:
return not _call_done.is_set()
def _wait_call(timeout: float) -> None:
_call_done.wait(timeout=timeout)
_last_heartbeat = time.time()
_HEARTBEAT_INTERVAL = 30.0 # seconds between gateway activity touches
# Managed local server: a cold model streams weights off disk for tens
@@ -5385,173 +5416,198 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_load_notice_shown = False
_load_notice_misses = 0
_is_local_base = bool(agent.base_url) and is_local_endpoint(agent.base_url)
while t.is_alive():
t.join(timeout=0.3)
_hb_now = time.time()
# Cold-load window: last_chunk_time is touched at request-client
# creation and then only by REAL chunks, so "no chunk for 2s+" is
# true through a model load (nothing can stream while the child is
# still mapping weights) and false during healthy token flow —
# which is what keeps this poll off the streaming hot path. The
# probe itself is an in-memory snapshot read.
if (
_is_local_base
and _hb_now - last_chunk_time["t"] >= 2.0
and _hb_now - _last_load_poll >= 1.0
):
_last_load_poll = _hb_now
_load_notice = _managed_local_load_notice(agent, api_kwargs)
if _load_notice is not None:
agent._emit_wait_notice(_load_notice)
agent._touch_activity("local model loading")
_load_notice_shown = True
_load_notice_misses = 0
# Loading IS liveness for the heartbeat; the stale detector
# needs no help — the local floor (900s) dwarfs any load.
_last_heartbeat = _hb_now
continue
if _load_notice_shown:
# One missed sample is routine (a /slots read straddling a
# batch boundary, a 2s probe timeout under load) — clearing
# on it made the status line strobe blank once every few
# seconds mid-prefill. Only a SUSTAINED absence means the
# phase really ended.
_load_notice_misses += 1
if _load_notice_misses >= 3:
_load_notice_shown = False
def _monitor_loop() -> None:
nonlocal _last_heartbeat, _last_load_poll, _load_notice_shown, _load_notice_misses
while _call_alive():
_wait_call(0.3)
_hb_now = time.time()
# Cold-load window: last_chunk_time is touched at request-client
# creation and then only by REAL chunks, so "no chunk for 2s+" is
# true through a model load (nothing can stream while the child is
# still mapping weights) and false during healthy token flow —
# which is what keeps this poll off the streaming hot path. The
# probe itself is an in-memory snapshot read.
if (
_is_local_base
and _hb_now - last_chunk_time["t"] >= 2.0
and _hb_now - _last_load_poll >= 1.0
):
_last_load_poll = _hb_now
_load_notice = _managed_local_load_notice(agent, api_kwargs)
if _load_notice is not None:
agent._emit_wait_notice(_load_notice)
agent._touch_activity("local model loading")
_load_notice_shown = True
_load_notice_misses = 0
agent._emit_wait_notice("")
# Loading IS liveness for the heartbeat; the stale detector
# needs no help — the local floor (900s) dwarfs any load.
_last_heartbeat = _hb_now
continue
if _load_notice_shown:
# One missed sample is routine (a /slots read straddling a
# batch boundary, a 2s probe timeout under load) — clearing
# on it made the status line strobe blank once every few
# seconds mid-prefill. Only a SUSTAINED absence means the
# phase really ended.
_load_notice_misses += 1
if _load_notice_misses >= 3:
_load_notice_shown = False
_load_notice_misses = 0
agent._emit_wait_notice("")
# Periodic heartbeat: touch the agent's activity tracker so the
# gateway's inactivity monitor knows we're alive while waiting
# for stream chunks. Without this, long thinking pauses (e.g.
# reasoning models) or slow prefill on local providers (Ollama)
# trigger false inactivity timeouts. The _call thread touches
# activity on each chunk, but the gap between API call start
# and first chunk can exceed the gateway timeout — especially
# when the stale-stream timeout is disabled (local providers).
if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL:
_last_heartbeat = _hb_now
_waiting_secs = int(_hb_now - last_chunk_time["t"])
if _waiting_secs >= _HEARTBEAT_INTERVAL:
# No chunks for 30s+ — rewrite the live spinner/status line
# so CLI/TUI/Desktop users see WHAT the wait is (slow or
# overloaded provider / long thinking pause) instead of an
# unexplained generic spinner, and WHEN recovery kicks in.
if (
_stream_stale_timeout is not None
and _stream_stale_timeout != float("inf")
):
_recovery = f"; auto-reconnect at {int(_stream_stale_timeout)}s"
# Periodic heartbeat: touch the agent's activity tracker so the
# gateway's inactivity monitor knows we're alive while waiting
# for stream chunks. Without this, long thinking pauses (e.g.
# reasoning models) or slow prefill on local providers (Ollama)
# trigger false inactivity timeouts. The _call thread touches
# activity on each chunk, but the gap between API call start
# and first chunk can exceed the gateway timeout — especially
# when the stale-stream timeout is disabled (local providers).
if _hb_now - _last_heartbeat >= _HEARTBEAT_INTERVAL:
_last_heartbeat = _hb_now
_waiting_secs = int(_hb_now - last_chunk_time["t"])
if _waiting_secs >= _HEARTBEAT_INTERVAL:
# No chunks for 30s+ — rewrite the live spinner/status line
# so CLI/TUI/Desktop users see WHAT the wait is (slow or
# overloaded provider / long thinking pause) instead of an
# unexplained generic spinner, and WHEN recovery kicks in.
if (
_stream_stale_timeout is not None
and _stream_stale_timeout != float("inf")
):
_recovery = f"; auto-reconnect at {int(_stream_stale_timeout)}s"
else:
_recovery = ""
agent._emit_wait_notice(
f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — "
f"{_waiting_secs}s with no output yet (provider may be "
f"slow or overloaded, or the model is thinking{_recovery})"
)
else:
_recovery = ""
# Chunks are flowing — keep the activity tracker fresh but
# leave the live display alone.
agent._touch_activity(
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
)
# Detect stale streams: connections kept alive by SSE pings
# but delivering no real chunks. Kill the client so the
# inner retry loop can start a fresh connection.
_stale_elapsed = time.time() - last_chunk_time["t"]
if _stale_elapsed > _stream_stale_timeout:
_est_ctx = estimate_request_context_tokens(api_kwargs)
logger.warning(
"Stream stale for %.0fs (threshold %.0fs) — no chunks received. "
"model=%s context=~%s tokens. Killing connection.",
_stale_elapsed, _stream_stale_timeout,
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
)
agent._buffer_status(
f"⚠️ No response from provider for {int(_stale_elapsed)}s "
f"(model: {api_kwargs.get('model', 'unknown')}, "
f"context: ~{_est_ctx:,} tokens). "
f"Reconnecting..."
)
try:
_cancel_current_stream_attempt("stale_stream_kill")
_close_request_client_once("stale_stream_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
# #67142: the stale stream ran on a request-local anthropic
# client, already socket-aborted above via
# _close_request_client_once (which unblocks the worker and
# preserves the #28161 no-hang guarantee). The shared
# _anthropic_client is NOT the in-flight transport, so we must
# not close it from this poll (stranger) thread — that was the
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
# #70773: same FD-recycle corruption vector as #67142.
# The shared OpenAI client's connection pool must NOT be
# closed from this watchdog/poll thread — worker threads
# from previous stale-killed attempts may still be
# unwinding their SSL BIOs. The request-local client is
# already closed above via _close_request_client_once.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next request.
pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
agent._emit_wait_notice(
f"⏳ waiting on {api_kwargs.get('model', 'the provider')} — "
f"{_waiting_secs}s with no output yet (provider may be "
f"slow or overloaded, or the model is thinking{_recovery})"
f"⚠ no output from provider for {int(_stale_elapsed)}s — "
f"reconnecting..."
)
else:
# Chunks are flowing — keep the activity tracker fresh but
# leave the live display alone.
agent._touch_activity(
f"waiting for stream response ({_waiting_secs}s, no chunks yet)"
f"stale stream detected after {int(_stale_elapsed)}s, reconnecting"
)
# Detect stale streams: connections kept alive by SSE pings
# but delivering no real chunks. Kill the client so the
# inner retry loop can start a fresh connection.
_stale_elapsed = time.time() - last_chunk_time["t"]
if _stale_elapsed > _stream_stale_timeout:
_est_ctx = estimate_request_context_tokens(api_kwargs)
logger.warning(
"Stream stale for %.0fs (threshold %.0fs) — no chunks received. "
"model=%s context=~%s tokens. Killing connection.",
_stale_elapsed, _stream_stale_timeout,
api_kwargs.get("model", "unknown"), f"{_est_ctx:,}",
)
agent._buffer_status(
f"⚠️ No response from provider for {int(_stale_elapsed)}s "
f"(model: {api_kwargs.get('model', 'unknown')}, "
f"context: ~{_est_ctx:,} tokens). "
f"Reconnecting..."
)
try:
_cancel_current_stream_attempt("stale_stream_kill")
_close_request_client_once("stale_stream_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
# canonical comment block above ``_stale_streak()``.
_bump_stale_streak(agent)
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
# #67142: the stale stream ran on a request-local anthropic
# client, already socket-aborted above via
# _close_request_client_once (which unblocks the worker and
# preserves the #28161 no-hang guarantee). The shared
# _anthropic_client is NOT the in-flight transport, so we must
# not close it from this poll (stranger) thread — that was the
# FD-recycle corruption vector. Nothing further is needed.
pass
else:
# #70773: same FD-recycle corruption vector as #67142.
# The shared OpenAI client's connection pool must NOT be
# closed from this watchdog/poll thread — worker threads
# from previous stale-killed attempts may still be
# unwinding their SSL BIOs. The request-local client is
# already closed above via _close_request_client_once.
# The shared client will be replaced lazily by
# _ensure_primary_openai_client on the next request.
pass
# Reset the timer so we don't kill repeatedly while
# the inner thread processes the closure.
last_chunk_time["t"] = time.time()
agent._emit_wait_notice(
f"⚠ no output from provider for {int(_stale_elapsed)}s — "
f"reconnecting..."
)
agent._touch_activity(
f"stale stream detected after {int(_stale_elapsed)}s, reconnecting"
)
if agent._interrupt_requested:
# The stale branch above already counted this iteration when its
# deadline won the race; do not double-count a simultaneous stop.
if _stale_elapsed <= _stream_stale_timeout:
_record_interrupted_provider_wait(
agent,
_stale_elapsed,
response_started=deltas_were_sent["yes"],
if agent._interrupt_requested:
# The stale branch above already counted this iteration when its
# deadline won the race; do not double-count a simultaneous stop.
if _stale_elapsed <= _stream_stale_timeout:
_record_interrupted_provider_wait(
agent,
_stale_elapsed,
response_started=deltas_were_sent["yes"],
)
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits without retrying or surfacing a network error.
# (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing streaming httpx client due to interrupt "
"(not a network error)."
)
# Mark THIS request cancelled before force-closing so the worker's
# exception handler recognizes the forced transport error as a
# cancel and exits without retrying or surfacing a network error.
# (#6600)
_request_cancelled["value"] = True
logger.debug(
"Force-closing streaming httpx client due to interrupt "
"(not a network error)."
)
try:
_cancel_current_stream_attempt("stream_interrupt_abort")
# #67142: kind-aware — anthropic aborts the request-local
# client's socket from this poll thread; the shared
# _anthropic_client is never closed here.
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
# Wait for the worker to unwind Relay-managed stream scopes
# (physical LLM + deferred logical) before surfacing
# InterruptedError. Raising immediately lets turn teardown
# (finish_logical_calls / end_turn / close_session) race a
# still-open physical scope and corrupt the LIFO stack —
# "scope handle is not at the top of the stack" → CLI EIO /
# redraw storm (#81521). No-op when Relay managed execution
# is not live.
_join_worker_for_relay_teardown(t, label="Streaming")
raise InterruptedError("Agent interrupted during streaming API call")
try:
_cancel_current_stream_attempt("stream_interrupt_abort")
# #67142: kind-aware — anthropic aborts the request-local
# client's socket from this poll thread; the shared
# _anthropic_client is never closed here.
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
# Wait for the worker to unwind Relay-managed stream scopes
# (physical LLM + deferred logical) before surfacing
# InterruptedError. Raising immediately lets turn teardown
# (finish_logical_calls / end_turn / close_session) race a
# still-open physical scope and corrupt the LIFO stack —
# "scope handle is not at the top of the stack" → CLI EIO /
# redraw storm (#81521). No-op when Relay managed execution
# is not live. (Inline mode has no worker: the request runs
# on the caller's thread and has already unwound by the time
# the InterruptedError below is raised.)
if t is not None:
_join_worker_for_relay_teardown(t, label="Streaming")
_monitor_interrupted["yes"] = True
return
if _inline:
# Request on THIS thread; heartbeat / stale / interrupt monitor on a
# side thread that only ever aborts sockets (never dispatches).
monitor = threading.Thread(
target=_context_thread_target(_monitor_loop),
name="stream-inline-monitor",
daemon=True,
)
monitor.start()
try:
_run_call()
finally:
monitor.join(timeout=2.0)
else:
_monitor_loop()
if _monitor_interrupted["yes"]:
raise InterruptedError("Agent interrupted during streaming API call")
# Worker thread exited before the main thread's poll loop could check
# the interrupt flag. If the worker returned early due to an interrupt
# (e.g. _call_anthropic() detected _interrupt_requested and returned
@@ -0,0 +1,243 @@
"""Delegated children and cron turns stream on the wire (#90202, #100260).
``should_use_direct_api_call`` contexts (gateway cron turns, delegate_task
children) must not spawn the interrupt worker — it wedges inside their nested
thread pools (#62151, #60203). The original fix short-circuited them onto the
NON-streaming wire, which silently dropped every liveness property streaming
provides: edge proxies killed the silent POST (z.ai HTTP 524, #90202), and the
non-stream stale watchdog could not tell a reasoning model's thinking phase
from a hung provider (#100260 — children died at exactly ``stale_timeout``).
These tests pin the replacement contract: those contexts stay on the streaming
path, issue ``stream=True`` on the calling thread (no worker), and keep the
stale detector + cross-thread interrupt abort working from the monitor thread.
"""
import json
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from types import SimpleNamespace
import pytest
import run_agent
from agent import chat_completion_helpers as helpers
from agent.chat_completion_helpers import (
interruptible_streaming_api_call,
should_use_direct_api_call,
)
# ---------------------------------------------------------------------------
# Real OpenAI-wire SSE server: records the wire ``stream`` flag per request.
# ---------------------------------------------------------------------------
class _Wire:
def __init__(self, *, stall_after_first_chunk: bool = False):
self.requests: list[dict] = []
self.stall = stall_after_first_chunk
self.hits = threading.Semaphore(0)
wire = self
class Handler(BaseHTTPRequestHandler):
def log_message(self, *_a):
pass
def do_POST(self):
n = int(self.headers.get("content-length", 0))
body = json.loads(self.rfile.read(n) or b"{}")
if not self.path.endswith("/chat/completions"):
# Local-endpoint capability probes (/api/show etc.) —
# answer fast so agent construction never waits on the
# stalling stream below.
self.send_response(404)
self.end_headers()
return
wire.requests.append(body)
wire.hits.release()
self.send_response(200)
self.send_header("content-type", "text/event-stream")
self.end_headers()
first = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"role": "assistant", "content": "hello"},
"finish_reason": None}],
}
self.wfile.write(f"data: {json.dumps(first)}\n\n".encode())
self.wfile.flush()
if wire.stall:
try:
for _ in range(400):
time.sleep(0.05)
self.wfile.write(b": keepalive\n\n")
self.wfile.flush()
except Exception:
pass
return
second = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {"content": " world"}, "finish_reason": None}],
}
fin = {
"id": "c1", "object": "chat.completion.chunk", "created": 1, "model": "m",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
"usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5},
}
for c in (second, fin):
self.wfile.write(f"data: {json.dumps(c)}\n\n".encode())
self.wfile.write(b"data: [DONE]\n\n")
self.wfile.flush()
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
threading.Thread(target=self.server.serve_forever, daemon=True).start()
self.base_url = f"http://127.0.0.1:{self.server.server_address[1]}/v1"
def close(self):
self.server.shutdown()
self.server.server_close()
@pytest.fixture
def wire():
w = _Wire()
yield w
w.close()
@pytest.fixture
def stalling_wire():
w = _Wire(stall_after_first_chunk=True)
yield w
w.close()
def _make_agent(base_url: str, *, platform: str):
return run_agent.AIAgent(
api_key="test-key",
base_url=base_url,
model="m",
provider="custom",
platform=platform,
quiet_mode=True,
skip_context_files=True,
skip_memory=True,
enabled_toolsets=[],
max_iterations=1,
)
_KW = {"model": "m", "messages": [{"role": "user", "content": "hi"}]}
@pytest.mark.parametrize("platform", ["subagent", "cron"])
def test_direct_contexts_stream_on_the_wire_and_on_the_calling_thread(wire, platform):
agent = _make_agent(wire.base_url, platform=platform)
assert should_use_direct_api_call(agent) is True
issued_on = {}
real_create = agent._create_request_openai_client
def spy(*a, **k):
issued_on["tid"] = threading.get_ident()
return real_create(*a, **k)
agent._create_request_openai_client = spy
response = interruptible_streaming_api_call(agent, dict(_KW))
completions = [r for r in wire.requests if "messages" in r]
assert completions, "no chat completion reached the wire"
assert completions[-1].get("stream") is True, (
f"{platform} turn went out non-streaming: stream={completions[-1].get('stream')!r}"
)
# No interrupt worker: the request was dispatched from the caller's thread
# (the #62151 / #60203 deadlock class needs the request on a spawned worker).
assert issued_on["tid"] == threading.get_ident()
assert response.choices[0].message.content == "hello world"
assert response.choices[0].finish_reason == "stop"
def test_interactive_platform_still_uses_the_worker_thread(wire):
"""Regression guard for the refactor: non-direct contexts keep the
interrupt worker (interactive /stop responsiveness relies on it)."""
agent = _make_agent(wire.base_url, platform="cli")
assert should_use_direct_api_call(agent) is False
issued_on = {}
real_create = agent._create_request_openai_client
def spy(*a, **k):
issued_on["tid"] = threading.get_ident()
return real_create(*a, **k)
agent._create_request_openai_client = spy
response = interruptible_streaming_api_call(agent, dict(_KW))
assert issued_on["tid"] != threading.get_ident()
assert response.choices[0].message.content == "hello world"
def test_inline_stream_stale_detector_still_fires_from_monitor_thread(
stalling_wire, monkeypatch
):
"""The stale-stream detector moved onto a monitor thread for inline
mode; a stream that sends one chunk then only keep-alives must still be
killed at the stale budget instead of hanging until the socket dies."""
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "1.0")
monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
agent = _make_agent(stalling_wire.base_url, platform="subagent")
started = time.time()
response = interruptible_streaming_api_call(agent, dict(_KW))
elapsed = time.time() - started
assert elapsed < 6.0, f"inline stream was not bounded by the stale detector ({elapsed:.1f}s)"
# A partial delta was delivered → the loop gets the length-truncated
# partial-stream stub (same contract as the worker path).
assert getattr(response, "id", None) == helpers.PARTIAL_STREAM_STUB_ID
assert response.choices[0].finish_reason == helpers.FINISH_REASON_LENGTH
def test_inline_stream_cross_thread_interrupt_aborts_promptly(stalling_wire, monkeypatch):
"""``AIAgent.interrupt()`` from another thread (cron watchdog, delegation
stall monitor) must abort the inline stream and surface InterruptedError
— the property the direct_api_call path guaranteed via
``_active_request_abort``."""
monkeypatch.setenv("HERMES_STREAM_STALE_TIMEOUT", "60")
monkeypatch.setenv("HERMES_STREAM_RETRIES", "0")
agent = _make_agent(stalling_wire.base_url, platform="cron")
box: dict = {}
def _run():
t0 = time.time()
try:
interruptible_streaming_api_call(agent, dict(_KW))
box["outcome"] = "returned"
except BaseException as exc: # noqa: BLE001 — record whatever surfaces
box["outcome"] = type(exc).__name__
box["elapsed"] = time.time() - t0
worker = threading.Thread(target=_run, daemon=True)
worker.start()
assert stalling_wire.hits.acquire(timeout=5.0), "request never reached the wire"
time.sleep(0.3) # let the first chunk land
agent.interrupt("test interrupt")
worker.join(timeout=10.0)
assert not worker.is_alive(), "inline stream did not unwind after interrupt"
assert box["outcome"] == "InterruptedError"
assert box["elapsed"] < 5.0
def test_should_use_direct_api_call_gate_is_unchanged():
"""The routing predicate itself is untouched — only what it routes to."""
def mk(platform, api_mode="chat_completions", provider="openrouter"):
return SimpleNamespace(platform=platform, api_mode=api_mode, provider=provider)
assert should_use_direct_api_call(mk("cron")) is True
assert should_use_direct_api_call(mk("subagent")) is True
assert should_use_direct_api_call(mk("cli")) is False
assert should_use_direct_api_call(mk("cron", api_mode="anthropic_messages")) is False
assert should_use_direct_api_call(mk("cron", provider="moa")) is False
+3 -1
View File
@@ -1152,7 +1152,9 @@ The **stale stream detection** kills connections that receive SSE keep-alive pin
The **stale non-stream detection** kills non-streaming calls that produce no response for too long. By default Hermes disables this on local endpoints to avoid false positives during long prefills. If you explicitly set `providers.<id>.stale_timeout_seconds`, `providers.<id>.models.<model>.stale_timeout_seconds`, or `HERMES_API_CALL_STALE_TIMEOUT`, that explicit value is honored even on local endpoints.
This budget bounds every non-streaming call, including the ones cron jobs and delegated subagents run inline. A provider that accepts a request and then goes silent — connection held open, no bytes, no error — is aborted at the stale timeout and retried, rather than hanging until the much longer socket read timeout (or, for an unattended cron run, until something external kills the process).
This budget bounds every non-streaming call. A provider that accepts a request and then goes silent — connection held open, no bytes, no error — is aborted at the stale timeout and retried, rather than hanging until the much longer socket read timeout (or, for an unattended cron run, until something external kills the process).
Cron jobs and delegated subagents stream too. They run the request inline on their own thread (the interrupt worker other sessions use wedges inside the gateway's nested thread pools), but the wire request is still `stream: true`, so the **stale stream detection** budget above governs them — every token counts as liveness, so a reasoning model that thinks for minutes is not mistaken for a hung provider, and edge proxies that kill silent connections keep seeing bytes.
## Context Pressure Warnings