-
Notifications
You must be signed in to change notification settings - Fork 3.4k
fix(voice): don't hold session.run() open on update_agent handoffs with long-lived on_enter #6500
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+154
−1
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
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
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
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import asyncio | ||
|
|
||
| import pytest | ||
|
|
||
| from livekit.agents import Agent, AgentSession, AgentTask, RunContext, function_tool | ||
| from livekit.agents.llm import FunctionToolCall | ||
|
|
||
| from .fake_llm import FakeLLM, FakeLLMResponse | ||
|
|
||
| pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent] | ||
|
|
||
|
|
||
| class AskNameTask(AgentTask): | ||
| """A task that needs a future user turn to complete.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__(instructions="ask name task") | ||
|
|
||
| async def on_enter(self) -> None: | ||
| await self.session.generate_reply(instructions="ask_name") | ||
|
|
||
| @function_tool | ||
| async def record_name(self, ctx: RunContext, name: str) -> str: | ||
| """Called when the user provides their name.""" | ||
| self.complete(None) | ||
| return "recorded" | ||
|
|
||
|
|
||
| class SurveyAgent(Agent): | ||
| """Agent whose on_enter spans multiple user turns (awaits an AgentTask).""" | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__(instructions="survey agent") | ||
|
|
||
| async def on_enter(self) -> None: | ||
| await AskNameTask() | ||
|
|
||
|
|
||
| class Greeter(Agent): | ||
| def __init__(self) -> None: | ||
| super().__init__(instructions="greeter agent") | ||
|
|
||
| @function_tool | ||
| async def start_survey(self, ctx: RunContext) -> None: | ||
| """Called when the user is ready to start the survey.""" | ||
| self.session.update_agent(SurveyAgent()) | ||
|
|
||
|
|
||
| def _build_fake_llm() -> FakeLLM: | ||
| return FakeLLM( | ||
| fake_responses=[ | ||
| # turn 1: the greeter routes to SurveyAgent via update_agent() | ||
| FakeLLMResponse( | ||
| input="ready", | ||
| content="", | ||
| ttft=0.1, | ||
| duration=0.1, | ||
| tool_calls=[ | ||
| FunctionToolCall(name="start_survey", arguments="{}", call_id="call_1") | ||
| ], | ||
| ), | ||
| # AskNameTask.on_enter -> generate_reply(instructions="ask_name") | ||
| FakeLLMResponse(input="ask_name", content="what is your name?", ttft=0.1, duration=0.1), | ||
| # turn 2: the user answers; the task records the name and completes | ||
| FakeLLMResponse( | ||
| input="Bob", | ||
| content="", | ||
| ttft=0.1, | ||
| duration=0.1, | ||
| tool_calls=[ | ||
| FunctionToolCall( | ||
| name="record_name", arguments='{"name": "Bob"}', call_id="call_2" | ||
| ) | ||
| ], | ||
| ), | ||
| ] | ||
| ) | ||
|
|
||
|
|
||
| class GreetingAgent(Agent): | ||
| """Agent whose on_enter does async work before speaking — the run must | ||
| keep tracking on_enter so the greeting is captured before run() returns.""" | ||
|
|
||
| def __init__(self) -> None: | ||
| super().__init__(instructions="greeting agent") | ||
|
|
||
| async def on_enter(self) -> None: | ||
| await asyncio.sleep(0.5) | ||
| await self.session.generate_reply(instructions="delayed_greeting") | ||
|
|
||
|
|
||
| class GreeterToGreeting(Agent): | ||
| def __init__(self) -> None: | ||
| super().__init__(instructions="greeter agent") | ||
|
|
||
| @function_tool | ||
| async def start(self, ctx: RunContext) -> None: | ||
| """Called when the user asks to proceed.""" | ||
| self.session.update_agent(GreetingAgent()) | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_update_agent_on_enter_output_captured(): | ||
| """run() must not complete before the new agent's on_enter has emitted its | ||
| greeting (on_enter is watched, not awaited).""" | ||
| llm = FakeLLM( | ||
| fake_responses=[ | ||
| FakeLLMResponse( | ||
| input="go", | ||
| content="", | ||
| ttft=0.1, | ||
| duration=0.1, | ||
| tool_calls=[FunctionToolCall(name="start", arguments="{}", call_id="call_1")], | ||
| ), | ||
| FakeLLMResponse(input="delayed_greeting", content="hello!", ttft=0.1, duration=0.1), | ||
| ] | ||
| ) | ||
| async with AgentSession(llm=llm) as sess: | ||
| await sess.start(GreeterToGreeting()) | ||
|
|
||
| result = await asyncio.wait_for(sess.run(user_input="go"), timeout=5.0) | ||
| # the delayed greeting must be part of this run's output | ||
| result.expect.contains_message(role="assistant") | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_update_agent_long_on_enter_no_deadlock(): | ||
| """session.run() must return when a function tool calls update_agent() and | ||
| the new agent's on_enter awaits an AgentTask that needs more user input. | ||
|
|
||
| The run watches _update_activity_task; if the activity update waits for | ||
| on_enter (which waits for the next user turn), the run can never complete — | ||
| a circular wait, since run() must return before the next turn can be sent. | ||
| """ | ||
| llm = _build_fake_llm() | ||
| async with AgentSession(llm=llm) as sess: | ||
| await sess.start(Greeter()) | ||
|
|
||
| # must not deadlock: returns once the handoff is done and the | ||
| # AskNameTask question has been spoken | ||
| first_result = await asyncio.wait_for(sess.run(user_input="ready"), timeout=5.0) | ||
| assert first_result is not None | ||
|
|
||
| # the next turn completes the task | ||
| second_result = await asyncio.wait_for(sess.run(user_input="Bob"), timeout=5.0) | ||
| second_result.expect.contains_function_call(name="record_name") |
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.
Uh oh!
There was an error while loading. Please reload this page.