Skip to content

fix(livekit-agents): don't drop conversation work started by a non-blocking tool - #6660

Open
fxhxdxd wants to merge 1 commit into
livekit:mainfrom
fxhxdxd:fix/run-result-tracks-non-blocking-tool
Open

fix(livekit-agents): don't drop conversation work started by a non-blocking tool#6660
fxhxdxd wants to merge 1 commit into
livekit:mainfrom
fxhxdxd:fix/run-result-tracks-non-blocking-tool

Conversation

@fxhxdxd

@fxhxdxd fxhxdxd commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Fixes #6658

The trigger is the await ctx.update("") at the top of the reported tool, not update_agent() itself.

ctx.update() resolves the executor's first_update_fut, and _ToolExecutor.execute() ends with return await first_update_fut. So dispatch gets its answer and the turn finishes while exe_task, the rest of the tool body, is still running. The ctx.foreground() block and the session.update_agent() inside it happen after the run is already over.

RunResult stops recording as soon as _done_fut resolves, and that happens when every watched handle is done. _enqueue_reply registers self._reply_task with the run state, but nothing registered exe_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 framework run() returns, the session closes, and the handoff is dropped:

WARNING  livekit.agents:agent_session.py:1657 session is closing, skipping start activity of forwarded

The fix

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.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 inside ctx.foreground():

  • one asserts the forwarded agent's ChatMessageEvent reaches result.events
  • one walks the sequence the docs suggest: function call, function call output, agent handoff, assistant message

Both 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:

  • a tool that reports progress and then sleeps for an hour must not gate the run. This one fails with a TimeoutError if the watch is unbounded.
  • a tool that raises after going non-blocking is reported through the normal tool lifecycle, so the waiter must not turn it into a failed run

Full make unit-tests locally: 1260 passed. The 9 errors I see are all in tests/test_room.py and are identical on a clean checkout, so they are a local env thing, not this patch. ruff, ruff format and mypy -p livekit.agents are clean.

@fxhxdxd
fxhxdxd requested a review from a team as a code owner August 1, 2026 04:47
devin-ai-integration[bot]

This comment was marked as resolved.

@fxhxdxd
fxhxdxd force-pushed the fix/run-result-tracks-non-blocking-tool branch from 9aea90a to 900dae9 Compare August 1, 2026 05:05
devin-ai-integration[bot]

This comment was marked as resolved.

@fxhxdxd
fxhxdxd force-pushed the fix/run-result-tracks-non-blocking-tool branch 2 times, most recently from fa148b8 to 3376679 Compare August 1, 2026 05:35
@fxhxdxd fxhxdxd changed the title fix(livekit-agents): keep the run open while a non-blocking tool finishes fix(livekit-agents): don't drop conversation work started by a non-blocking tool Aug 1, 2026
…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
fxhxdxd force-pushed the fix/run-result-tracks-non-blocking-tool branch from 3376679 to 1946034 Compare August 1, 2026 07:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Test framework issues when updating agent from tool

1 participant