1924 lines
73 KiB
Python
1924 lines
73 KiB
Python
import enum
|
|
import json
|
|
from typing import Annotated, Any, Literal
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, Field, ValidationError
|
|
|
|
from livekit.agents import Agent
|
|
from livekit.agents.llm import (
|
|
ProviderTool,
|
|
Tool,
|
|
ToolContext,
|
|
ToolError,
|
|
ToolFlag,
|
|
Toolset,
|
|
function_tool,
|
|
)
|
|
from livekit.agents.llm._strict import to_strict_json_schema
|
|
from livekit.agents.llm.utils import (
|
|
build_legacy_openai_schema,
|
|
build_strict_openai_schema,
|
|
function_arguments_to_pydantic_model,
|
|
prepare_function_arguments,
|
|
)
|
|
|
|
pytestmark = [pytest.mark.unit, pytest.mark.virtual_time, pytest.mark.no_concurrent]
|
|
|
|
|
|
class MockOption(str, enum.Enum):
|
|
A = "a"
|
|
B = "b"
|
|
C = "c"
|
|
|
|
|
|
@function_tool
|
|
async def mock_tool_1(arg1: str, opt_arg2: str | None = None) -> dict[str, str | None]:
|
|
"""Test tool 1
|
|
Args:
|
|
arg1: The first argument
|
|
opt_arg2: The optional second argument
|
|
"""
|
|
return {"arg1": arg1, "opt_arg2": opt_arg2}
|
|
|
|
|
|
@function_tool
|
|
async def mock_tool_2(arg1: MockOption) -> str:
|
|
"""Test tool 2"""
|
|
return arg1.value
|
|
|
|
|
|
@function_tool
|
|
async def mock_tool_3() -> str:
|
|
"""Test tool 3"""
|
|
return "tool3"
|
|
|
|
|
|
@function_tool(
|
|
raw_schema={
|
|
"name": "raw_tool_1",
|
|
"description": "A raw tool",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
)
|
|
async def raw_tool_1() -> str:
|
|
"""Raw test tool 1"""
|
|
return "raw1"
|
|
|
|
|
|
@function_tool(
|
|
raw_schema={
|
|
"name": "raw_tool_2",
|
|
"description": "Another raw tool",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {},
|
|
},
|
|
}
|
|
)
|
|
async def raw_tool_2() -> str:
|
|
"""Raw test tool 2"""
|
|
return "raw2"
|
|
|
|
|
|
class DummyAgent(Agent):
|
|
def __init__(self):
|
|
super().__init__(instructions="You are a dummy agent.")
|
|
|
|
@function_tool
|
|
async def mock_tool_in_agent(
|
|
self, arg1: str, opt_arg2: str | None = None
|
|
) -> dict[str, str | None]:
|
|
"""Mock tool in agent
|
|
|
|
Args:
|
|
arg1: The first argument
|
|
opt_arg2: The optional second argument
|
|
"""
|
|
return {"arg1": arg1, "opt_arg2": opt_arg2}
|
|
|
|
@function_tool(
|
|
raw_schema={
|
|
"name": "raw_tool_in_agent",
|
|
"description": "A raw tool in agent",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"arg1": {"type": "string", "description": "The first argument"}},
|
|
},
|
|
}
|
|
)
|
|
async def raw_tool_in_agent(self, raw_arguments: dict[str, object]) -> str:
|
|
"""Raw tool in agent"""
|
|
assert "arg1" in raw_arguments
|
|
return raw_arguments["arg1"]
|
|
|
|
|
|
class DummyProviderTool(ProviderTool):
|
|
def __init__(self, id: str):
|
|
super().__init__(id=id)
|
|
|
|
|
|
class MockToolset1(Toolset):
|
|
def __init__(self):
|
|
super().__init__(id="mock_toolset_1")
|
|
|
|
@property
|
|
def tools(self) -> list[Tool]:
|
|
return [mock_tool_1, mock_tool_2]
|
|
|
|
|
|
class MockToolset2(Toolset):
|
|
def __init__(self):
|
|
super().__init__(id="mock_toolset_2")
|
|
|
|
@property
|
|
def tools(self) -> list[Tool]:
|
|
return [mock_tool_2, DummyProviderTool("provider1")]
|
|
|
|
|
|
class TestToolContext:
|
|
def test_equals_empty_contexts(self):
|
|
ctx1 = ToolContext.empty()
|
|
ctx2 = ToolContext.empty()
|
|
assert ctx1 == ctx2
|
|
|
|
def test_equals_same_tools(self):
|
|
ctx1 = ToolContext([mock_tool_1, mock_tool_2])
|
|
ctx2 = ToolContext([mock_tool_1, mock_tool_2])
|
|
assert ctx1 == ctx2
|
|
|
|
def test_equals_same_tools_different_order(self):
|
|
ctx1 = ToolContext([mock_tool_1, mock_tool_2])
|
|
ctx2 = ToolContext([mock_tool_2, mock_tool_1])
|
|
assert ctx1 == ctx2
|
|
|
|
def test_not_equals_different_tools(self):
|
|
ctx1 = ToolContext([mock_tool_1, mock_tool_2])
|
|
ctx2 = ToolContext([mock_tool_1, mock_tool_3])
|
|
assert ctx1 != ctx2
|
|
|
|
def test_not_equals_different_provider_tools(self):
|
|
provider1 = DummyProviderTool("provider1")
|
|
provider2 = DummyProviderTool("provider2")
|
|
provider3 = DummyProviderTool("provider3")
|
|
|
|
ctx1 = ToolContext([mock_tool_1, provider1, provider2])
|
|
ctx2 = ToolContext([mock_tool_1, provider1, provider3])
|
|
assert ctx1 != ctx2
|
|
|
|
def test_equals_different_provider_tools_order(self):
|
|
provider1 = DummyProviderTool("provider1")
|
|
provider2 = DummyProviderTool("provider2")
|
|
|
|
ctx1 = ToolContext([mock_tool_1, provider1, provider2])
|
|
ctx2 = ToolContext([mock_tool_1, provider2, provider1])
|
|
assert ctx1 == ctx2
|
|
|
|
def test_not_equals_missing_provider_tools(self):
|
|
provider1 = DummyProviderTool("provider1")
|
|
|
|
ctx1 = ToolContext([mock_tool_1, provider1])
|
|
ctx2 = ToolContext([mock_tool_1])
|
|
assert ctx1 != ctx2
|
|
|
|
def test_copy_equals_original(self):
|
|
ctx1 = ToolContext([mock_tool_1, mock_tool_2])
|
|
ctx2 = ctx1.copy()
|
|
assert ctx1 == ctx2
|
|
|
|
def test_openai_responses_raw_schema_does_not_mutate_tool_schema(self):
|
|
ctx = ToolContext([raw_tool_1])
|
|
raw_schema = raw_tool_1.info.raw_schema.copy()
|
|
|
|
responses_tools = ctx.parse_function_tools("openai.responses")
|
|
|
|
assert responses_tools[0]["type"] == "function"
|
|
assert raw_tool_1.info.raw_schema == raw_schema
|
|
assert "type" not in raw_tool_1.info.raw_schema
|
|
|
|
chat_tools = ctx.parse_function_tools("openai")
|
|
assert chat_tools[0] == {
|
|
"type": "function",
|
|
"function": raw_tool_1.info.raw_schema,
|
|
}
|
|
assert "type" not in chat_tools[0]["function"]
|
|
|
|
def test_update_tools_changes_equality(self):
|
|
ctx1 = ToolContext([mock_tool_1])
|
|
ctx2 = ToolContext([mock_tool_1])
|
|
assert ctx1 == ctx2
|
|
|
|
ctx1.update_tools([mock_tool_1, mock_tool_2])
|
|
assert ctx1 != ctx2
|
|
|
|
def test_not_equals_same_name_different_implementation(self):
|
|
@function_tool
|
|
async def duplicate_name() -> str:
|
|
"""First implementation"""
|
|
return "first"
|
|
|
|
@function_tool
|
|
async def duplicate_name() -> str: # noqa: F811
|
|
"""Second implementation"""
|
|
return "second"
|
|
|
|
ctx1 = ToolContext([mock_tool_1])
|
|
ctx1._fnc_tools_map["duplicate_name"] = duplicate_name
|
|
|
|
@function_tool
|
|
async def duplicate_name() -> str: # noqa: F811
|
|
"""Third implementation"""
|
|
return "third"
|
|
|
|
ctx2 = ToolContext([mock_tool_1])
|
|
ctx2._fnc_tools_map["duplicate_name"] = duplicate_name
|
|
|
|
assert ctx1 != ctx2
|
|
|
|
def test_all_tools_returns_combined_list(self):
|
|
provider1 = DummyProviderTool("provider1")
|
|
provider2 = DummyProviderTool("provider2")
|
|
|
|
ctx = ToolContext([mock_tool_1, mock_tool_2, provider1, provider2])
|
|
all_tools = ctx.flatten()
|
|
|
|
assert len(all_tools) == 4
|
|
assert mock_tool_1 in all_tools
|
|
assert mock_tool_2 in all_tools
|
|
assert provider1 in all_tools
|
|
assert provider2 in all_tools
|
|
|
|
def test_toolset_with_regular_tools(self):
|
|
toolset = MockToolset1() # contains mock_tool_1, mock_tool_2
|
|
ctx = ToolContext([toolset, mock_tool_3])
|
|
|
|
all_tools = ctx.flatten()
|
|
assert mock_tool_1 in all_tools
|
|
assert mock_tool_2 in all_tools
|
|
assert mock_tool_3 in all_tools
|
|
assert len(ctx.function_tools) == 3
|
|
assert toolset in ctx.toolsets
|
|
|
|
# copy should preserve toolsets
|
|
ctx_copy = ctx.copy()
|
|
assert ctx == ctx_copy
|
|
assert toolset in ctx_copy.toolsets
|
|
|
|
def test_toolset_same_instance_dedup(self):
|
|
# same instance appearing in multiple places is allowed (deduplication)
|
|
toolset = MockToolset1() # contains mock_tool_1, mock_tool_2
|
|
ToolContext([toolset, mock_tool_1]) # same mock_tool_1 instance, no conflict
|
|
ToolContext([mock_tool_1, toolset])
|
|
ToolContext([MockToolset1(), MockToolset2()]) # same mock_tool_2 instance
|
|
|
|
def test_toolset_duplicate_name_conflict(self):
|
|
toolset = MockToolset1() # contains mock_tool_1, mock_tool_2
|
|
|
|
# different instances with the same name should raise
|
|
@function_tool
|
|
async def mock_tool_1() -> str:
|
|
"""Duplicate name, different instance"""
|
|
return ""
|
|
|
|
with pytest.raises(ValueError, match="duplicate function name"):
|
|
ToolContext([toolset, mock_tool_1])
|
|
|
|
def test_toolset_equality(self):
|
|
toolset = MockToolset1()
|
|
|
|
# same toolset instance -> equal
|
|
ctx1 = ToolContext([toolset, mock_tool_3])
|
|
ctx2 = ToolContext([mock_tool_3, toolset]) # different order
|
|
assert ctx1 == ctx2
|
|
|
|
# different toolset instances -> not equal
|
|
ctx3 = ToolContext([MockToolset1()])
|
|
ctx4 = ToolContext([MockToolset1()])
|
|
assert ctx3 != ctx4
|
|
|
|
# toolset vs expanded tools -> not equal
|
|
ctx5 = ToolContext([toolset])
|
|
ctx6 = ToolContext([mock_tool_1, mock_tool_2])
|
|
assert ctx5 != ctx6
|
|
|
|
def test_exclude_hides_toolset_member_but_keeps_toolset(self):
|
|
toolset = MockToolset1() # contains mock_tool_1, mock_tool_2
|
|
ctx = ToolContext([toolset, mock_tool_3])
|
|
|
|
ctx._exclude([mock_tool_1])
|
|
|
|
# the excluded member is no longer callable / no longer visible to the LLM
|
|
assert "mock_tool_1" not in ctx.function_tools
|
|
assert mock_tool_1 not in ctx.flatten()
|
|
# its sibling and the top-level tool remain callable
|
|
assert "mock_tool_2" in ctx.function_tools
|
|
assert "mock_tool_3" in ctx.function_tools
|
|
# the toolset itself stays so executor routing and lifecycle are unaffected
|
|
assert toolset in ctx.toolsets
|
|
|
|
def test_exclude_empty_is_noop(self):
|
|
ctx = ToolContext([mock_tool_1, mock_tool_2])
|
|
ctx._exclude([])
|
|
assert set(ctx.function_tools) == {"mock_tool_1", "mock_tool_2"}
|
|
|
|
|
|
def test_end_call_tool_ignore_on_enter_flag():
|
|
from livekit.agents.beta import EndCallTool
|
|
|
|
(tool,) = EndCallTool().tools # defaults to ignore_on_enter=False
|
|
assert not (tool.info.flags & ToolFlag.IGNORE_ON_ENTER)
|
|
|
|
(tool,) = EndCallTool(ignore_on_enter=True).tools
|
|
assert tool.info.flags & ToolFlag.IGNORE_ON_ENTER
|
|
|
|
|
|
class TestToolExecution:
|
|
def test_function_arguments_to_pydantic_model(self):
|
|
schema1 = function_arguments_to_pydantic_model(mock_tool_1)
|
|
assert schema1.model_json_schema() == {
|
|
"properties": {
|
|
"arg1": {"description": "The first argument", "title": "Arg1", "type": "string"},
|
|
"opt_arg2": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"default": None,
|
|
"description": "The optional second argument",
|
|
"title": "Opt Arg2",
|
|
},
|
|
},
|
|
"required": ["arg1"],
|
|
"title": "MockTool1Args",
|
|
"type": "object",
|
|
}
|
|
|
|
schema2 = function_arguments_to_pydantic_model(mock_tool_2)
|
|
assert schema2.model_json_schema() == {
|
|
"$defs": {
|
|
"MockOption": {"enum": ["a", "b", "c"], "title": "MockOption", "type": "string"}
|
|
},
|
|
"properties": {"arg1": {"$ref": "#/$defs/MockOption"}},
|
|
"required": ["arg1"],
|
|
"title": "MockTool2Args",
|
|
"type": "object",
|
|
}
|
|
|
|
agent = DummyAgent()
|
|
schema3 = function_arguments_to_pydantic_model(agent.mock_tool_in_agent)
|
|
assert schema3.model_json_schema() == {
|
|
"properties": {
|
|
"arg1": {"description": "The first argument", "title": "Arg1", "type": "string"},
|
|
"opt_arg2": {
|
|
"anyOf": [{"type": "string"}, {"type": "null"}],
|
|
"default": None,
|
|
"description": "The optional second argument",
|
|
"title": "Opt Arg2",
|
|
},
|
|
},
|
|
"required": ["arg1"],
|
|
"title": "MockToolInAgentArgs",
|
|
"type": "object",
|
|
}
|
|
|
|
schema4 = function_arguments_to_pydantic_model(agent.raw_tool_in_agent)
|
|
assert schema4.model_json_schema() == {
|
|
"properties": {
|
|
"raw_arguments": {
|
|
"additionalProperties": True,
|
|
"title": "Raw Arguments",
|
|
"type": "object",
|
|
}
|
|
},
|
|
"required": ["raw_arguments"],
|
|
"title": "RawToolInAgentArgs",
|
|
"type": "object",
|
|
}
|
|
|
|
def test_field_constraints_preserved(self):
|
|
# Field(...) constraints (ge/le/pattern/...) live in FieldInfo.metadata,
|
|
# not its attributes; they must survive the signature -> pydantic model
|
|
# conversion so the model both advertises and enforces them.
|
|
@function_tool
|
|
async def book(count: Annotated[int, Field(ge=1, le=10, description="how many")]) -> str:
|
|
"""Book a thing."""
|
|
return "ok"
|
|
|
|
model = function_arguments_to_pydantic_model(book)
|
|
prop = model.model_json_schema()["properties"]["count"]
|
|
assert prop["minimum"] == 1
|
|
assert prop["maximum"] == 10
|
|
assert prop["description"] == "how many"
|
|
|
|
model(count=5) # within bounds
|
|
for bad in (0, 11):
|
|
with pytest.raises(ValidationError):
|
|
model(count=bad)
|
|
|
|
async def test_tool_execution(self):
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=mock_tool_1, json_arguments='{"arg1": "test", "opt_arg2": "test2"}'
|
|
)
|
|
assert args == ("test", "test2")
|
|
assert kwargs == {}
|
|
output = await mock_tool_1(*args, **kwargs)
|
|
assert output == {"arg1": "test", "opt_arg2": "test2"}
|
|
|
|
args, kwargs = prepare_function_arguments(fnc=mock_tool_2, json_arguments='{"arg1": "a"}')
|
|
assert args == ("a",)
|
|
assert kwargs == {}
|
|
output = await mock_tool_2(*args, **kwargs)
|
|
assert output == "a"
|
|
|
|
agent = DummyAgent()
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=agent.mock_tool_in_agent, json_arguments='{"arg1": "test", "opt_arg2": "test2"}'
|
|
)
|
|
assert args == ("test", "test2")
|
|
assert kwargs == {}
|
|
output = await agent.mock_tool_in_agent(*args, **kwargs)
|
|
assert output == {"arg1": "test", "opt_arg2": "test2"}
|
|
|
|
async def test_raw_function_tool_execution(self):
|
|
agent = DummyAgent()
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=agent.raw_tool_in_agent, json_arguments='{"arg1": "test"}'
|
|
)
|
|
assert args == ({"arg1": "test"},)
|
|
assert kwargs == {}
|
|
output = await agent.raw_tool_in_agent(*args, **kwargs)
|
|
assert output == "test"
|
|
|
|
async def test_tool_execution_with_default_value(self):
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=mock_tool_1, json_arguments='{"arg1": "test"}'
|
|
)
|
|
assert args == ("test", None)
|
|
assert kwargs == {}
|
|
output = await mock_tool_1(*args, **kwargs)
|
|
assert output == {"arg1": "test", "opt_arg2": None}
|
|
|
|
agent = DummyAgent()
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=agent.mock_tool_in_agent, json_arguments='{"arg1": "test"}'
|
|
)
|
|
assert args == ("test", None)
|
|
output = await agent.mock_tool_in_agent(*args, **kwargs)
|
|
assert output == {"arg1": "test", "opt_arg2": None}
|
|
|
|
def test_unexpected_arguments(self):
|
|
with pytest.raises(ToolError, match="validation error"):
|
|
prepare_function_arguments(fnc=mock_tool_1, json_arguments='{"opt_arg2": "test2"}')
|
|
|
|
with pytest.raises(ToolError, match="Received no value for required parameter"):
|
|
prepare_function_arguments(fnc=mock_tool_2, json_arguments='{"arg1": null}')
|
|
|
|
with pytest.raises(ToolError, match="validation error"):
|
|
prepare_function_arguments(fnc=mock_tool_2, json_arguments='{"arg1": "d"}')
|
|
|
|
agent = DummyAgent()
|
|
with pytest.raises(ToolError, match="validation error"):
|
|
prepare_function_arguments(
|
|
fnc=agent.mock_tool_in_agent, json_arguments='{"opt_arg2": "test2"}'
|
|
)
|
|
|
|
def test_repairs_malformed_json_arguments(self):
|
|
"""LLMs occasionally emit syntactically invalid JSON for tool calls.
|
|
We should recover via json_repair instead of giving up immediately."""
|
|
# Missing closing brace.
|
|
args, kwargs = prepare_function_arguments(
|
|
fnc=mock_tool_1, json_arguments='{"arg1": "hi", "opt_arg2": "yo"'
|
|
)
|
|
assert args == ("hi", "yo")
|
|
|
|
# Unquoted-style payload (json_repair handles loose forms).
|
|
args, kwargs = prepare_function_arguments(fnc=mock_tool_1, json_arguments="{arg1: 'hi'}")
|
|
assert args == ("hi", None)
|
|
|
|
def test_unrepairable_json_arguments_raise(self):
|
|
"""If json_repair can't recover anything meaningful, the error should
|
|
be surfaced as a ToolError so the LLM can self-correct on the next turn."""
|
|
with pytest.raises(ToolError, match="could not parse"):
|
|
prepare_function_arguments(fnc=mock_tool_1, json_arguments="not json at all")
|
|
|
|
def test_repairs_gemma_template_token_leak(self):
|
|
"""Gemma chat-template tokens (<|...|>) sometimes leak into function-call
|
|
arguments, producing unparseable JSON. The repair pass should both fix
|
|
the JSON shape AND strip the leaked tokens so the original payload
|
|
(an order id, here) survives the round-trip cleanly."""
|
|
|
|
@function_tool
|
|
async def remove_order_item(order_id: list[str]) -> str:
|
|
return ",".join(order_id)
|
|
|
|
# Real failing payload captured from a Gemma 4 deployment. The model
|
|
# tried to emit `{"order_id": ["O_WAAB70"]}` but `<|"|"` template
|
|
# tokens leaked around the value, breaking the JSON (the string is
|
|
# never properly closed before `]}`).
|
|
leaked = '{"order_id": ["<|\\"|\\"O_WAAB70<|\\"|\\"]}'
|
|
|
|
args, kwargs = prepare_function_arguments(fnc=remove_order_item, json_arguments=leaked)
|
|
assert args == (["O_WAAB70"],), f"expected order_id=['O_WAAB70'], got {args}"
|
|
|
|
def test_strip_template_tokens_leaves_clean_strings_alone(self):
|
|
"""Token stripping should only kick in on the repair path; well-formed
|
|
arguments that happen to contain `<|...|>` substrings should pass through
|
|
unmodified (we only run the strip pass when json_repair was triggered)."""
|
|
args, _ = prepare_function_arguments(fnc=mock_tool_1, json_arguments='{"arg1": "<|safe|>"}')
|
|
assert args == ("<|safe|>", None)
|
|
|
|
def test_parse_function_arguments_strict(self):
|
|
"""parse_function_arguments should accept valid JSON unchanged."""
|
|
from livekit.agents.llm.utils import parse_function_arguments
|
|
|
|
assert parse_function_arguments('{"a": 1, "b": [2, 3]}') == {"a": 1, "b": [2, 3]}
|
|
# Empty/None payloads normalize to {}.
|
|
assert parse_function_arguments("{}") == {}
|
|
assert parse_function_arguments("null") == {}
|
|
|
|
def test_parse_function_arguments_non_dict_raises(self):
|
|
"""parse_function_arguments must reject non-dict shapes (the caller
|
|
always expects a kwarg-style mapping)."""
|
|
from livekit.agents.llm.utils import parse_function_arguments
|
|
|
|
with pytest.raises(ValueError, match="expected dict"):
|
|
parse_function_arguments("[1, 2, 3]")
|
|
# Bare JSON strings hit the Nova-Sonic double-encoded unwrap path,
|
|
# which raises a more specific error.
|
|
with pytest.raises(ValueError, match="non-JSON string"):
|
|
parse_function_arguments('"just a string"')
|
|
|
|
def test_prepare_function_arguments_accepts_pre_parsed_dict(self):
|
|
"""Callers that already parsed the JSON (e.g. _execute_tools_task after
|
|
canonicalizing fnc_call.arguments) can pass the dict directly. Must
|
|
behave the same as passing the equivalent JSON string."""
|
|
from_string = prepare_function_arguments(
|
|
fnc=mock_tool_1, json_arguments='{"arg1": "hi", "opt_arg2": "yo"}'
|
|
)
|
|
from_dict = prepare_function_arguments(
|
|
fnc=mock_tool_1, json_arguments={"arg1": "hi", "opt_arg2": "yo"}
|
|
)
|
|
assert from_string == from_dict == (("hi", "yo"), {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_call_preserves_valid_argument_structure(self):
|
|
"""Canonicalization may normalize whitespace, but for already-valid JSON
|
|
the *structure* (keys, values) must round-trip identically. The repair
|
|
path must not silently alter legitimate arguments."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
tool_ctx = ToolContext([mock_tool_1])
|
|
original_args = '{"arg1": "hello", "opt_arg2": "world"}'
|
|
tool_call = FunctionToolCall(
|
|
name="mock_tool_1", arguments=original_args, call_id="call-valid-1"
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is False
|
|
|
|
# Whatever ended up in fnc_call.arguments must decode to the same dict.
|
|
assert json.loads(result.fnc_call.arguments) == {"arg1": "hello", "opt_arg2": "world"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_call_canonicalizes_repaired_arguments(self):
|
|
"""After a successful repair, the FunctionCall's `arguments` should be
|
|
rewritten to canonical JSON so the next LLM turn doesn't see the
|
|
broken string in conversation history (which would fail re-serialization
|
|
and cause 5xx from providers like Vertex)."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
@function_tool
|
|
async def remove_order_item(order_id: list[str]) -> str:
|
|
return ",".join(order_id)
|
|
|
|
tool_ctx = ToolContext([remove_order_item])
|
|
raw_broken = '{"order_id": ["<|\\"|\\"O_WAAB70<|\\"|\\"]}'
|
|
tool_call = FunctionToolCall(
|
|
name="remove_order_item",
|
|
arguments=raw_broken,
|
|
call_id="call-canon-1",
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is False
|
|
assert result.fnc_call_out.output == "O_WAAB70"
|
|
|
|
# The arguments stored on the FunctionCall should now be valid JSON
|
|
# that re-serializes cleanly to the repaired payload — so subsequent
|
|
# LLM turns don't choke on broken syntax in the history.
|
|
assert result.fnc_call.arguments != raw_broken
|
|
parsed = json.loads(result.fnc_call.arguments)
|
|
assert parsed == {"order_id": ["O_WAAB70"]}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_function_call_canonicalizes_when_validation_fails(self):
|
|
"""When JSON parses (possibly via repair) but pydantic validation then
|
|
fails, fnc_call.arguments must STILL be canonicalized — otherwise the
|
|
broken raw payload propagates into chat history and the next LLM turn
|
|
gets a 5xx from providers that re-serialize."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
@function_tool
|
|
async def take_int(arg1: int) -> str:
|
|
return str(arg1)
|
|
|
|
tool_ctx = ToolContext([take_int])
|
|
# malformed JSON that json_repair can fix, but the value is the wrong
|
|
# type for pydantic validation (string where int is required)
|
|
raw_broken = '{arg1: "not_an_int"}'
|
|
tool_call = FunctionToolCall(
|
|
name="take_int", arguments=raw_broken, call_id="call-validate-fail"
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
|
|
# Validation failed
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is True
|
|
|
|
# But fnc_call.arguments was canonicalized despite the validation error
|
|
assert result.fnc_call.arguments != raw_broken
|
|
parsed = json.loads(result.fnc_call.arguments)
|
|
assert parsed == {"arg1": "not_an_int"}
|
|
|
|
|
|
class TestNoParametersSchema:
|
|
"""Test that functions with no parameters generate valid JSON schema."""
|
|
|
|
def test_legacy_schema_no_parameters_has_no_required(self):
|
|
"""Legacy schema for no-param function must not include 'required'."""
|
|
params = build_legacy_openai_schema(mock_tool_3)["function"]["parameters"]
|
|
assert "properties" in params
|
|
assert params["properties"] == {}
|
|
assert "required" not in params
|
|
|
|
def test_strict_schema_no_parameters_has_no_required(self):
|
|
"""Strict schema for no-param function must not include 'required'."""
|
|
params = build_strict_openai_schema(mock_tool_3)["function"]["parameters"]
|
|
assert "properties" in params
|
|
assert params["properties"] == {}
|
|
assert "required" not in params
|
|
|
|
|
|
class _NullableEnumModel(BaseModel):
|
|
status: Literal["active", "inactive"] | None = Field(None)
|
|
|
|
|
|
class _NullableBoolModel(BaseModel):
|
|
flag: bool | None = Field(None)
|
|
|
|
|
|
class _NonNullableEnumModel(BaseModel):
|
|
status: Literal["active", "inactive"] = Field(...)
|
|
|
|
|
|
class TestStrictJsonSchema:
|
|
def test_nullable_enum_includes_null_in_enum(self):
|
|
schema = to_strict_json_schema(_NullableEnumModel)
|
|
status = schema["properties"]["status"]
|
|
assert None in status["enum"], f"enum should contain None: {status}"
|
|
assert "null" in status["type"], f"type should contain 'null': {status}"
|
|
|
|
def test_nullable_bool_has_null_type(self):
|
|
schema = to_strict_json_schema(_NullableBoolModel)
|
|
flag = schema["properties"]["flag"]
|
|
assert "enum" not in flag, f"bool field should not have enum: {flag}"
|
|
assert "null" in flag["type"], f"type should contain 'null': {flag}"
|
|
|
|
def test_non_nullable_enum_excludes_null(self):
|
|
schema = to_strict_json_schema(_NonNullableEnumModel)
|
|
status = schema["properties"]["status"]
|
|
assert None not in status["enum"], f"enum should not contain None: {status}"
|
|
assert "null" not in status.get("type", []), f"type should not contain 'null': {status}"
|
|
|
|
|
|
class _CarModel(BaseModel):
|
|
vehicle: Literal["Car"]
|
|
brand: str
|
|
color: str
|
|
|
|
|
|
class _BikeModel(BaseModel):
|
|
vehicle: Literal["Bike"]
|
|
brand: str
|
|
color: str
|
|
|
|
|
|
class _DiscriminatedUnionModel(BaseModel):
|
|
item: Annotated[_CarModel | _BikeModel, Field(discriminator="vehicle")]
|
|
|
|
|
|
class _NestedDiscriminatedUnionModel(BaseModel):
|
|
items: list[Annotated[_CarModel | _BikeModel, Field(discriminator="vehicle")]]
|
|
|
|
|
|
def _has_one_of(schema: object) -> bool:
|
|
"""Recursively check if any dict in the schema tree contains 'oneOf'."""
|
|
if isinstance(schema, dict):
|
|
if "oneOf" in schema:
|
|
return True
|
|
return any(_has_one_of(v) for v in schema.values())
|
|
if isinstance(schema, list):
|
|
return any(_has_one_of(v) for v in schema)
|
|
return False
|
|
|
|
|
|
class TestDiscriminatedUnionSchema:
|
|
"""Test that discriminated unions use anyOf instead of oneOf in strict schema."""
|
|
|
|
def test_discriminated_union_uses_anyof_not_oneof(self):
|
|
"""Pydantic emits oneOf for discriminated unions, but OpenAI strict mode
|
|
rejects oneOf. Ensure to_strict_json_schema converts oneOf to anyOf."""
|
|
schema = to_strict_json_schema(_DiscriminatedUnionModel)
|
|
assert not _has_one_of(schema), (
|
|
f"schema should not contain oneOf: {json.dumps(schema, indent=2)}"
|
|
)
|
|
item = schema["properties"]["item"]
|
|
assert "anyOf" in item, f"item should have anyOf: {json.dumps(item, indent=2)}"
|
|
assert len(item["anyOf"]) == 2, f"item should have 2 variants: {json.dumps(item, indent=2)}"
|
|
|
|
def test_nested_discriminated_union_uses_anyof_not_oneof(self):
|
|
"""Nested discriminated unions should also convert oneOf to anyOf."""
|
|
schema = to_strict_json_schema(_NestedDiscriminatedUnionModel)
|
|
assert not _has_one_of(schema), (
|
|
f"nested schema should not contain oneOf: {json.dumps(schema, indent=2)}"
|
|
)
|
|
|
|
def test_discriminated_union_build_strict_openai_schema(self):
|
|
"""End-to-end: build_strict_openai_schema should not produce oneOf for
|
|
a function tool with a discriminated union parameter."""
|
|
|
|
@function_tool
|
|
async def lookup_vehicle(
|
|
item: Annotated[_CarModel | _BikeModel, Field(discriminator="vehicle")],
|
|
) -> str:
|
|
"""Look up a vehicle."""
|
|
return str(item)
|
|
|
|
schema = build_strict_openai_schema(lookup_vehicle)
|
|
schema_str = json.dumps(schema)
|
|
assert '"oneOf"' not in schema_str, (
|
|
f"strict openai schema should not contain oneOf: {json.dumps(schema, indent=2)}"
|
|
)
|
|
|
|
|
|
class _OpenEnumModel(BaseModel):
|
|
"""Simulates a codegen'd "open enum" pattern (e.g. Fern Python SDK).
|
|
Union[Literal["a", "b"], Any] produces an anyOf with a bare {} entry."""
|
|
|
|
preference: Literal["a", "b"] | Any | None = None
|
|
|
|
|
|
class _NestedOpenEnumModel(BaseModel):
|
|
items: list[_OpenEnumModel]
|
|
|
|
|
|
def _has_empty_schema(schema: object) -> bool:
|
|
"""Recursively check if any dict in the schema tree is an empty {}."""
|
|
if isinstance(schema, dict):
|
|
if schema == {}:
|
|
return True
|
|
return any(_has_empty_schema(v) for v in schema.values())
|
|
if isinstance(schema, list):
|
|
return any(_has_empty_schema(v) for v in schema)
|
|
return False
|
|
|
|
|
|
class TestEmptySchemaStripping:
|
|
"""Test that empty {} entries are stripped from anyOf/oneOf."""
|
|
|
|
def test_open_enum_strips_empty_anyof(self):
|
|
schema = to_strict_json_schema(_OpenEnumModel)
|
|
assert not _has_empty_schema(schema), (
|
|
f"schema should not contain empty {{}}: {json.dumps(schema, indent=2)}"
|
|
)
|
|
|
|
def test_nested_open_enum_strips_empty_anyof(self):
|
|
schema = to_strict_json_schema(_NestedOpenEnumModel)
|
|
assert not _has_empty_schema(schema), (
|
|
f"nested schema should not contain empty {{}}: {json.dumps(schema, indent=2)}"
|
|
)
|
|
|
|
def test_single_variant_after_strip_is_unwrapped(self):
|
|
"""When stripping {} leaves a single variant, anyOf should be unwrapped."""
|
|
schema = to_strict_json_schema(_OpenEnumModel)
|
|
pref = schema["properties"]["preference"]
|
|
# After stripping {}, the union should be simplified (no single-element anyOf)
|
|
any_of = pref.get("anyOf")
|
|
if any_of is not None:
|
|
assert len(any_of) != 1, (
|
|
f"single-element anyOf should be unwrapped: {json.dumps(pref, indent=2)}"
|
|
)
|
|
|
|
|
|
class TestExecuteFunctionCallValidationErrors:
|
|
"""Test that argument validation errors are surfaced to the LLM."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_missing_required_arg_surfaces_error(self):
|
|
"""When the LLM omits a required argument, the error details should be
|
|
returned as a ToolError (is_error=True) instead of 'An internal error occurred'."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
tool_ctx = ToolContext([mock_tool_1])
|
|
tool_call = FunctionToolCall(
|
|
name="mock_tool_1",
|
|
arguments="{}", # missing required 'arg1'
|
|
call_id="test-call-1",
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is True
|
|
# The error message should contain details about what went wrong,
|
|
# NOT the generic "An internal error occurred"
|
|
assert "An internal error occurred" not in result.fnc_call_out.output
|
|
assert "arg1" in result.fnc_call_out.output
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrong_type_arg_surfaces_error(self):
|
|
"""When the LLM provides an argument with the wrong type, the validation
|
|
error details should be surfaced."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
tool_ctx = ToolContext([mock_tool_2])
|
|
tool_call = FunctionToolCall(
|
|
name="mock_tool_2",
|
|
arguments='{"arg1": 12345}', # should be a MockOption string, not int
|
|
call_id="test-call-2",
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is True
|
|
assert "An internal error occurred" not in result.fnc_call_out.output
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_valid_args_still_work(self):
|
|
"""Verify that valid arguments still execute successfully."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
tool_ctx = ToolContext([mock_tool_1])
|
|
tool_call = FunctionToolCall(
|
|
name="mock_tool_1",
|
|
arguments='{"arg1": "hello"}',
|
|
call_id="test-call-3",
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is False
|
|
assert "arg1" in result.fnc_call_out.output
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_invalid_json_surfaces_error(self):
|
|
"""When the LLM provides invalid JSON, the error should be surfaced."""
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
|
|
tool_ctx = ToolContext([mock_tool_1])
|
|
tool_call = FunctionToolCall(
|
|
name="mock_tool_1",
|
|
arguments="{not valid json}",
|
|
call_id="test-call-4",
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx)
|
|
|
|
assert result.fnc_call_out is not None
|
|
assert result.fnc_call_out.is_error is True
|
|
# Should contain error details, not generic message
|
|
assert "An internal error occurred" not in result.fnc_call_out.output
|
|
|
|
|
|
class TestAsyncToolsetDedup:
|
|
def test_same_id_no_conflict(self):
|
|
"""Two AsyncToolsets with the same id and different tools should not raise."""
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
|
|
ts1 = AsyncToolset(id="same_id", tools=[mock_tool_1])
|
|
ts2 = AsyncToolset(id="same_id", tools=[mock_tool_2])
|
|
|
|
ctx = ToolContext([ts1, ts2])
|
|
|
|
names = [t.id for t in ctx.flatten() if hasattr(t, "id")]
|
|
assert "mock_tool_1" in names
|
|
assert "mock_tool_2" in names
|
|
|
|
|
|
class TestConfirmDuplicateSchema:
|
|
"""Schema injection for @function_tool(on_duplicate='confirm')."""
|
|
|
|
@staticmethod
|
|
@function_tool(on_duplicate="confirm")
|
|
async def _confirm_tool(origin: str, destination: str) -> dict[str, str]:
|
|
"""Book a flight.
|
|
|
|
Args:
|
|
origin: where to fly from
|
|
destination: where to fly to
|
|
"""
|
|
return {"origin": origin, "destination": destination}
|
|
|
|
@staticmethod
|
|
@function_tool(
|
|
raw_schema={
|
|
"name": "raw_confirm_tool",
|
|
"description": "raw tool",
|
|
"parameters": {
|
|
"type": "object",
|
|
"properties": {"gate_id": {"type": "string"}},
|
|
"required": ["gate_id"],
|
|
},
|
|
},
|
|
on_duplicate="confirm",
|
|
)
|
|
async def _raw_confirm_tool(raw_arguments: dict[str, Any]) -> dict[str, Any]:
|
|
return raw_arguments
|
|
|
|
def test_legacy_schema_has_confirm_param(self):
|
|
params = build_legacy_openai_schema(self._confirm_tool)["function"]["parameters"]
|
|
assert "lk_agents_confirm_duplicate" in params["properties"]
|
|
# legacy: not in required, has a default
|
|
assert "lk_agents_confirm_duplicate" not in params.get("required", [])
|
|
assert params["properties"]["lk_agents_confirm_duplicate"]["default"] is False
|
|
|
|
def test_strict_schema_has_confirm_param(self):
|
|
params = build_strict_openai_schema(self._confirm_tool)["function"]["parameters"]
|
|
assert "lk_agents_confirm_duplicate" in params["properties"]
|
|
# strict: in required, nullable type for optionality
|
|
assert "lk_agents_confirm_duplicate" in params["required"]
|
|
prop_type = params["properties"]["lk_agents_confirm_duplicate"]["type"]
|
|
assert "null" in prop_type and "boolean" in prop_type
|
|
|
|
def test_raw_schema_has_confirm_param(self):
|
|
params = self._raw_confirm_tool.info.raw_schema["parameters"]
|
|
assert "lk_agents_confirm_duplicate" in params["properties"]
|
|
assert "lk_agents_confirm_duplicate" in params["required"]
|
|
prop_type = params["properties"]["lk_agents_confirm_duplicate"]["type"]
|
|
assert "null" in prop_type and "boolean" in prop_type
|
|
|
|
def test_plain_tool_has_no_confirm_param(self):
|
|
params = build_strict_openai_schema(mock_tool_1)["function"]["parameters"]
|
|
assert "lk_agents_confirm_duplicate" not in params.get("properties", {})
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_direct_call_with_typed_args(self):
|
|
# Wrapper preserves direct invocation with the original signature.
|
|
result = await self._confirm_tool(origin="NYC", destination="Tokyo")
|
|
assert result == {"origin": "NYC", "destination": "Tokyo"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_wrapper_drops_confirm_kwarg(self):
|
|
# The wrapper pops lk_agents_confirm_duplicate before calling the user fn.
|
|
result = await self._confirm_tool(
|
|
origin="NYC", destination="Tokyo", lk_agents_confirm_duplicate=True
|
|
)
|
|
assert result == {"origin": "NYC", "destination": "Tokyo"}
|
|
|
|
|
|
class TestAsyncToolOptions:
|
|
"""``AsyncToolOptions`` resolution, override layering, and routing."""
|
|
|
|
def test_resolve_defaults_filled(self):
|
|
from livekit.agents.voice.tool_executor import (
|
|
_ASYNC_TOOL_OPTIONS_DEFAULTS,
|
|
_resolve_async_tool_options,
|
|
)
|
|
|
|
# None → all defaults
|
|
resolved = _resolve_async_tool_options(None)
|
|
assert resolved == _ASYNC_TOOL_OPTIONS_DEFAULTS
|
|
|
|
def test_resolve_partial_fills_missing_with_defaults(self):
|
|
from livekit.agents.voice.tool_executor import (
|
|
_ASYNC_TOOL_OPTIONS_DEFAULTS,
|
|
_resolve_async_tool_options,
|
|
)
|
|
|
|
resolved = _resolve_async_tool_options({"update_template": "custom-update"})
|
|
assert resolved["update_template"] == "custom-update"
|
|
# other keys retain the module default
|
|
assert (
|
|
resolved["duplicate_reject_template"]
|
|
== _ASYNC_TOOL_OPTIONS_DEFAULTS["duplicate_reject_template"]
|
|
)
|
|
assert (
|
|
resolved["reply_at_tail_template"]
|
|
== _ASYNC_TOOL_OPTIONS_DEFAULTS["reply_at_tail_template"]
|
|
)
|
|
|
|
def test_executor_uses_resolved_options(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor(async_tool_options={"duplicate_reject_template": "rejected!"})
|
|
assert executor._tool_options["duplicate_reject_template"] == "rejected!"
|
|
# unspecified key falls back to default, NOT to anything else
|
|
assert "{function_name}" in executor._tool_options["update_template"]
|
|
|
|
@staticmethod
|
|
def _mock_scope(session_update: str = "session", agent_update: str | None = None):
|
|
# minimal stand-ins for what AsyncToolset._attach_activity reads from
|
|
from livekit.agents.types import NOT_GIVEN
|
|
from livekit.agents.voice.tool_executor import _resolve_async_tool_options
|
|
|
|
class _Session:
|
|
_async_tool_options = _resolve_async_tool_options({"update_template": session_update})
|
|
|
|
class _Agent:
|
|
_async_tool_options = (
|
|
{"update_template": agent_update} if agent_update is not None else NOT_GIVEN
|
|
)
|
|
|
|
class _Activity:
|
|
_agent = _Agent()
|
|
|
|
return _Session(), _Activity()
|
|
|
|
def test_toolset_own_override_wins(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
|
|
ts = AsyncToolset(
|
|
id="t",
|
|
tools=[mock_tool_1],
|
|
tool_handling={"async_options": {"update_template": "toolset-own"}},
|
|
)
|
|
session, activity = self._mock_scope(agent_update="agent")
|
|
ts._attach_activity(activity=activity, session=session)
|
|
assert ts._executor._tool_options["update_template"] == "toolset-own"
|
|
|
|
def test_toolset_inherits_agent_when_no_override(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
|
|
ts = AsyncToolset(id="t", tools=[mock_tool_1])
|
|
session, activity = self._mock_scope(agent_update="agent")
|
|
ts._attach_activity(activity=activity, session=session)
|
|
# whole-value override: only `update_template` was given on agent, the rest fall
|
|
# back to module defaults (NOT to session options)
|
|
assert ts._executor._tool_options["update_template"] == "agent"
|
|
|
|
def test_toolset_inherits_session_when_no_agent_no_override(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
|
|
ts = AsyncToolset(id="t", tools=[mock_tool_1])
|
|
session, activity = self._mock_scope()
|
|
ts._attach_activity(activity=activity, session=session)
|
|
assert ts._executor._tool_options["update_template"] == "session"
|
|
|
|
def test_session_scoped_toolset_skips_agent(self):
|
|
# _attach_activity(activity=None) marks the toolset as session-scoped;
|
|
# agent options are ignored even if present.
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
|
|
ts = AsyncToolset(id="t", tools=[mock_tool_1])
|
|
session, _activity = self._mock_scope(agent_update="agent-should-be-ignored")
|
|
ts._attach_activity(activity=None, session=session)
|
|
assert ts._executor._tool_options["update_template"] == "session"
|
|
assert ts._executor._owning_activity is None
|
|
|
|
def test_build_executor_map_routes_toolset_tools(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
from livekit.agents.voice.tool_executor import _build_executor_map, _ToolExecutor
|
|
|
|
ts = AsyncToolset(id="async-1", tools=[mock_tool_1])
|
|
default = _ToolExecutor()
|
|
mapping = _build_executor_map(toolsets=[ts], default=default)
|
|
|
|
# tool inside AsyncToolset routes to that toolset's executor
|
|
assert mapping["mock_tool_1"] is ts._executor
|
|
# tools not in the map fall back to default at the call site
|
|
assert mapping.get("not_in_map") is None
|
|
|
|
def test_build_executor_map_nested_async_toolset_wins(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
from livekit.agents.llm.tool_context import Toolset
|
|
from livekit.agents.voice.tool_executor import _build_executor_map, _ToolExecutor
|
|
|
|
inner = AsyncToolset(id="inner", tools=[mock_tool_2])
|
|
outer_async = AsyncToolset(id="outer", tools=[mock_tool_1, inner])
|
|
|
|
# routing keeps inner's executor for inner's tools, outer's for outer's
|
|
mapping = _build_executor_map(toolsets=[outer_async], default=_ToolExecutor())
|
|
assert mapping["mock_tool_1"] is outer_async._executor
|
|
assert mapping["mock_tool_2"] is inner._executor
|
|
|
|
# plain Toolset doesn't change executor; its tools route to the surrounding default
|
|
plain = Toolset(id="plain", tools=[mock_tool_3])
|
|
default = _ToolExecutor()
|
|
mapping2 = _build_executor_map(toolsets=[plain], default=default)
|
|
assert mapping2["mock_tool_3"] is default
|
|
|
|
def test_session_stores_resolved_options(self):
|
|
# session-level options are resolved and stored at __init__; the actual
|
|
# wiring onto toolset executors happens later at activity start (so
|
|
# toolsets added after session.__init__ are picked up).
|
|
from livekit.agents.voice.agent_session import AgentSession
|
|
|
|
session = AgentSession(tool_handling={"async_options": {"update_template": "from-session"}})
|
|
assert session._async_tool_options["update_template"] == "from-session"
|
|
# other keys fall back to defaults, not to anything else
|
|
assert "{function_name}" in session._async_tool_options["duplicate_reject_template"]
|
|
|
|
def test_agent_stores_raw_options(self):
|
|
from livekit.agents.utils.misc import is_given
|
|
from livekit.agents.voice.agent import Agent
|
|
|
|
agent = Agent(
|
|
instructions="x",
|
|
tool_handling={"async_options": {"update_template": "from-agent"}},
|
|
)
|
|
assert is_given(agent._async_tool_options)
|
|
assert agent._async_tool_options["update_template"] == "from-agent"
|
|
|
|
|
|
# --- helpers for executor / RunContext tests ----------------------------------
|
|
|
|
|
|
def _make_run_context(
|
|
call_id: str = "call_1",
|
|
name: str = "test_tool",
|
|
arguments: str = "{}",
|
|
extra: dict[str, Any] | None = None,
|
|
allow_interruptions: bool = True,
|
|
):
|
|
"""Build a minimal RunContext — only what the executor and update() actually read."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from livekit.agents.llm import FunctionCall
|
|
from livekit.agents.voice.events import RunContext
|
|
|
|
speech_handle = MagicMock()
|
|
speech_handle.num_steps = 1
|
|
speech_handle.allow_interruptions = allow_interruptions
|
|
|
|
fnc_call = FunctionCall(
|
|
call_id=call_id,
|
|
name=name,
|
|
arguments=arguments,
|
|
extra=extra if extra is not None else {},
|
|
)
|
|
return RunContext(
|
|
session=MagicMock(),
|
|
speech_handle=speech_handle,
|
|
function_call=fnc_call,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=False)
|
|
def _clear_running_tasks():
|
|
"""Wipe the module-level registry between tests to avoid cross-test bleed."""
|
|
from livekit.agents.voice.tool_executor import _RunningTasks
|
|
|
|
_RunningTasks.clear()
|
|
yield
|
|
_RunningTasks.clear()
|
|
|
|
|
|
class TestRunContextUpdate:
|
|
"""RunContext.update() — call_id suffix, extra-dict isolation, ordering."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_first_update_keeps_original_call_id(self):
|
|
"""First update reuses the original call_id (so realtime/response models can
|
|
match it server-side); later updates get an ``_update_N`` suffix."""
|
|
ctx = _make_run_context(call_id="orig")
|
|
await ctx.update("msg1")
|
|
await ctx.update("msg2")
|
|
|
|
assert ctx._updates[0][0].call_id == "orig"
|
|
assert ctx._updates[1][0].call_id == "orig_update_1"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_synthesized_pair_extra_is_copy(self):
|
|
"""Each synthesized pair gets its own ``extra`` dict so later mutation doesn't leak back."""
|
|
ctx = _make_run_context(call_id="orig", extra={"k": "v"})
|
|
await ctx.update("msg1")
|
|
|
|
ctx.function_call.extra["leaked"] = True
|
|
|
|
assert "leaked" not in ctx._updates[0][0].extra
|
|
assert ctx._updates[0][0].extra == {"k": "v"}
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_updates_recorded_without_executor(self):
|
|
"""With no executor attached, update() just appends to ``_updates`` and returns."""
|
|
ctx = _make_run_context()
|
|
await ctx.update("first")
|
|
await ctx.update("second")
|
|
assert len(ctx._updates) == 2
|
|
assert "first" in ctx._updates[0][1].output
|
|
assert "second" in ctx._updates[1][1].output
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_accepts_callable_template(self):
|
|
"""``template=`` may be a callable receiving the placeholder dict."""
|
|
ctx = _make_run_context(call_id="orig", name="fn")
|
|
await ctx.update(
|
|
"hello", template=lambda c: f"[{c['function_name']}/{c['call_id']}] {c['message']}"
|
|
)
|
|
assert ctx._updates[0][1].output == "[fn/orig] hello"
|
|
|
|
|
|
class TestAsyncToolOptionsRendering:
|
|
"""``_render`` dispatches between ``str.format`` and callable templates."""
|
|
|
|
def test_render_string(self):
|
|
from livekit.agents.voice.tool_executor import _render
|
|
|
|
assert _render("hi {name}", {"name": "world"}) == "hi world"
|
|
|
|
def test_render_callable(self):
|
|
from livekit.agents.voice.tool_executor import _render
|
|
|
|
assert _render(lambda c: f"hi {c['name']}", {"name": "world"}) == "hi world"
|
|
|
|
|
|
class TestExecuteFunctionCallWithUpdate:
|
|
"""execute_function_call wires ctx.update() into FunctionCallResult.fnc_call_updates."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fnc_call_updates_populated(self):
|
|
from livekit.agents.llm.llm import FunctionToolCall
|
|
from livekit.agents.llm.utils import execute_function_call
|
|
from livekit.agents.voice.events import RunContext
|
|
|
|
@function_tool
|
|
async def progress_tool(ctx: RunContext, query: str) -> str:
|
|
"""Look up something.
|
|
|
|
Args:
|
|
query: the query string
|
|
"""
|
|
await ctx.update("searching...")
|
|
return f"result for {query}"
|
|
|
|
tool_ctx = ToolContext([progress_tool])
|
|
run_ctx = _make_run_context(call_id="probe", name="progress_tool")
|
|
tool_call = FunctionToolCall(
|
|
name="progress_tool", arguments='{"query": "x"}', call_id="probe"
|
|
)
|
|
|
|
result = await execute_function_call(tool_call, tool_ctx, call_ctx=run_ctx)
|
|
|
|
assert result.fnc_call_out is not None
|
|
assert "result for x" in result.fnc_call_out.output
|
|
assert len(result.fnc_call_updates) == 1
|
|
assert "searching" in result.fnc_call_updates[0][1].output
|
|
|
|
|
|
class TestHasCancellableTool:
|
|
"""`has_cancellable_tool` decides whether AgentActivity auto-exposes
|
|
cancel_task / get_running_tasks."""
|
|
|
|
def test_plain_tools_not_cancellable(self):
|
|
from livekit.agents.voice.tool_executor import has_cancellable_tool
|
|
|
|
assert has_cancellable_tool([mock_tool_1, mock_tool_2]) is False
|
|
|
|
def test_one_cancellable_tool_returns_true(self):
|
|
from livekit.agents.voice.tool_executor import has_cancellable_tool
|
|
|
|
@function_tool(flags=ToolFlag.CANCELLABLE)
|
|
async def long_running() -> str:
|
|
return "done"
|
|
|
|
assert has_cancellable_tool([mock_tool_1, long_running]) is True
|
|
|
|
def test_recurses_into_toolsets(self):
|
|
from livekit.agents.llm.async_toolset import AsyncToolset
|
|
from livekit.agents.voice.tool_executor import has_cancellable_tool
|
|
|
|
@function_tool(flags=ToolFlag.CANCELLABLE)
|
|
async def long_running() -> str:
|
|
return "done"
|
|
|
|
ts = AsyncToolset(id="t", tools=[long_running])
|
|
assert has_cancellable_tool([mock_tool_1, ts]) is True
|
|
|
|
|
|
def _register_fake(
|
|
executor, call_id: str, name: str, *, allow_cancellation: bool, allow_interruptions: bool = True
|
|
):
|
|
"""Stub a _RunningTask into the executor so policy methods can be tested
|
|
without choreographing real execute() lifetimes. Caller must clean up the
|
|
returned task via _cleanup_fakes."""
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.tool_executor import _RunningTask
|
|
|
|
async def _runner():
|
|
try:
|
|
await _asyncio.Event().wait()
|
|
except _asyncio.CancelledError:
|
|
return
|
|
|
|
exe_task = _asyncio.create_task(_runner(), name=f"fake_{call_id}")
|
|
run_ctx = _make_run_context(call_id=call_id, name=name, allow_interruptions=allow_interruptions)
|
|
executor._running_tasks[call_id] = _RunningTask(
|
|
ctx=run_ctx,
|
|
exe_task=exe_task,
|
|
executor=executor,
|
|
allow_cancellation=allow_cancellation,
|
|
)
|
|
return exe_task
|
|
|
|
|
|
async def _cleanup_fakes(*exe_tasks):
|
|
import asyncio as _asyncio
|
|
|
|
for t in exe_tasks:
|
|
if not t.done():
|
|
t.cancel()
|
|
await _asyncio.gather(*exe_tasks, return_exceptions=True)
|
|
|
|
|
|
class TestCheckDuplicate:
|
|
"""_check_duplicate policy decisions, exercised with a pre-populated registry."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_allow_returns_none(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=True)
|
|
try:
|
|
assert (
|
|
await executor._check_duplicate(
|
|
"tool_x", on_duplicate="allow", confirm_duplicate=None
|
|
)
|
|
is None
|
|
)
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reject_returns_message(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=True)
|
|
try:
|
|
result = await executor._check_duplicate(
|
|
"tool_x", on_duplicate="reject", confirm_duplicate=None
|
|
)
|
|
assert isinstance(result, str) and "already running" in result
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_confirm_blocks_without_param(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=True)
|
|
try:
|
|
result = await executor._check_duplicate(
|
|
"tool_x", on_duplicate="confirm", confirm_duplicate=False
|
|
)
|
|
assert isinstance(result, str) and "confirm duplicate" in result.lower()
|
|
# with confirm=True, the policy lets the new call through
|
|
assert (
|
|
await executor._check_duplicate(
|
|
"tool_x", on_duplicate="confirm", confirm_duplicate=True
|
|
)
|
|
is None
|
|
)
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_cancels_when_cancellable(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=True)
|
|
try:
|
|
assert (
|
|
await executor._check_duplicate(
|
|
"tool_x", on_duplicate="replace", confirm_duplicate=None
|
|
)
|
|
is None
|
|
)
|
|
assert t.cancelled() or t.done()
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_replace_raises_when_non_cancellable(self):
|
|
"""replace must honor allow_cancellation, not bypass it."""
|
|
from livekit.agents.llm.tool_context import ToolError
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=False)
|
|
try:
|
|
with pytest.raises(ToolError, match="not cancellable"):
|
|
await executor._check_duplicate(
|
|
"tool_x", on_duplicate="replace", confirm_duplicate=None
|
|
)
|
|
assert not t.cancelled() # the running tool was left alone
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_duplicate_check_lock_serializes(self):
|
|
"""Holding the executor's lock blocks any concurrent _check_duplicate."""
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=True)
|
|
try:
|
|
await executor._duplicate_check_lock.acquire()
|
|
pending = _asyncio.create_task(
|
|
executor._check_duplicate("tool_x", on_duplicate="reject", confirm_duplicate=None)
|
|
)
|
|
await _asyncio.sleep(0)
|
|
assert not pending.done() # blocked on the lock
|
|
executor._duplicate_check_lock.release()
|
|
result = await pending
|
|
assert isinstance(result, str) and "already running" in result
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
|
|
class TestCancelAll:
|
|
"""cancel_all / drain semantics, exercised with a pre-populated registry."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellable_only_cancels_flagged(self):
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
cancellable = _register_fake(executor, "c", "tool_c", allow_cancellation=True)
|
|
non_cancellable = _register_fake(executor, "nc", "tool_nc", allow_cancellation=False)
|
|
try:
|
|
# drain cancels the cancellable but awaits the non-cancellable
|
|
import asyncio as _asyncio
|
|
|
|
drain_task = _asyncio.create_task(executor.drain())
|
|
await cancellable
|
|
assert cancellable.done()
|
|
assert not drain_task.done()
|
|
assert not non_cancellable.done()
|
|
finally:
|
|
non_cancellable.cancel()
|
|
await _cleanup_fakes(cancellable, non_cancellable)
|
|
await drain_task
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_raises_when_interruptions_disallowed(self):
|
|
"""cancel() refuses when the speech handle has ``allow_interruptions=False`` —
|
|
the same predicate gates drain and LLM-driven cancel."""
|
|
from livekit.agents.llm.tool_context import ToolError
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(
|
|
executor, "a", "tool_x", allow_cancellation=True, allow_interruptions=False
|
|
)
|
|
try:
|
|
with pytest.raises(ToolError, match="interruptions are disallowed"):
|
|
await executor.cancel("a")
|
|
finally:
|
|
await _cleanup_fakes(t)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_task_companion_misses_non_cancellable(self):
|
|
"""The LLM-facing ``cancel_task`` raises ToolError for tools registered with
|
|
``allow_cancellation=False``."""
|
|
from livekit.agents.llm.tool_context import ToolError
|
|
from livekit.agents.voice.tool_executor import _RunningTasks, _ToolExecutor, cancel_task
|
|
|
|
executor = _ToolExecutor()
|
|
t = _register_fake(executor, "a", "tool_x", allow_cancellation=False)
|
|
# cancel_task reads from the module-level registry, keyed by session
|
|
run_ctx = executor._running_tasks["a"].ctx
|
|
_RunningTasks[run_ctx.session] = executor._running_tasks
|
|
try:
|
|
with pytest.raises(ToolError, match="not cancellable"):
|
|
await cancel_task(run_ctx, "a")
|
|
assert not t.cancelled()
|
|
finally:
|
|
_RunningTasks.pop(run_ctx.session, None)
|
|
await _cleanup_fakes(t)
|
|
|
|
|
|
class TestToolExecutorLifecycle:
|
|
pytestmark = pytest.mark.usefixtures("_clear_running_tasks")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_context_back_refs_cleared_on_finish(self):
|
|
"""RunContext drops its executor refs once the tool finishes — a stashed
|
|
ctx can't drive the executor post-completion."""
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def quick_tool() -> str:
|
|
"""q"""
|
|
return "ok"
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="a", name="quick_tool")
|
|
result = await executor.execute(tool=quick_tool, run_ctx=run_ctx, raw_arguments={})
|
|
assert result == "ok"
|
|
|
|
await _asyncio.sleep(0) # let _on_done fire
|
|
assert run_ctx._executor is None
|
|
assert run_ctx._first_update_fut is None
|
|
|
|
|
|
class TestAgentSessionWaitForIdle:
|
|
def test_raises_runtime_error_when_no_activity(self):
|
|
"""wait_for_idle raises instead of spinning when no activity has started."""
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.agent_session import AgentSession
|
|
|
|
session = AgentSession()
|
|
with pytest.raises(RuntimeError, match="no active AgentActivity"):
|
|
_asyncio.get_event_loop().run_until_complete(session.wait_for_idle())
|
|
|
|
|
|
# --- tool status events ---------------------------------------------------------
|
|
|
|
|
|
def _emitted_items(session: Any) -> list[Any]:
|
|
"""Updates carried by tool_execution_updated events emitted on a mocked session."""
|
|
from livekit.agents.voice.events import ToolExecutionUpdatedEvent
|
|
|
|
items = []
|
|
for call in session.emit.call_args_list:
|
|
name, ev = call.args
|
|
if name == "tool_execution_updated":
|
|
assert isinstance(ev, ToolExecutionUpdatedEvent)
|
|
items.append(ev.update)
|
|
return items
|
|
|
|
|
|
async def _drain_executor(executor: Any) -> None:
|
|
"""Yield until in-flight tool tasks settle, so their terminal events are emitted."""
|
|
import asyncio as _asyncio
|
|
|
|
while executor.has_running_tasks:
|
|
await _asyncio.sleep(0)
|
|
|
|
|
|
def _make_fake_speech():
|
|
"""A SpeechHandle stand-in that lets tests fire the done callbacks manually."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from livekit.agents.voice import SpeechHandle
|
|
|
|
speech = MagicMock(spec=SpeechHandle)
|
|
speech.id = "speech_1"
|
|
speech.interrupted = False
|
|
speech.chat_items = ["said something"]
|
|
callbacks: list[Any] = []
|
|
speech.add_done_callback.side_effect = callbacks.append
|
|
speech.fire_done = lambda: [cb(speech) for cb in list(callbacks)]
|
|
return speech
|
|
|
|
|
|
def _make_reply_session(speech: Any) -> Any:
|
|
"""A session mock with just enough surface for _enqueue_reply/_deliver_reply."""
|
|
from unittest.mock import AsyncMock, MagicMock
|
|
|
|
from livekit.agents.llm import ChatContext
|
|
|
|
session = MagicMock()
|
|
agent = MagicMock()
|
|
agent.chat_ctx = ChatContext.empty()
|
|
agent.update_chat_ctx = AsyncMock()
|
|
session.current_agent = agent
|
|
session._global_run_state = None
|
|
activity = MagicMock()
|
|
activity.agent = agent
|
|
session.wait_for_idle = AsyncMock(return_value=activity)
|
|
session.generate_reply = MagicMock(return_value=speech)
|
|
return session
|
|
|
|
|
|
def _make_run_context_with_session(session: Any, call_id: str, name: str):
|
|
from unittest.mock import MagicMock
|
|
|
|
from livekit.agents.llm import FunctionCall
|
|
from livekit.agents.voice.events import RunContext
|
|
|
|
speech_handle = MagicMock()
|
|
speech_handle.num_steps = 1
|
|
speech_handle.allow_interruptions = True
|
|
return RunContext(
|
|
session=session,
|
|
speech_handle=speech_handle,
|
|
function_call=FunctionCall(call_id=call_id, name=name, arguments="{}"),
|
|
)
|
|
|
|
|
|
class TestToolCallEvents:
|
|
"""tool_execution_updated emission across the executor lifecycle."""
|
|
|
|
pytestmark = pytest.mark.usefixtures("_clear_running_tasks")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_tool_started_then_done(self):
|
|
from livekit.agents.voice.events import ToolCallEnded, ToolCallStarted
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def quick_tool() -> str:
|
|
"""q"""
|
|
return "ok"
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="c1", name="quick_tool")
|
|
result = await executor.execute(tool=quick_tool, run_ctx=run_ctx, raw_arguments={})
|
|
assert result == "ok"
|
|
await _drain_executor(executor)
|
|
|
|
items = _emitted_items(run_ctx.session)
|
|
assert isinstance(items[0], ToolCallStarted)
|
|
assert items[0].function_call.call_id == "c1"
|
|
assert items[1] == ToolCallEnded(id="c1", call_id="c1", message="ok", status="done")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_before_update_uses_plain_call_id(self):
|
|
from livekit.agents.voice.events import ToolCallEnded, ToolCallStarted
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def boom() -> str:
|
|
"""b"""
|
|
raise RuntimeError("nope")
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="c2", name="boom")
|
|
with pytest.raises(RuntimeError, match="nope"):
|
|
await executor.execute(tool=boom, run_ctx=run_ctx, raw_arguments={})
|
|
await _drain_executor(executor)
|
|
|
|
items = _emitted_items(run_ctx.session)
|
|
assert isinstance(items[0], ToolCallStarted)
|
|
assert items[1] == ToolCallEnded(id="c2", call_id="c2", message="nope", status="error")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancelled_tool(self):
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.events import ToolCallEnded
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
running = _asyncio.Event()
|
|
|
|
@function_tool(flags=ToolFlag.CANCELLABLE)
|
|
async def long_tool() -> str:
|
|
"""l"""
|
|
running.set()
|
|
await _asyncio.sleep(3600)
|
|
return "never"
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="c3", name="long_tool")
|
|
exec_task = _asyncio.create_task(
|
|
executor.execute(tool=long_tool, run_ctx=run_ctx, raw_arguments={})
|
|
)
|
|
await running.wait()
|
|
assert await executor.cancel("c3")
|
|
assert await exec_task is None
|
|
|
|
items = _emitted_items(run_ctx.session)
|
|
assert items[-1] == ToolCallEnded(id="c3", call_id="c3", message=None, status="cancelled")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancel_before_first_step_releases_dispatch(self):
|
|
"""A cancel landing before the exe task ever runs must still resolve
|
|
execute() and report the cancellation (the handler never gets to run)."""
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.events import ToolCallEnded
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool(flags=ToolFlag.CANCELLABLE)
|
|
async def long_tool() -> str:
|
|
"""l"""
|
|
await _asyncio.sleep(3600)
|
|
return "never"
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="c3b", name="long_tool")
|
|
exec_task = _asyncio.create_task(
|
|
executor.execute(tool=long_tool, run_ctx=run_ctx, raw_arguments={})
|
|
)
|
|
# registration happens synchronously inside execute(); the exe task has not
|
|
# run a single step yet when cancel() fires
|
|
while "c3b" not in executor._running_tasks:
|
|
await _asyncio.sleep(0)
|
|
assert await executor.cancel("c3b")
|
|
assert await _asyncio.wait_for(exec_task, timeout=5) is None
|
|
|
|
items = _emitted_items(run_ctx.session)
|
|
assert items[-1] == ToolCallEnded(id="c3b", call_id="c3b", message=None, status="cancelled")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_internal_tools_tracked(self):
|
|
from livekit.agents.voice.events import ToolCallEnded, ToolCallStarted
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool(name="lk_agents_test_tool")
|
|
async def internal_tool() -> str:
|
|
"""i"""
|
|
return "ok"
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context(call_id="c4", name="lk_agents_test_tool")
|
|
await executor.execute(tool=internal_tool, run_ctx=run_ctx, raw_arguments={})
|
|
await _drain_executor(executor)
|
|
|
|
items = _emitted_items(run_ctx.session)
|
|
assert isinstance(items[0], ToolCallStarted)
|
|
assert items[1] == ToolCallEnded(id="c4", call_id="c4", message="ok", status="done")
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_updates_and_deferred_result_with_reply_lifecycle(self):
|
|
from livekit.agents.voice.events import (
|
|
RunContext,
|
|
ToolCallEnded,
|
|
ToolCallStarted,
|
|
ToolCallUpdated,
|
|
ToolReplyUpdated,
|
|
)
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def progress_tool(ctx: RunContext) -> str:
|
|
"""p"""
|
|
await ctx.update("step one")
|
|
await ctx.update("step two")
|
|
return "all done"
|
|
|
|
import asyncio as _asyncio
|
|
|
|
speech = _make_fake_speech()
|
|
session = _make_reply_session(speech)
|
|
# hold the session busy until the tool has buffered everything, so the
|
|
# deferred reply coalesces the second update and the final result
|
|
idle_event = _asyncio.Event()
|
|
activity = session.wait_for_idle.return_value
|
|
|
|
async def _wait_for_idle():
|
|
await idle_event.wait()
|
|
return activity
|
|
|
|
session.wait_for_idle = _wait_for_idle
|
|
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context_with_session(session, call_id="c5", name="progress_tool")
|
|
|
|
first = await executor.execute(tool=progress_tool, run_ctx=run_ctx, raw_arguments={})
|
|
assert "step one" in first
|
|
while executor.has_running_tasks:
|
|
await _asyncio.sleep(0)
|
|
idle_event.set()
|
|
assert executor._reply_task is not None
|
|
await executor._reply_task
|
|
|
|
items = _emitted_items(session)
|
|
assert isinstance(items[0], ToolCallStarted)
|
|
# first update is inline (plain call_id), the second is buffered
|
|
assert items[1] == ToolCallUpdated(id="c5", call_id="c5", message="step one")
|
|
assert items[2] == ToolCallUpdated(id="c5_update_1", call_id="c5", message="step two")
|
|
# the final return is deferred through the coalescer
|
|
assert items[3] == ToolCallEnded(
|
|
id="c5_final", call_id="c5", message="all done", status="done"
|
|
)
|
|
# the deferred reply covering the buffered ids was scheduled
|
|
reply = items[4]
|
|
assert isinstance(reply, ToolReplyUpdated)
|
|
assert reply.status == "scheduled"
|
|
assert reply.update_ids == ["c5_update_1", "c5_final"]
|
|
|
|
speech.fire_done()
|
|
completed = _emitted_items(session)[-1]
|
|
assert isinstance(completed, ToolReplyUpdated)
|
|
assert completed.status == "completed"
|
|
assert completed.update_ids == ["c5_update_1", "c5_final"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_interrupted_and_skipped_reply_outcomes(self):
|
|
from livekit.agents.voice.events import RunContext, ToolReplyUpdated
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def progress_tool(ctx: RunContext) -> str:
|
|
"""p"""
|
|
await ctx.update("working")
|
|
return "done"
|
|
|
|
for interrupted, chat_items, expected in [
|
|
(True, ["partial"], "interrupted"),
|
|
(False, [], "skipped"),
|
|
]:
|
|
speech = _make_fake_speech()
|
|
speech.interrupted = interrupted
|
|
speech.chat_items = chat_items
|
|
session = _make_reply_session(speech)
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context_with_session(session, call_id="c6", name="progress_tool")
|
|
|
|
await executor.execute(tool=progress_tool, run_ctx=run_ctx, raw_arguments={})
|
|
while executor._reply_task is None:
|
|
import asyncio as _asyncio
|
|
|
|
await _asyncio.sleep(0)
|
|
await executor._reply_task
|
|
|
|
speech.fire_done()
|
|
last = _emitted_items(session)[-1]
|
|
assert isinstance(last, ToolReplyUpdated)
|
|
assert last.status == expected
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_error_after_update_is_deferred_with_final_id(self):
|
|
from livekit.agents.voice.events import RunContext, ToolCallEnded
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def update_then_boom(ctx: RunContext) -> str:
|
|
"""u"""
|
|
await ctx.update("starting")
|
|
raise RuntimeError("late failure")
|
|
|
|
speech = _make_fake_speech()
|
|
session = _make_reply_session(speech)
|
|
executor = _ToolExecutor()
|
|
run_ctx = _make_run_context_with_session(session, call_id="c7", name="update_then_boom")
|
|
|
|
await executor.execute(tool=update_then_boom, run_ctx=run_ctx, raw_arguments={})
|
|
while executor._reply_task is None:
|
|
import asyncio as _asyncio
|
|
|
|
await _asyncio.sleep(0)
|
|
await executor._reply_task
|
|
|
|
items = _emitted_items(session)
|
|
terminal = next(i for i in items if isinstance(i, ToolCallEnded))
|
|
assert terminal == ToolCallEnded(
|
|
id="c7_final", call_id="c7", message="late failure", status="error"
|
|
)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_reply_spans_multiple_executions(self):
|
|
import asyncio as _asyncio
|
|
|
|
from livekit.agents.voice.events import RunContext, ToolReplyUpdated
|
|
from livekit.agents.voice.tool_executor import _ToolExecutor
|
|
|
|
@function_tool
|
|
async def progress_tool(ctx: RunContext) -> str:
|
|
"""p"""
|
|
await ctx.update("working")
|
|
return "done"
|
|
|
|
speech = _make_fake_speech()
|
|
session = _make_reply_session(speech)
|
|
# hold the session busy until both executions have buffered their results
|
|
idle_event = _asyncio.Event()
|
|
activity = session.wait_for_idle.return_value
|
|
|
|
async def _wait_for_idle():
|
|
await idle_event.wait()
|
|
return activity
|
|
|
|
session.wait_for_idle = _wait_for_idle
|
|
|
|
executor = _ToolExecutor()
|
|
ctx_a = _make_run_context_with_session(session, call_id="a", name="progress_tool")
|
|
ctx_b = _make_run_context_with_session(session, call_id="b", name="progress_tool")
|
|
|
|
await executor.execute(tool=progress_tool, run_ctx=ctx_a, raw_arguments={})
|
|
await executor.execute(tool=progress_tool, run_ctx=ctx_b, raw_arguments={})
|
|
while executor.has_running_tasks:
|
|
await _asyncio.sleep(0)
|
|
idle_event.set()
|
|
assert executor._reply_task is not None
|
|
await executor._reply_task
|
|
|
|
scheduled = [i for i in _emitted_items(session) if isinstance(i, ToolReplyUpdated)]
|
|
assert len(scheduled) == 1
|
|
assert scheduled[0].status == "scheduled"
|
|
assert set(scheduled[0].update_ids) == {"a_final", "b_final"}
|