Files
2026-07-13 13:39:38 +08:00

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"}