fix(livekit-agents): don't drop conversation work started by a non-blocking tool - #6660
Open
fxhxdxd wants to merge 1 commit into
Open
fix(livekit-agents): don't drop conversation work started by a non-blocking tool#6660fxhxdxd wants to merge 1 commit into
fxhxdxd wants to merge 1 commit into
Conversation
fxhxdxd
force-pushed
the
fix/run-result-tracks-non-blocking-tool
branch
from
August 1, 2026 05:05
9aea90a to
900dae9
Compare
fxhxdxd
force-pushed
the
fix/run-result-tracks-non-blocking-tool
branch
2 times, most recently
from
August 1, 2026 05:35
fa148b8 to
3376679
Compare
…ocking tool ctx.update() resolves the executor's first_update_fut, and execute() ends with `return await first_update_fut`, so dispatch gets its answer while exe_task (the rest of the tool body) is still running. Anything the tool does after that update, a handoff, a generate_reply, lands after the turn is over. RunResult stops recording once every watched handle is done. _enqueue_reply registers _reply_task with the run state, but nothing registered exe_task, so the run had no idea the tool was still going. A tool that calls ctx.update() and then session.update_agent() produced only FunctionCallEvent and FunctionCallOutputEvent; the new agent's on_enter reply never reached the RunResult and the session tore down mid-handoff: session is closing, skipping start activity of forwarded Let an active RunResult wait on the tool body, mirroring what _enqueue_reply already does for the deferred reply. The wait is bounded. ctx.update() is also how a tool answers early and keeps doing long background work, and that kind of tool should not hold run() open until it finishes, so the run watches a bounded waiter rather than exe_task itself. Same shape as the realtime auto tool reply in agent_activity, and the same 5s default. Nothing changes outside session.run(): the watch only happens when there is an active run state. Fixes livekit#6658
fxhxdxd
force-pushed
the
fix/run-result-tracks-non-blocking-tool
branch
from
August 1, 2026 07:05
3376679 to
1946034
Compare
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.
Fixes #6658
The trigger is the
await ctx.update("")at the top of the reported tool, notupdate_agent()itself.ctx.update()resolves the executor'sfirst_update_fut, and_ToolExecutor.execute()ends withreturn await first_update_fut. So dispatch gets its answer and the turn finishes whileexe_task, the rest of the tool body, is still running. Thectx.foreground()block and thesession.update_agent()inside it happen after the run is already over.RunResultstops recording as soon as_done_futresolves, and that happens when every watched handle is done._enqueue_replyregistersself._reply_taskwith the run state, but nothing registeredexe_task, so the run had no idea the tool was still going and Agent2 never got a chance to register its on_enter speech handle.It works in production because the session outlives
run(). Under the test frameworkrun()returns, the session closes, and the handoff is dropped:The fix
Let an active
RunResultwait on the tool body, mirroring what_enqueue_replyalready does for the deferred reply.The wait is bounded.
ctx.update()is also how a tool answers early and keeps doing long background work, and that kind of tool should not holdrun()open until it finishes. So the run watches a bounded waiter rather thanexe_taskitself, same shape as the realtime auto tool reply inagent_activity.py. Timeout is a module constant,NON_BLOCKING_TOOL_RUN_TIMEOUT = 5.0, matching the 5s already used there. Happy to change the value or make it configurable.Nothing changes outside
session.run(): the watch only happens when there is an active run state, same guard the auto reply uses.Tests
tests/test_run_result_handoff_in_tool.py, four cases.The reported shape, a tool that calls
ctx.update("")then hands off insidectx.foreground():ChatMessageEventreachesresult.eventsBoth fail on main (only the two function-call events arrive) and pass with the change. Drop the
ctx.update("")line and they pass either way, which isolates the trigger.Then the two cases the bound exists for:
Full
make unit-testslocally: 1260 passed. The 9 errors I see are all intests/test_room.pyand are identical on a clean checkout, so they are a local env thing, not this patch. ruff, ruff format andmypy -p livekit.agentsare clean.