fix(interrupt): per-agent interrupt event to prevent cross-session contamination - #6186
Closed
malaiwah wants to merge 1 commit into
Closed
fix(interrupt): per-agent interrupt event to prevent cross-session contamination#6186malaiwah wants to merge 1 commit into
malaiwah wants to merge 1 commit into
Conversation
…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>
This was referenced Apr 8, 2026
Contributor
|
Thanks for the detailed write-up and the ContextVar approach — the per-agent interrupt isolation problem you identified in #6185 is real and the analysis is solid. This is an automated hermes-sweeper review. The fix landed on
The ContextVar design has merit for a future async-first tool dispatch, but the functional gap is closed on |
Collaborator
Collaborator
|
Likely duplicate of merged #7930. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Fixes #6185.
tools/interrupt.pyexposed a single process-wide_interrupt_event = threading.Event()shared by every concurrentAIAgent. When two agents run simultaneously in the same process —two
/v1/chat/completionsclients on the API server, or a gatewayrunner with multiple active sessions — one agent's
interrupt()setthe global event and every other concurrent agent's tools (terminal,
web_extract, browser, vision, all environment runners) returned
[interrupted]immediately, producing truncated mid-task completionson unrelated requests.
The reverse race existed too: any other request whose
run_conversation()started would callclear_interrupt()→_set_interrupt(False), silently un-interrupting an agent thatanother caller had legitimately stopped.
Per-instance
self._interrupt_requestedonly guarded the LLM API-callpolling loop in
_interruptible_api_call; the 11 long-running toolsthat import
is_interruptedfromtools.interruptall observed thesingleton, so the isolation broke at the tool boundary.
Issue #6185 has the full reproduction and a list of the affected
tools. PR #3427 (merged) made the SSE-disconnect path the most
reliable trigger.
Approach
AIAgentnow owns its ownthreading.Event(
self._interrupt_event).tools/interrupt.pyadds acontextvars.ContextVarthat holds thecurrently active agent's event.
is_interrupted()andset_interrupt()consult the bound event when present, fallingback to the historical module-level singleton when none is bound
(CLI single-agent usage and tests that import
_interrupt_eventdirectly continue to work unchanged).
AIAgent.run_conversation()becomes a thin wrapper that bindsself._interrupt_eventto the context variable, calls the renamed_run_conversation_locked()(the original body), and unbinds in afinally.AIAgent.interrupt()andclear_interrupt()write directly toself._interrupt_eventrather than going throughset_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_concurrentsnapshots the current context withcontextvars.copy_context()and hands eachThreadPoolExecutorworker a fresh
.copy()torun()the tool in. Without this,worker threads would not inherit the binding, since neither
ThreadPoolExecutor.submitnorasyncio.loop.run_in_executorpropagates contextvars automatically.
The 11 tool modules that import
is_interruptedneed no edits — theper-agent binding is fully transparent to them.
Test plan
New
tests/tools/test_interrupt_isolation.pyadds 8 regression testscovering:
ThreadPoolExecutorworkers inheriting the correct event viacopy_context().runAIAgentcross-contamination: interrupting agent A does not affect agent B; clearing one agent's interrupt does not clear the other'sExisting
tests/run_agent/test_interrupt_propagation.pyupdated:test_child_clear_interrupt_at_start_clears_globalasserted the bug-as-feature; it now asserts the new isolation
contract under the name
test_child_clear_interrupt_does_not_affect_global.test_interrupt_propagation,test_real_interrupt_subagent, andtest_cli_interrupt_subagentreceive a
_interrupt_event = threading.Event().tests/run_agent/test_run_agent.py:TestInterruptandTestHydrateTodoStoredrop the now-unusedpatch("run_agent._set_interrupt")context managers (the importalias is gone).
TestMemoryNudgeCounterPersistence::test_counters_not_reset_in_preambleand
TestDeadRetryCode::test_no_unreachable_max_retries_after_backoffnow
inspect.getsource(AIAgent._run_conversation_locked)sincerun_conversationis the binding wrapper and no longer holds thepreamble or the retry loop.
Test results
tests, plus all pre-existing
tools/test_interrupt.py,run_agent/test_interrupt_propagation.py,run_agent/test_real_interrupt_subagent.py,run_agent/test_interactive_interrupt.py,run_agent/test_exit_cleanup_interrupt.py,cli/test_cli_interrupt_subagent.py,gateway/test_interrupt_key_match.py).tests/run_agent/test_run_agent.py(228 tests) andtests/gateway/test_api_server*.py(141 tests) pass.tests/run_agent/,tests/tools/,tests/cli/,and
tests/gateway/test_api_server*.py: 3558 pass, 19 pre-existingfailures (verified by re-running on
mainHEADff6a86cb). Thepre-existing failures are environment-dependent
(
test_docker_environment,test_file_tools_live,test_managed_media_gateways,test_transcription,test_vision_tools::test_check_requirements_accepts_codex_auth,test_quick_commands) and unrelated to interrupt handling.Backwards compatibility
is_interrupted()andset_interrupt()public API isunchanged.
_interrupt_eventis preserved as a fallback soany external caller that imports it directly continues to work.
identically: a single agent in a process binds its own event and
observes its own signal — exactly the same outward behaviour as
before, but isolated from other agents that might run alongside.
cc #4072 #3427
🤖 Generated with Claude Code