Files
livekit--agents/tests/test_nested_agent_task.py
2026-07-13 13:39:38 +08:00

207 lines
7.1 KiB
Python

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 InnerTask(AgentTask):
"""A task that needs a second user turn to complete (user must trigger 'finish')."""
def __init__(self) -> None:
super().__init__(instructions="inner task")
async def on_enter(self) -> None:
self.session.generate_reply(instructions="inner_greeting")
@function_tool
async def finish(self, ctx: RunContext) -> str:
"""Called to complete the inner task."""
self.complete(None)
return "done"
class OuterTask(AgentTask):
"""A task whose on_enter triggers a tool call that awaits InnerTask."""
def __init__(self) -> None:
super().__init__(instructions="outer task")
async def on_enter(self) -> None:
await self.session.generate_reply(instructions="outer_greeting")
@function_tool
async def start_inner(self, ctx: RunContext) -> str:
"""Transitions into InnerTask."""
await InnerTask()
self.complete(None)
return "inner completed"
class RootAgent(Agent):
def __init__(self) -> None:
super().__init__(instructions="root agent")
@function_tool
async def start_outer(self, ctx: RunContext) -> str:
"""Transitions into OuterTask."""
await OuterTask()
return "outer completed"
@pytest.mark.asyncio
async def test_nested_agent_task_no_deadlock():
"""session.run() must return when an AgentTask hands off to a nested task
that collects additional user input before completing."""
llm = _build_fake_llm()
async with AgentSession(llm=llm) as sess:
await sess.start(RootAgent())
# This must not deadlock — it should return once the on_enter chain
# has started, even though InnerTask is still waiting for user input.
first_result = await asyncio.wait_for(sess.run(user_input="go"), timeout=5.0)
assert first_result is not None
# Now complete InnerTask by triggering the finish tool
second_result = await asyncio.wait_for(sess.run(user_input="done"), timeout=5.0)
assert second_result is not None
class SimpleTask(AgentTask):
"""A task that needs a user turn to complete (user must trigger 'finish')."""
def __init__(self) -> None:
super().__init__(instructions="simple task")
async def on_enter(self) -> None:
self.session.generate_reply(instructions="task_greeting")
@function_tool
async def finish(self, ctx: RunContext) -> str:
"""Called to complete the task."""
self.complete(None)
return "done"
class EnterHandoffAgent(Agent):
"""Agent whose on_enter reply calls a tool that awaits an AgentTask.
The on_enter speech predates any session.run(), so the run doesn't watch it.
"""
def __init__(self) -> None:
super().__init__(instructions="root agent")
async def on_enter(self) -> None:
await self.session.generate_reply(instructions="enter_greeting")
@function_tool
async def start_task(self, ctx: RunContext) -> str:
"""Transitions into SimpleTask."""
await SimpleTask()
return "task completed"
@pytest.mark.asyncio
async def test_handoff_from_pre_run_speech():
"""A handoff triggered by a speech created before session.run() (e.g. in
on_enter) must keep the run alive until the new activity has started;
otherwise the next run() races the transition and gets rejected."""
llm = FakeLLM(
fake_responses=[
# on_enter generate_reply(instructions="enter_greeting") -> calls start_task;
# slow enough that run(user_input="hi") starts before the tool call lands
FakeLLMResponse(
input="enter_greeting",
content="",
ttft=1.0,
duration=1.0,
tool_calls=[FunctionToolCall(name="start_task", arguments="{}", call_id="call_1")],
),
# user says "hi" while the handoff is in flight; this is the only
# speech the first run watches
FakeLLMResponse(input="hi", content="hello!", ttft=1.0, duration=2.0),
# SimpleTask on_enter greeting
FakeLLMResponse(input="task_greeting", content="hello from task", ttft=0, duration=0),
# user says "bye" -> LLM calls finish
FakeLLMResponse(
input="bye",
content="",
ttft=0,
duration=0,
tool_calls=[FunctionToolCall(name="finish", arguments="{}", call_id="call_2")],
),
# after start_task tool output, LLM responds
FakeLLMResponse(input="task completed", content="all done", ttft=0, duration=0),
]
)
async with AgentSession(llm=llm) as sess:
await sess.start(EnterHandoffAgent())
await asyncio.wait_for(sess.run(user_input="hi"), timeout=5.0)
assert isinstance(sess.current_agent, SimpleTask)
await asyncio.wait_for(sess.run(user_input="bye"), timeout=5.0)
assert isinstance(sess.current_agent, EnterHandoffAgent)
def _build_fake_llm() -> FakeLLM:
return FakeLLM(
fake_responses=[
# user says "go" -> LLM calls start_outer
FakeLLMResponse(
input="go",
content="",
ttft=0,
duration=0,
tool_calls=[FunctionToolCall(name="start_outer", arguments="{}", call_id="call_1")],
),
# OuterTask on_enter generate_reply(instructions="outer_greeting")
# -> LLM calls start_inner
FakeLLMResponse(
input="outer_greeting",
content="",
ttft=0,
duration=0,
tool_calls=[FunctionToolCall(name="start_inner", arguments="{}", call_id="call_2")],
),
# InnerTask on_enter generate_reply(instructions="inner_greeting")
# -> LLM just says hello (no tool call yet — needs user input to finish)
FakeLLMResponse(
input="inner_greeting",
content="hello from inner",
ttft=0,
duration=0,
),
# user says "done" -> LLM calls finish
FakeLLMResponse(
input="done",
content="",
ttft=0,
duration=0,
tool_calls=[FunctionToolCall(name="finish", arguments="{}", call_id="call_3")],
),
# after finish tool output, LLM responds to start_inner tool output
FakeLLMResponse(
input="inner completed",
content="",
ttft=0,
duration=0,
),
# after start_outer tool output, LLM responds
FakeLLMResponse(
input="outer completed",
content="all done",
ttft=0,
duration=0,
),
]
)