fix(agent): cancel orphaned tool task when SSE client disconnects mid-call (#5106)

stream_agent_loop's per-tool drain loop had no cleanup path for early
generator close. Starlette throws GeneratorExit into the generator at
whatever await point it's suspended on when the SSE client disconnects
(aclose()) - here that's 'await _progress_q.get()' inside the drain
loop, before the final 'await _tool_task' line ever runs. The task,
which wraps execute_tool_block, was left running unawaited and
uncancelled.

For bash/python tools this orphans the underlying subprocess:
subprocess_tools.py already has correct CancelledError handling that
kills the child process, but only runs if the task is actually
cancelled. A client disconnecting mid long-running command left that
subprocess running server-side for its full duration with nothing
left to reap it.

Wrap the drain loop in try/finally: on early exit, cancel _tool_task
(if not already done) and await it so the existing subprocess-kill
path runs.

Adds a regression test that drives the real stream_agent_loop with a
fake tool handler, closes the generator mid tool-call (mirroring what
Starlette does on disconnect), and asserts the handler observed
cancellation immediately - not merely via asyncio.run()'s own
end-of-run task cleanup, which would mask the bug.

Fixes #5105
This commit is contained in:
jagadish-zentiti
2026-07-11 18:14:06 +05:30
committed by GitHub
parent 1f8687abeb
commit 890d6a0220
2 changed files with 117 additions and 10 deletions
+25 -10
View File
@@ -4013,16 +4013,31 @@ async def stream_agent_loop(
await _progress_q.put(None)
_tool_task = asyncio.create_task(_run_tool())
# Drain progress events as they arrive — block until the
# next event OR the tool finishes (sentinel = None).
while True:
evt = await _progress_q.get()
if evt is None:
break
yield (
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n'
)
desc, result = await _tool_task
try:
# Drain progress events as they arrive — block until the
# next event OR the tool finishes (sentinel = None).
while True:
evt = await _progress_q.get()
if evt is None:
break
yield (
f'data: {json.dumps({"type": "tool_progress", "tool": block.tool_type, "round": round_num, **evt})}\n\n'
)
desc, result = await _tool_task
finally:
# If the SSE client disconnects (or this generator is
# otherwise closed) while we're awaiting a progress event
# above, GeneratorExit is thrown in right here and the
# `await _tool_task` on the line above never runs — the
# task (and any subprocess execute_tool_block spawned for
# bash/python tools) would otherwise keep running
# orphaned with nothing left to await or cancel it.
if not _tool_task.done():
_tool_task.cancel()
try:
await _tool_task
except (asyncio.CancelledError, Exception):
pass
# A skill the model just loaded can prescribe tools that weren't
# RAG-selected this turn (declared via requires_toolsets in its