import json from unittest.mock import MagicMock import pytest from livekit.agents.beta.toolsets.tool_proxy import ToolProxyToolset from livekit.agents.llm import ToolContext, ToolError, Toolset, function_tool pytestmark = [pytest.mark.unit, pytest.mark.concurrent] @function_tool async def weather_tool(city: str) -> str: """Get weather for a city. Args: city: City name or region """ return f"sunny in {city}" @function_tool async def forecast_tool(city: str, days: int) -> str: """Get weather forecast. Args: city: City name or region days: Number of days to forecast """ return f"{days}-day forecast for {city}: mostly sunny" @function_tool async def stock_tool(symbol: str) -> str: """Get stock price for a symbol. Args: symbol: Stock ticker symbol """ return f"{symbol}: $100" @function_tool( raw_schema={ "name": "raw_search", "description": "Search with raw schema", "parameters": { "type": "object", "properties": { "query": {"type": "string", "description": "Search query"}, "limit": {"type": "integer", "description": "Max results"}, }, }, } ) async def raw_search(raw_arguments: dict[str, object]) -> str: return f"results for {raw_arguments.get('query')}" class WeatherToolset(Toolset): def __init__(self): super().__init__(id="weather", tools=[weather_tool, forecast_tool]) class FinanceToolset(Toolset): def __init__(self): super().__init__(id="finance", tools=[stock_tool]) def _mock_ctx() -> MagicMock: return MagicMock() class TestToolProxyToolset: def test_tools_always_two(self): """ToolProxyToolset always exposes exactly search_tools and call_tool.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset(), FinanceToolset()], ) assert len(ts.tools) == 2 tool_names = [t.id for t in ts.tools] assert "tool_search" in tool_names assert "call_tool" in tool_names async def test_tools_constant_after_search(self): """Tool list stays constant even after search — no dynamic loading.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset(), FinanceToolset()], ) await ts.setup() assert len(ts.tools) == 2 await ts._handle_search({"query": "weather"}) assert len(ts.tools) == 2 # still just search + call async def test_search_returns_full_schemas(self): """search_tools returns tool schemas with full JSON schema for parameters.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset(), FinanceToolset()], ) await ts.setup() result = await ts._handle_search({"query": "weather"}) parsed = [json.loads(schema) for schema in result.split("\n")] assert isinstance(parsed, list) assert len(parsed) >= 1 names = [t["name"] for t in parsed] assert "weather_tool" in names # Check schema has full JSON schema with types weather = next(t for t in parsed if t["name"] == "weather_tool") assert "Get weather for a city" in weather["description"] assert "parameters" in weather params = weather["parameters"] assert "properties" in params assert "city" in params["properties"] assert params["properties"]["city"]["type"] == "string" async def test_search_returns_raw_tool_schema(self): """search_tools returns full raw_schema parameters for RawFunctionTool.""" ts = ToolProxyToolset( id="proxy", tools=[raw_search], ) await ts.setup() result = await ts._handle_search({"query": "search"}) parsed = [json.loads(schema) for schema in result.split("\n")] assert len(parsed) >= 1 tool = parsed[0] assert tool["name"] == "raw_search" params = tool["parameters"] assert params["type"] == "object" assert "query" in params["properties"] assert params["properties"]["query"]["type"] == "string" assert params["properties"]["limit"]["type"] == "integer" async def test_search_no_results_raises_tool_error(self): ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() with pytest.raises(ToolError, match="No tools found"): await ts._handle_search({"query": "xyznonexistent"}) async def test_search_empty_query_raises_tool_error(self): ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() with pytest.raises(ToolError, match="cannot be empty"): await ts._handle_search({"query": ""}) async def test_call_tool_function_tool(self): """call_tool can execute a FunctionTool by name.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset(), FinanceToolset()], ) await ts.setup() result = await ts._handle_call( _mock_ctx(), {"name": "weather_tool", "parameters": {"city": "London"}} ) assert "sunny in London" in str(result) async def test_call_tool_with_multiple_args(self): ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() result = await ts._handle_call( _mock_ctx(), {"name": "forecast_tool", "parameters": {"city": "Tokyo", "days": 3}} ) assert "3-day forecast for Tokyo" in str(result) async def test_call_tool_raw_function_tool(self): """call_tool can execute a RawFunctionTool by name.""" ts = ToolProxyToolset( id="proxy", tools=[raw_search], ) await ts.setup() result = await ts._handle_call( _mock_ctx(), {"name": "raw_search", "parameters": {"query": "test", "limit": 5}} ) assert "results for test" in str(result) async def test_call_unknown_tool_raises_tool_error(self): ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() with pytest.raises(ToolError, match="unknown tool"): await ts._handle_call(_mock_ctx(), {"name": "nonexistent_tool", "parameters": {}}) async def test_call_missing_required_arg_raises_tool_error(self, capsys): """Missing required argument produces a detailed ToolError for the LLM.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() with pytest.raises(ToolError, match="Error parsing arguments") as exc_info: await ts._handle_call(_mock_ctx(), {"name": "weather_tool", "parameters": {}}) error_msg = exc_info.value.message print(f"Missing arg error: {error_msg}") # Error message should contain the missing field name and indicate it's required assert "city" in error_msg assert "type=missing" in error_msg async def test_call_wrong_type_arg_raises_tool_error(self, capsys): """Wrong argument type produces a detailed ToolError for the LLM.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset()], ) await ts.setup() with pytest.raises(ToolError, match="Error parsing arguments") as exc_info: await ts._handle_call( _mock_ctx(), {"name": "forecast_tool", "parameters": {"city": "Tokyo", "days": "not_a_number"}}, ) error_msg = exc_info.value.message print(f"Wrong type error: {error_msg}") # Error message should contain the field name and indicate a type parsing issue assert "days" in error_msg assert "int_parsing" in error_msg async def test_call_tool_propagates_tool_error(self): """ToolError raised inside a tool is re-raised as-is.""" @function_tool async def tool_with_tool_error(x: str) -> str: """A tool that raises ToolError. Args: x: Some input """ raise ToolError("custom error message") ts = ToolProxyToolset( id="proxy", tools=[tool_with_tool_error], ) await ts.setup() with pytest.raises(ToolError, match="custom error message"): await ts._handle_call( _mock_ctx(), {"name": "tool_with_tool_error", "parameters": {"x": "test"}} ) async def test_tool_context_sees_only_proxy_tools(self): """ToolContext built from ToolProxyToolset only sees search + call.""" ts = ToolProxyToolset( id="proxy", tools=[WeatherToolset(), FinanceToolset()], ) await ts.setup() ctx = ToolContext([ts]) assert "tool_search" in ctx.function_tools assert "call_tool" in ctx.function_tools assert len(ctx.function_tools) == 2 assert "weather_tool" not in ctx.function_tools async def test_nested_toolsets(self): """Nested toolsets are accessible via call_tool.""" class OuterToolset(Toolset): def __init__(self): super().__init__(id="outer", tools=[WeatherToolset(), stock_tool]) ts = ToolProxyToolset(id="proxy", tools=[OuterToolset()]) await ts.setup() # Search should find tools from nested toolsets result = await ts._handle_search({"query": "weather stock"}) parsed = [json.loads(schema) for schema in result.split("\n")] names = [t["name"] for t in parsed] assert "weather_tool" in names or "stock_tool" in names # call_tool should work for nested tools result = await ts._handle_call( _mock_ctx(), {"name": "weather_tool", "parameters": {"city": "Paris"}} ) assert "sunny in Paris" in str(result)