fix(voice): don't hold run open on updateAgent handoffs#2098
Merged
Conversation
toubatbrian
approved these changes
Jul 24, 2026
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
Ports livekit/agents#6500 to JS:
updateAgent()handoffs no longer wait for the new agent'sonEntertask directly, but still watch that task on the activerun()so delayedonEnteroutput is captured.Adds parity coverage for delayed
onEnteroutput and long-livedonEnterflows that need a future user turn.Source diff coverage
livekit-agents/livekit/agents/voice/agent_session.pyagents/src/voice/agent_session.ts:updateAgent()now calls_updateActivity(..., { waitOnEnter: false }), then watches the new activity's_onEnterTaskon the activeRunResultwhen present.tests/test_update_agent_long_on_enter.pyagents/src/voice/agent_session_update_agent_long_on_enter.test.ts: ported both source regression scenarios using Vitest, JSAgent/AgentTask, and the targetFakeLLM/RunResulttest helpers.Testing
pnpm prettier --check "agents/src/voice/agent_session.ts" "agents/src/voice/agent_session_update_agent_long_on_enter.test.ts"pnpm --filter @livekit/agents typecheckpnpm --filter @livekit/agents lint(passes with existing warnings)pnpm vitest run agents/src/voice/agent_session_update_agent_long_on_enter.test.tspnpm vitest run agents --maxWorkers=1pnpm build:agentsNote: the default parallel
pnpm vitest run agentshit the existingagents/src/utils.test.tsnested task ordering flake twice; that file passed standalone, and the full agents suite passed serially with--maxWorkers=1.Cue voice E2E
4df08ec5a2e20eb20a631843fcf5f4040ac47c09sid_ceaa915daa36(--mode voice)function_tools_executed(.function_calls[0].name="switch_to_delayed_agent") -> conversation_item_added(.item.message.role="ASSISTANT",.item.message.content[0].text="Rosetta 2098 delayed on enter verified.")[20s]switch_to_delayed_agent, whose implementation callssession.updateAgent(new DelayedGreetingAgent()); offset 28 records the agent handoff; after the new agent's delayedonEnter, offset 34 records the exact assistant outputRosetta 2098 delayed on enter verified.~/.cue-cli/sessions/sid_ceaa915daa36/manifest.json,events.jsonl,recordings/001_run/result.json,recording.wav, andrecordings/001_run/recording.wavdeepgram/nova-3, LLMopenai/gpt-4.1-mini, TTScartesia/sonic-3through LiveKit Inference, and Silero VAD (manifest.jsonreportsvad: "silero").Ported from livekit/agents#6500
Original PR description
What
session.run()deadlocks when a function tool callssession.update_agent(new_agent)and the new agent'son_enterawaits anAgentTask(orTaskGroup) that needs a future user turn to complete.The chain:
update_agent()→_update_activity_task(watched by the activeRunResultso the handoff result lands before run completion) →_update_activity(agent)with the defaultwait_on_enter=True→ awaits the new agent'son_enter→ which awaits anAgentTaskthat needs the next user turn. But the driver can't send the next turn untilrun()returns — a circular wait. The run hangs until an external timeout cancels everything (surfacing asToolError: AgentTask ... is cancelled).Both other activity-transition paths already guard against exactly this:
session.start()passeswait_on_enter=False(agent_session.py:916), which is why theexamples/surveypattern (agenton_enterawaiting a wholeTaskGroup) works when the agent is the starting agent.AgentTask.__await_implpasseswait_on_enter=Falsewith a comment describing this exact deadlock: "on_enter may spawn nested AgentTasks that require user input, but session.run() can't return until all watched handles complete — creating a circular wait."update_agent()was the only path still waiting. Entering the same agent that works viasession.start()through anupdate_agent()handoff deadlocks — reproduced on 1.6.4, 1.6.5, and 1.6.6.Fix
Pass
wait_on_enter=Falsein_update_activity_task, matching the other two paths.Test
tests/test_update_agent_long_on_enter.py— FakeLLM-scripted: a greeter tool callsupdate_agent()into an agent whoseon_enterawaits anAgentTaskneeding another user turn; assertsrun()returns on the handoff turn and the task completes on the following turn. Deadlocks (times out) without the fix; passes with it.Validation:
tests/test_agent_session.py,test_nested_agent_task.py,test_audio_recognition_handoff.py,test_agent_task_close_race.py,test_drain_timeout.py,test_tool_results_preserved_on_interruption.py,test_user_turn_exceeded.py,test_speech_handle_exception.py,test_agent_update_options.py+ the new test: 116 passed.examples/survey/test_survey_agent.py: 5 passed (live LLM).update_agent()to an agent whoseon_enterdrives a multi-task loop): its full offline suite of ~1000 tests passes against this patch; on current releases every workflow test deadlocks.