Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion livekit-agents/livekit/agents/voice/agent_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -1601,7 +1601,12 @@ async def _update_activity_task(
if old_task is not None:
await old_task

await self._update_activity(agent)
await self._update_activity(agent, wait_on_enter=False)
Comment thread
k-zaher marked this conversation as resolved.

# watch on_enter so the run captures its output without awaiting it
if (activity := self._activity) is not None and activity._on_enter_task is not None:
if (run_state := self._global_run_state) is not None and not run_state.done():
run_state._watch_handle(activity._on_enter_task)

def _emit_debug_message(self, payload: dict[str, Any]) -> None:
""":meta private: internal — emit a debug/trace payload to the debugger/recorder."""
Expand Down
148 changes: 148 additions & 0 deletions tests/test_update_agent_long_on_enter.py
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")