fix(api-server): cancel orphaned agent + true interrupt on SSE disconnect (salvage #3399) - #3427
Conversation
When a streaming /v1/chat/completions client disconnects mid-stream (network drop, browser tab close), response.write raises ConnectionResetError but the agent_task created via ensure_future is never cancelled. The orphaned agent continues consuming API tokens and memory with no reference held to it. Wrap the SSE write loop in try/except to catch disconnect errors and cancel the agent task in the except handler.
The original PR (#3399) caught disconnect errors and cancelled the asyncio task, but run_in_executor tasks can't be interrupted by asyncio cancellation — the agent thread keeps running and consuming API tokens. Wire agent.interrupt() via a mutable agent_ref container: - _run_agent() stores the AIAgent at agent_ref[0] before run_conversation - On SSE disconnect, the except block calls agent.interrupt() which sets _interrupt_requested and signals tools to abort - The agent stops at the next loop iteration boundary Added 2 tests: interrupt is called on disconnect, agent_ref=None still handles disconnect gracefully.
|
FYI: this PR's "true interrupt on SSE disconnect" path interacts with the process-wide singleton in The session isolation at the The reverse race exists too: any other request whose Verified on |
…ntamination
`tools/interrupt.py` exposed a single process-wide
`_interrupt_event = threading.Event()` shared by every concurrent
`AIAgent` in the process. When one agent's `interrupt()` was called
(e.g. an SSE client disconnecting on the API server), the global event
was set and every other concurrently-running agent's tools — terminal,
web_extract, browser, vision, all environment runners — observed
`is_interrupted() == True` and returned `[interrupted]` immediately,
producing truncated mid-task completions on unrelated requests.
The reverse race existed too: any other request whose
`run_conversation()` started would call `clear_interrupt()` →
`_set_interrupt(False)`, silently un-interrupting an agent that another
caller had legitimately stopped.
The session isolation at `self._interrupt_requested` only guarded the
LLM API-call polling loop; the 11 long-running tools that import
`is_interrupted` from `tools.interrupt` all observed the singleton.
Fix:
- Each `AIAgent` now owns its own `threading.Event`
(`self._interrupt_event`).
- `tools/interrupt.py` adds a `contextvars.ContextVar` that holds the
currently-active agent's event. `is_interrupted()` and
`set_interrupt()` consult the bound event when present, falling back
to the historical module-level singleton when none is bound (CLI
single-agent usage and tests that import `_interrupt_event` directly
keep working unchanged).
- `AIAgent.run_conversation()` becomes a thin wrapper that binds
`self._interrupt_event` to the context variable, calls the renamed
`_run_conversation_locked()` (the original body), and unbinds in a
`finally`.
- `AIAgent.interrupt()` and `clear_interrupt()` write directly to
`self._interrupt_event` rather than going through `set_interrupt`,
because they are typically invoked from a different thread (gateway
message handler, SSE disconnect handler) where the contextvar may be
unbound or bound to a different agent.
- `_execute_tool_calls_concurrent` snapshots the current context with
`contextvars.copy_context()` and hands each `ThreadPoolExecutor`
worker a fresh `.copy()` to `run()` the tool in. Without this,
worker threads would not inherit the binding, since neither
`ThreadPoolExecutor.submit` nor `asyncio.loop.run_in_executor`
propagates contextvars automatically.
Tests:
- New `tests/tools/test_interrupt_isolation.py` adds 8 regression
tests covering the bind/unbind API, two-context independence,
ThreadPoolExecutor inheritance, and end-to-end `AIAgent` cross-
contamination scenarios.
- Existing `tests/run_agent/test_interrupt_propagation.py` updated:
the previous `test_child_clear_interrupt_at_start_clears_global`
asserted the bug-as-feature; it now asserts the new isolation
contract under the name
`test_child_clear_interrupt_does_not_affect_global`.
- All bare-constructed mock agents in `test_interrupt_propagation`,
`test_real_interrupt_subagent`, and `test_cli_interrupt_subagent`
receive a `_interrupt_event = threading.Event()`.
- `TestInterrupt` and `TestHydrateTodoStore` in `test_run_agent.py`
drop the now-unused `patch("run_agent._set_interrupt")` context
managers (the import alias is gone).
- `TestMemoryNudgeCounterPersistence::test_counters_not_reset_in_preamble`
and `TestDeadRetryCode::test_no_unreachable_max_retries_after_backoff`
now `inspect.getsource(AIAgent._run_conversation_locked)` since
`run_conversation` is the binding wrapper and no longer holds the
preamble or the retry loop.
Reported in NousResearch#4072 (thread safety) and NousResearch#3427 (the merged SSE-disconnect
PR that made this trigger reliable on multi-client API server
deployments).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Followed up with #6185 and a fix in #6186 that makes the interrupt event per- |
…nect (salvage NousResearch#3399) (NousResearch#3427) Salvage of NousResearch#3399 by @binhnt92 with true agent interruption added on top. When a streaming /v1/chat/completions client disconnects mid-stream, the agent is now interrupted via agent.interrupt() so it stops making LLM API calls, and the asyncio task wrapper is cancelled. Closes NousResearch#3399.
…nect (salvage NousResearch#3399) (NousResearch#3427) Salvage of NousResearch#3399 by @binhnt92 with true agent interruption added on top. When a streaming /v1/chat/completions client disconnects mid-stream, the agent is now interrupted via agent.interrupt() so it stops making LLM API calls, and the asyncio task wrapper is cancelled. Closes NousResearch#3399.
…nect (salvage NousResearch#3399) (NousResearch#3427) Salvage of NousResearch#3399 by @binhnt92 with true agent interruption added on top. When a streaming /v1/chat/completions client disconnects mid-stream, the agent is now interrupted via agent.interrupt() so it stops making LLM API calls, and the asyncio task wrapper is cancelled. Closes NousResearch#3399.
…nect (salvage NousResearch#3399) (NousResearch#3427) Salvage of NousResearch#3399 by @binhnt92 with true agent interruption added on top. When a streaming /v1/chat/completions client disconnects mid-stream, the agent is now interrupted via agent.interrupt() so it stops making LLM API calls, and the asyncio task wrapper is cancelled. Closes NousResearch#3399.
…nect (salvage NousResearch#3399) (NousResearch#3427) Salvage of NousResearch#3399 by @binhnt92 with true agent interruption added on top. When a streaming /v1/chat/completions client disconnects mid-stream, the agent is now interrupted via agent.interrupt() so it stops making LLM API calls, and the asyncio task wrapper is cancelled. Closes NousResearch#3399.
Summary
Salvage of #3399 by @binhnt92 with true agent interruption added on top.
Problem: When a streaming
/v1/chat/completionsclient disconnects mid-stream (network drop, browser tab close, Open WebUI navigation),response.write()raisesConnectionResetErrorbut the agent task keeps running — making LLM API calls and consuming tokens with no one listening.Original fix (#3399): Wrapped the SSE write loop in try/except to catch disconnect errors and cancel the asyncio task. However,
agent_task.cancel()only marks the asyncio Future as cancelled — the underlying thread (viarun_in_executor) continues running, still burning tokens.Added on top: True agent interruption via
agent.interrupt():_run_agent()accepts an optionalagent_refmutable container and stores the AIAgent reference atagent_ref[0]beforerun_conversation()beginsagent.interrupt("SSE client disconnected")which sets_interrupt_requestedand signals all tools to abortChanges
gateway/platforms/api_server.py: disconnect handling + agent_ref wiring + interrupt calltests/gateway/test_sse_agent_cancel.py: 6 tests (4 original + 2 new for interrupt behavior)Test plan
Closes #3399. Original commit by @binhnt92 preserved via cherry-pick.