From 5dc853a4bac75a73d0e4680e9362aa728672b615 Mon Sep 17 00:00:00 2001 From: Nikita Date: Fri, 14 Nov 2025 15:13:18 -0800 Subject: [PATCH 1/5] WIP: HTTP & SMS Tools --- examples/voice_agents/weather_agent.py | 328 +++++++++++--- .../livekit/agents/beta/tools/__init__.py | 17 + .../agents/beta/tools/http/__init__.py | 13 + .../agents/beta/tools/http/send_http.py | 306 +++++++++++++ .../livekit/agents/beta/tools/sms/README.md | 55 +++ .../livekit/agents/beta/tools/sms/__init__.py | 12 + .../livekit/agents/beta/tools/sms/send_sms.py | 416 ++++++++++++++++++ tests/test_beta_tools.py | 236 ++++++++++ 8 files changed, 1331 insertions(+), 52 deletions(-) create mode 100644 livekit-agents/livekit/agents/beta/tools/__init__.py create mode 100644 livekit-agents/livekit/agents/beta/tools/http/__init__.py create mode 100644 livekit-agents/livekit/agents/beta/tools/http/send_http.py create mode 100644 livekit-agents/livekit/agents/beta/tools/sms/README.md create mode 100644 livekit-agents/livekit/agents/beta/tools/sms/__init__.py create mode 100644 livekit-agents/livekit/agents/beta/tools/sms/send_sms.py create mode 100644 tests/test_beta_tools.py diff --git a/examples/voice_agents/weather_agent.py b/examples/voice_agents/weather_agent.py index c9c107d330..de00ccdc35 100644 --- a/examples/voice_agents/weather_agent.py +++ b/examples/voice_agents/weather_agent.py @@ -1,57 +1,285 @@ +""" +Weather Agent - Demonstrates HTTP tool usage with real APIs + +This agent can: +- Search for locations by name +- Get current weather conditions +- Provide 7-day weather forecasts + +Uses free Open-Meteo API (no API key required) +""" + import logging -import aiohttp from dotenv import load_dotenv -from livekit.agents import AgentServer, JobContext, cli -from livekit.agents.llm import function_tool -from livekit.agents.voice import Agent, AgentSession -from livekit.plugins import openai +from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli +from livekit.agents.beta.tools.http import HTTPToolConfig, create_http_tool +from livekit.agents.beta.tools.sms import SMSToolConfig, create_sms_tool +from livekit.plugins import silero -logger = logging.getLogger("weather-example") -logger.setLevel(logging.INFO) +logger = logging.getLogger("weather-agent") load_dotenv() +def _format_location_response(resp) -> str: + """Parse geocoding response and return formatted location info.""" + import json + + data = json.loads(resp.body) + + if not data.get("results"): + return "Sorry, I couldn't find that location." + + result = data["results"][0] + return json.dumps( + { + "name": result.get("name"), + "latitude": result.get("latitude"), + "longitude": result.get("longitude"), + "country": result.get("country"), + "timezone": result.get("timezone"), + } + ) + + +def _format_current_weather(resp) -> str: + """Parse current weather response into readable format.""" + import json + + data = json.loads(resp.body) + current = data.get("current", {}) + + # WMO Weather interpretation codes + weather_codes = { + 0: "Clear sky", + 1: "Mainly clear", + 2: "Partly cloudy", + 3: "Overcast", + 45: "Fog", + 48: "Depositing rime fog", + 51: "Light drizzle", + 53: "Moderate drizzle", + 55: "Dense drizzle", + 56: "Light freezing drizzle", + 57: "Dense freezing drizzle", + 61: "Slight rain", + 63: "Moderate rain", + 65: "Heavy rain", + 66: "Light freezing rain", + 67: "Heavy freezing rain", + 71: "Slight snow", + 73: "Moderate snow", + 75: "Heavy snow", + 77: "Snow grains", + 80: "Slight rain showers", + 81: "Moderate rain showers", + 82: "Violent rain showers", + 85: "Slight snow showers", + 86: "Heavy snow showers", + 95: "Thunderstorm", + 96: "Thunderstorm with slight hail", + 99: "Thunderstorm with heavy hail", + } + + weather_desc = weather_codes.get(current.get("weather_code", 0), "Unknown") + temp = current.get("temperature_2m") + feels_like = current.get("apparent_temperature") + humidity = current.get("relative_humidity_2m") + wind = current.get("wind_speed_10m") + + return ( + f"Current weather: {weather_desc}. " + f"Temperature is {temp}°C (feels like {feels_like}°C). " + f"Humidity at {humidity}%, wind speed {wind} km/h." + ) + + +def _format_forecast(resp) -> str: + """Parse forecast response into readable format.""" + import json + + data = json.loads(resp.body) + daily = data.get("daily", {}) + + # just show first 3 days to keep it concise + times = daily.get("time", [])[:3] + max_temps = daily.get("temperature_2m_max", [])[:3] + min_temps = daily.get("temperature_2m_min", [])[:3] + + forecast_text = "Here's the forecast: " + for i, date in enumerate(times): + forecast_text += f"{date}: High {max_temps[i]}°C, Low {min_temps[i]}°C. " + + return forecast_text + + +# Create HTTP tools for weather APIs +search_location_tool = create_http_tool( + HTTPToolConfig( + name="search_location", + description="Search for a location by name to get its coordinates. Always use this first when the user mentions a city name.", + url="https://geocoding-api.open-meteo.com/v1/search", + method="GET", + timeout_ms=5000, + parameters={ + "type": "object", + "required": ["name"], + "properties": { + "name": { + "type": "string", + "description": "City name to search for (e.g., 'New York', 'London', 'Tokyo')", + }, + "count": { + "type": "number", + "description": "Number of results to return. Default is 1 (best match).", + }, + "language": { + "type": "string", + "description": "Language for results (en, ru, etc). Default is 'en'.", + }, + }, + }, + output_normalizer=_format_location_response, + ) +) + + +get_current_weather_tool = create_http_tool( + HTTPToolConfig( + name="get_current_weather", + description="Get the current weather for a specific location. Returns temperature, humidity, apparent temperature, weather condition, and wind speed.", + url="https://api.open-meteo.com/v1/forecast", + method="GET", + timeout_ms=10000, + parameters={ + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "number", + "description": "Latitude coordinate (e.g., 40.7128 for New York, 51.5074 for London)", + }, + "longitude": { + "type": "number", + "description": "Longitude coordinate (e.g., -74.0060 for New York, -0.1278 for London)", + }, + "current": { + "type": "string", + "description": "Current weather variables to fetch", + }, + "timezone": { + "type": "string", + "description": "Timezone (use 'auto' to detect automatically)", + }, + "temperature_unit": { + "type": "string", + "description": "Temperature unit: 'celsius' or 'fahrenheit'", + "enum": ["celsius", "fahrenheit"], + }, + }, + }, + output_normalizer=_format_current_weather, + ) +) + + +get_weather_forecast_tool = create_http_tool( + HTTPToolConfig( + name="get_weather_forecast", + description="Get a 7-day weather forecast for a location. Returns daily high/low temperatures, weather conditions, precipitation, and wind speed.", + url="https://api.open-meteo.com/v1/forecast", + method="GET", + timeout_ms=15000, + parameters={ + "type": "object", + "required": ["latitude", "longitude"], + "properties": { + "latitude": { + "type": "number", + "description": "Latitude coordinate (e.g., 40.7128 for New York)", + }, + "longitude": { + "type": "number", + "description": "Longitude coordinate (e.g., -74.0060 for New York)", + }, + "daily": { + "type": "string", + "description": "Daily weather variables to fetch", + }, + "timezone": { + "type": "string", + "description": "Timezone (use 'auto' to detect automatically)", + }, + "temperature_unit": { + "type": "string", + "description": "Temperature unit: 'celsius' or 'fahrenheit'", + "enum": ["celsius", "fahrenheit"], + }, + "forecast_days": { + "type": "number", + "description": "Number of forecast days (1-16). Default is 7.", + }, + }, + }, + execution_message="One moment, fetching the weather forecast for you.", + output_normalizer=_format_forecast, + ) +) + + +# Create SMS tool (automatically sends to the caller) +# Only initialize if SMS provider credentials are available +# It will skip sending the SMS if no credentials are found +try: + send_weather_sms_tool = create_sms_tool( + SMSToolConfig( + name="send_weather_sms", + description="Send weather information via SMS to the caller. Use this when the user asks to receive weather info by text message.", + to_number="+15555555555", # PUT YOUR PHONE NUMBER HERE + ) + ) +except ValueError: + logger.warning("SMS tool not available: no SMS provider credentials found") + send_weather_sms_tool = None + + class WeatherAgent(Agent): def __init__(self) -> None: - super().__init__( - instructions="You are a weather agent.", - llm=openai.realtime.RealtimeModel(), + tools = [ + search_location_tool, + get_current_weather_tool, + get_weather_forecast_tool, + ] + + instructions = ( + "You are a helpful weather assistant. When a user asks about weather:\n" + "1. First use search_location to get coordinates for the city\n" + "2. Then use get_current_weather or get_weather_forecast with those coordinates\n" + "3. Be friendly and conversational\n" + "4. Always mention the city name in your response\n" + ) + + if send_weather_sms_tool: + tools.append(send_weather_sms_tool) + instructions += "5. If the user asks to receive weather info by SMS/text, use send_weather_sms with a concise weather summary\n" + + instructions += ( + "Note: Open-Meteo API requires specific parameters - for current weather, pass " + "current='temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m' " + "and timezone='auto'. For forecast, pass " + "daily='temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,wind_speed_10m_max' " + "and timezone='auto'." ) - @function_tool - async def get_weather( - self, - latitude: str, - longitude: str, - ): - """Called when the user asks about the weather. This function will return the weather for - the given location. When given a location, please estimate the latitude and longitude of the - location and do not ask the user for them. - - Args: - latitude: The latitude of the location - longitude: The longitude of the location - """ - - logger.info(f"getting weather for {latitude}, {longitude}") - url = f"https://api.open-meteo.com/v1/forecast?latitude={latitude}&longitude={longitude}¤t=temperature_2m" - weather_data = {} - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - if response.status == 200: - data = await response.json() - # response from the function call is returned to the LLM - weather_data = { - "temperature": data["current"]["temperature_2m"], - "temperature_unit": "Celsius", - } - else: - raise Exception(f"Failed to get weather data, status code: {response.status}") - - return weather_data + super().__init__(instructions=instructions) + self._tools = tools + + async def on_enter(self): + await self.session.generate_reply( + instructions="say hello to the user in English and ask them what city they would like to know about" + ) server = AgentServer() @@ -59,19 +287,15 @@ async def get_weather( @server.rtc_session() async def entrypoint(ctx: JobContext): - # each log entry will include these fields - ctx.log_context_fields = { - "room_name": ctx.room.name, - "user_id": "your user_id", - } - - session = AgentSession() - - await session.start( - agent=WeatherAgent(), - room=ctx.room, + session = AgentSession( + stt="deepgram/nova-3", + llm="openai/gpt-4o-mini", + tts="cartesia/sonic-3:a167e0f3-df7e-4d52-a9c3-f949145efdab", + vad=silero.VAD.load(), ) + await session.start(agent=WeatherAgent(), room=ctx.room) + if __name__ == "__main__": cli.run_app(server) diff --git a/livekit-agents/livekit/agents/beta/tools/__init__.py b/livekit-agents/livekit/agents/beta/tools/__init__.py new file mode 100644 index 0000000000..d5936fdce0 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/__init__.py @@ -0,0 +1,17 @@ +"""Production-ready tools for common agent operations.""" + +from . import http, sms +from .http import HTTPResponse, HTTPToolConfig, create_http_tool +from .send_dtmf import send_dtmf_events +from .sms import SMSToolConfig, create_sms_tool + +__all__ = [ + "http", + "sms", + "send_dtmf_events", + "HTTPResponse", + "HTTPToolConfig", + "create_http_tool", + "SMSToolConfig", + "create_sms_tool", +] diff --git a/livekit-agents/livekit/agents/beta/tools/http/__init__.py b/livekit-agents/livekit/agents/beta/tools/http/__init__.py new file mode 100644 index 0000000000..e65077f63b --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/http/__init__.py @@ -0,0 +1,13 @@ +"""HTTP request tools for making API calls from agents. + +Provides a simple way to expose HTTP API calls as agent tools with +configurable parameters, headers, and response handling. +""" + +from .send_http import HTTPResponse, HTTPToolConfig, create_http_tool + +__all__ = [ + "HTTPResponse", + "HTTPToolConfig", + "create_http_tool", +] diff --git a/livekit-agents/livekit/agents/beta/tools/http/send_http.py b/livekit-agents/livekit/agents/beta/tools/http/send_http.py new file mode 100644 index 0000000000..b971552bf7 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/http/send_http.py @@ -0,0 +1,306 @@ +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +import aiohttp + +from .... import FunctionTool +from ....llm.tool_context import ToolError, function_tool +from ....log import logger +from ....voice import AgentSession, RunContext + + +@dataclass +class HTTPResponse: + """Structured response from HTTP request. + + Attributes: + success: Whether the request was successful (2xx status) + status_code: HTTP status code + body: Response body as string + headers: Response headers + error: Error message (if failed) + """ + + success: bool + status_code: int + body: str + headers: dict[str, str] + error: str | None = None + + +@dataclass +class HTTPToolConfig: + """Configuration for HTTP tool customization. + + Attributes: + url: Endpoint URL (required) + name: Custom tool name (optional) + description: Custom tool description (optional) + method: HTTP method (default: "POST") + headers: Optional dict of headers to send with request + timeout_ms: Request timeout in milliseconds (default: 30000) + parameters: JSON schema dict for tool parameters (defaults to empty object schema) + execution_message: Optional message to announce before executing (spoken by agent) + output_normalizer: Optional function to transform HTTPResponse to string or None + - Return string (including ""): agent uses it in next response + - Return None: agent stays silent (good for background calls) + """ + + url: str + name: str | None = None + description: str | None = None + method: str = "POST" + headers: dict[str, str] | None = None + timeout_ms: int = 30000 + parameters: dict[str, Any] | None = None + execution_message: str | None = None + output_normalizer: Callable[[HTTPResponse], str | None] | None = None + + +async def _announce_execution(tool_name: str, message: str, session: AgentSession) -> None: + """Announce execution message via agent session. + + Args: + tool_name: Name of the tool being executed + message: Message to announce + session: AgentSession from RunContext + """ + try: + await session.generate_reply( + instructions=f"You are running {tool_name} tool (do not announce the tool name) for user and should announce it using this instruction: {message}", + allow_interruptions=False, + ) + except Exception as e: + logger.debug( + "failed to announce execution for tool", + extra={"tool_name": tool_name, "error": str(e)}, + ) + + +async def _make_http_request( + url: str, + method: str, + headers: dict[str, str] | None, + timeout_ms: int, + arguments: dict[str, Any], +) -> HTTPResponse: + """Make HTTP request with given parameters. + + Args: + url: Endpoint URL + method: HTTP method + headers: Request headers + timeout_ms: Timeout in milliseconds + arguments: Parameters to send + + Returns: + HTTPResponse with success/failure details + """ + method = method.upper() + timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000.0) + + async with aiohttp.ClientSession(timeout=timeout) as session: + try: + if method == "GET": + resp_ctx = session.get(url, params=arguments, headers=headers) + elif method == "POST": + resp_ctx = session.post(url, json=arguments, headers=headers) + elif method == "PUT": + resp_ctx = session.put(url, json=arguments, headers=headers) + elif method == "PATCH": + resp_ctx = session.patch(url, json=arguments, headers=headers) + else: # DELETE + resp_ctx = session.delete(url, params=arguments, headers=headers) + + async with resp_ctx as resp: + body = await resp.text() + success = 200 <= resp.status < 300 + + response = HTTPResponse( + success=success, + status_code=resp.status, + body=body, + headers=dict(resp.headers), + error=None if success else f"HTTP {resp.status}", + ) + + if not success: + logger.warning( + "HTTP request returned error status", + extra={ + "url": url, + "method": method, + "status_code": resp.status, + "response_size": len(body), + }, + ) + + return response + + except asyncio.TimeoutError as e: + logger.warning( + "HTTP request timeout", + extra={ + "url": url, + "method": method, + "timeout_ms": timeout_ms, + }, + ) + raise ToolError(f"Request timeout after {timeout_ms}ms") from e + except aiohttp.ClientError as e: + logger.error( + "HTTP request client error", + extra={ + "url": url, + "method": method, + "error": str(e), + }, + ) + raise ToolError(f"HTTP request failed: {str(e)}") from e + except Exception as e: + logger.exception( + "unexpected error during HTTP request", + extra={ + "url": url, + "method": method, + }, + ) + raise ToolError(f"Unexpected error: {str(e)}") from e + + +def create_http_tool(config: HTTPToolConfig) -> FunctionTool: + """Create an HTTP request tool. + + The tool makes HTTP requests to a specified endpoint with configurable + parameters, headers, and response handling. + + Args: + config: Configuration for the HTTP tool + + Returns: + FunctionTool that can be used in Agent.tools + + Raises: + ValueError: If config is invalid (missing URL, invalid parameters schema) + + Example: + ```python + from livekit.agents.beta.tools.http import create_http_tool, HTTPToolConfig + + # Minimal usage + tool = create_http_tool(HTTPToolConfig( + url="https://api.example.com/status" + )) + + # With parameters + config = HTTPToolConfig( + name="create_ticket", + description="Create a support ticket", + url="https://api.example.com/tickets", + method="POST", + headers={"Authorization": "Bearer token"}, + parameters={ + "type": "object", + "properties": { + "title": {"type": "string", "description": "Ticket title"}, + "priority": {"type": "string", "enum": ["low", "high"]} + }, + "required": ["title"] + } + ) + tool = create_http_tool(config) + + # With normalizer for custom response handling + config_with_norm = HTTPToolConfig( + name="get_weather", + url="https://api.weather.com/current", + method="GET", + parameters={ + "type": "object", + "properties": { + "city": {"type": "string"} + }, + "required": ["city"] + }, + output_normalizer=lambda resp: f"Temp: {resp.body}" if resp.success else None + ) + weather_tool = create_http_tool(config_with_norm) + + # Use in agent + agent = Agent(tools=[tool, weather_tool]) + ``` + """ + if not config.url: + logger.error("HTTP tool creation failed: missing URL") + raise ValueError("URL is required in HTTPToolConfig") + + supported_methods = {"GET", "POST", "PUT", "PATCH", "DELETE"} + method_upper = config.method.upper() + if method_upper not in supported_methods: + logger.error( + "HTTP tool creation failed: unsupported method", + extra={"method": config.method, "supported_methods": list(supported_methods)}, + ) + raise ValueError( + f"Unsupported HTTP method: {config.method}. " + f"Supported methods: {', '.join(sorted(supported_methods))}" + ) + + parameters_schema = config.parameters or {"type": "object", "properties": {}, "required": []} + + if not isinstance(parameters_schema, dict) or "type" not in parameters_schema: + logger.error( + "HTTP tool creation failed: invalid parameters schema", + extra={"schema_type": type(parameters_schema).__name__}, + ) + raise ValueError("Invalid parameters schema - must be a dict with 'type' field") + + tool_name = config.name or "http_request" + tool_description = config.description or f"Make {config.method} request to {config.url}" + + logger.info( + "creating HTTP tool", + extra={ + "tool_name": tool_name, + "url": config.url, + "method": config.method, + "timeout_ms": config.timeout_ms, + "has_custom_headers": config.headers is not None, + "has_execution_message": config.execution_message is not None, + "has_output_normalizer": config.output_normalizer is not None, + }, + ) + + raw_schema = { + "name": tool_name, + "description": tool_description, + "parameters": parameters_schema, + } + + async def http_handler(raw_arguments: dict, context: RunContext) -> str | None: + if config.execution_message: + await _announce_execution(tool_name, config.execution_message, context.session) + + response = await _make_http_request( + url=config.url, + method=config.method, + headers=config.headers, + timeout_ms=config.timeout_ms, + arguments=raw_arguments, + ) + + if not response.success: + error_body = response.body[:500] if len(response.body) > 500 else response.body + raise ToolError(f"HTTP {response.status_code}: {error_body}") + + if config.output_normalizer: + return config.output_normalizer(response) + + return response.body + + return function_tool( + http_handler, + raw_schema=raw_schema, + ) diff --git a/livekit-agents/livekit/agents/beta/tools/sms/README.md b/livekit-agents/livekit/agents/beta/tools/sms/README.md new file mode 100644 index 0000000000..62980f7cee --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/sms/README.md @@ -0,0 +1,55 @@ +# SMS Tools + +Send SMS messages from voice agents with auto-detected provider support. +Supported: Twilio, Vonage, SignalWire + +## Usage + +```python +from livekit.agents import Agent +from livekit.agents.beta.tools.sms import create_sms_tool, SMSToolConfig + +# Default: auto-detects recipient from SIP participant +# LLM only needs to provide the message text +sms_tool = create_sms_tool() + +# Custom: change tool name, description, recipient or sender +sms_tool = create_sms_tool(SMSToolConfig( + name="notify_customer", # Custom function name + description="Send confirmation", # Custom function description + to_number="+1234567890", # Send to specific number + from_number="+0987654321", # Send from specific number +)) + +agent = Agent( + instructions="You can send SMS messages to callers", + tools=[sms_tool] +) +``` + +## Environment Variables + +**Twilio:** + +```bash +TWILIO_ACCOUNT_SID=ACxxxxxxxxxxxxx +TWILIO_AUTH_TOKEN=your_auth_token +TWILIO_PHONE_NUMBER=+1234567890 +``` + +**SignalWire:** + +```bash +SIGNALWIRE_PROJECT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +SIGNALWIRE_TOKEN=PTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx +SIGNALWIRE_SPACE_URL=example.signalwire.com +SIGNALWIRE_PHONE_NUMBER=+1234567890 +``` + +**Vonage:** + +```bash +VONAGE_API_KEY=your_api_key +VONAGE_API_SECRET=your_api_secret +VONAGE_PHONE_NUMBER=1234567890 +``` diff --git a/livekit-agents/livekit/agents/beta/tools/sms/__init__.py b/livekit-agents/livekit/agents/beta/tools/sms/__init__.py new file mode 100644 index 0000000000..a390026351 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/sms/__init__.py @@ -0,0 +1,12 @@ +"""SMS sending tools with auto-detected provider support. + +Supports Twilio, SignalWire, and Vonage SMS providers. +""" + +from .send_sms import SMSResponse, SMSToolConfig, create_sms_tool + +__all__ = [ + "SMSToolConfig", + "SMSResponse", + "create_sms_tool", +] diff --git a/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py new file mode 100644 index 0000000000..b36a17fe32 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py @@ -0,0 +1,416 @@ +import asyncio +import json +import os +from collections.abc import Callable +from dataclasses import dataclass + +import aiohttp + +from livekit import rtc + +from .... import FunctionTool +from ....job import get_job_context +from ....llm.tool_context import ToolError, function_tool + + +@dataclass +class SMSResponse: + """Structured response from SMS send operation. + + Attributes: + success: Whether the SMS was sent successfully + message_id: Provider's message ID (if available) + to: Recipient phone number + error: Error message (if failed) + """ + + success: bool + message_id: str | None + to: str + error: str | None = None + + +@dataclass +class SMSToolConfig: + """Configuration for SMS tool customization. + + Attributes: + name: Custom tool name (default: "send_sms") + description: Custom tool description + to_number: Recipient phone number (if set, disables auto_detect_caller) + from_number: Sender phone number (if not set, uses PROVIDER_PHONE_NUMBER env var) + auto_detect_caller: Auto-detect caller phone from SIP participant (default: True) + output_normalizer: Optional function to transform SMSResponse to string + """ + + name: str | None = None + description: str | None = None + to_number: str | None = None + from_number: str | None = None + auto_detect_caller: bool = True + output_normalizer: Callable[[SMSResponse], str] | None = None + + +def _detect_provider() -> tuple[str, dict] | None: + """Auto-detect SMS provider based on environment variables. + + Returns: + Tuple of (provider_name, credentials) or None if no provider configured + """ + if os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"): + return ( + "twilio", + { + "account_sid": os.getenv("TWILIO_ACCOUNT_SID"), + "auth_token": os.getenv("TWILIO_AUTH_TOKEN"), + "from": os.getenv("TWILIO_PHONE_NUMBER"), + }, + ) + + if os.getenv("SIGNALWIRE_PROJECT_ID") and os.getenv("SIGNALWIRE_TOKEN"): + return ( + "signalwire", + { + "project_id": os.getenv("SIGNALWIRE_PROJECT_ID"), + "token": os.getenv("SIGNALWIRE_TOKEN"), + "space_url": os.getenv("SIGNALWIRE_SPACE_URL"), + "from": os.getenv("SIGNALWIRE_PHONE_NUMBER"), + }, + ) + + if os.getenv("VONAGE_API_KEY") and os.getenv("VONAGE_API_SECRET"): + return ( + "vonage", + { + "api_key": os.getenv("VONAGE_API_KEY"), + "api_secret": os.getenv("VONAGE_API_SECRET"), + "from": os.getenv("VONAGE_PHONE_NUMBER"), + }, + ) + + return None + + +async def _get_caller_phone_number() -> str | None: + """Extract caller phone number from SIP participant. + + Returns: + Phone number from SIP participant identity or None if not available + """ + try: + job_ctx = get_job_context() + for participant in job_ctx.room.remote_participants.values(): + if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP: + return participant.identity + except Exception: + return None + + +def _validate_phone_number(phone: str) -> bool: + """Validate phone number format (international and local formats supported). + + Args: + phone: Phone number to validate + + Returns: + True if valid phone number (7-15 digits after cleaning) + """ + if not phone or not isinstance(phone, str): + return False + + digits_only = "".join(c for c in phone if c.isdigit()) + return 7 <= len(digits_only) <= 15 + + +async def _send_sms_http( + provider_name: str, + url: str, + to_number: str, + message: str, + from_number: str, + *, + auth: aiohttp.BasicAuth | None = None, + json_body: dict | None = None, + form_body: dict | None = None, + parse_response: Callable[[dict, str], SMSResponse], +) -> SMSResponse: + """Generic HTTP SMS sender with unified error handling.""" + async with aiohttp.ClientSession() as session: + try: + kwargs = {"timeout": aiohttp.ClientTimeout(total=10)} + if auth: + kwargs["auth"] = auth + if json_body: + kwargs["json"] = json_body + if form_body: + kwargs["data"] = form_body + + async with session.post(url, **kwargs) as resp: + if resp.status in (200, 201): + result = await resp.json() + return parse_response(result, to_number) + else: + error_text = await resp.text() + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=f"HTTP {resp.status}: {error_text}", + ) + except asyncio.TimeoutError: + return SMSResponse( + success=False, message_id=None, to=to_number, error="Request timeout" + ) + except Exception as e: + return SMSResponse(success=False, message_id=None, to=to_number, error=str(e)) + + +async def _send_twilio_sms( + credentials: dict, to_number: str, message: str, from_number: str +) -> SMSResponse: + """Send SMS via Twilio API.""" + url = f"https://api.twilio.com/2010-04-01/Accounts/{credentials['account_sid']}/Messages.json" + auth = aiohttp.BasicAuth(credentials["account_sid"], credentials["auth_token"]) + form_body = {"To": to_number, "From": from_number, "Body": message} + + def parse(result: dict, to: str) -> SMSResponse: + return SMSResponse(success=True, message_id=result.get("sid"), to=to) + + return await _send_sms_http( + "Twilio", + url, + to_number, + message, + from_number, + auth=auth, + form_body=form_body, + parse_response=parse, + ) + + +async def _send_signalwire_sms( + credentials: dict, to_number: str, message: str, from_number: str +) -> SMSResponse: + """Send SMS via SignalWire API.""" + space_url = credentials.get("space_url") + if not space_url: + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error="SIGNALWIRE_SPACE_URL not configured", + ) + + url = f"https://{space_url}/api/laml/2010-04-01/Accounts/{credentials['project_id']}/Messages.json" + auth = aiohttp.BasicAuth(credentials["project_id"], credentials["token"]) + # SignalWire requires + prefix + form_body = { + "To": to_number if to_number.startswith("+") else f"+{to_number}", + "From": from_number if from_number.startswith("+") else f"+{from_number}", + "Body": message, + } + + def parse(result: dict, to: str) -> SMSResponse: + return SMSResponse(success=True, message_id=result.get("sid"), to=to) + + return await _send_sms_http( + "SignalWire", + url, + to_number, + message, + from_number, + auth=auth, + form_body=form_body, + parse_response=parse, + ) + + +async def _send_vonage_sms( + credentials: dict, to_number: str, message: str, from_number: str +) -> SMSResponse: + """Send SMS via Vonage (Nexmo) API.""" + url = "https://rest.nexmo.com/sms/json" + json_body = { + "api_key": credentials["api_key"], + "api_secret": credentials["api_secret"], + "to": to_number, + "from": from_number, + "text": message, + } + + def parse(result: dict, to: str) -> SMSResponse: + messages = result.get("messages", []) + if messages and messages[0].get("status") == "0": + return SMSResponse(success=True, message_id=messages[0].get("message-id"), to=to) + error = messages[0].get("error-text", "Unknown error") if messages else "Unknown error" + return SMSResponse(success=False, message_id=None, to=to, error=error) + + return await _send_sms_http( + "Vonage", url, to_number, message, from_number, json_body=json_body, parse_response=parse + ) + + +def create_sms_tool(config: SMSToolConfig | None = None) -> FunctionTool: + """Create an SMS sending tool with auto-detected provider. + + The tool automatically detects which SMS provider to use based on + environment variables. Supports Twilio, SignalWire, and Vonage. + + Environment Variables: + Twilio: + - TWILIO_ACCOUNT_SID + - TWILIO_AUTH_TOKEN + - TWILIO_PHONE_NUMBER (optional, can be set in config) + + SignalWire: + - SIGNALWIRE_PROJECT_ID + - SIGNALWIRE_TOKEN + - SIGNALWIRE_SPACE_URL + - SIGNALWIRE_PHONE_NUMBER (optional, can be set in config) + + Vonage: + - VONAGE_API_KEY + - VONAGE_API_SECRET + - VONAGE_PHONE_NUMBER (optional, can be set in config) + + Args: + config: Optional configuration to customize tool behavior + + Returns: + FunctionTool that can be used in Agent.tools + + Example: + ```python + from livekit.agents.beta.tools.sms import create_sms_tool, SMSToolConfig + + # Basic usage - auto-detects provider + sms_tool = create_sms_tool() + + # With customization + config = SMSToolConfig( + name="notify_customer", + description="Send confirmation SMS to customer", + auto_detect_caller=True, + ) + sms_tool = create_sms_tool(config) + + # Use in agent + agent = Agent( + instructions="You are a helpful assistant", + tools=[sms_tool] + ) + ``` + """ + config = config or SMSToolConfig() + + provider_info = _detect_provider() + if not provider_info: + raise ValueError( + "No SMS provider credentials found in environment variables. " + "Set TWILIO_*, SIGNALWIRE_*, or VONAGE_* env vars to enable SMS. " + "Required variables:\n" + " Twilio: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN\n" + " SignalWire: SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN, SIGNALWIRE_SPACE_URL\n" + " Vonage: VONAGE_API_KEY, VONAGE_API_SECRET" + ) + + provider_name, credentials = provider_info + + tool_name = config.name or "send_sms" + tool_description = ( + config.description + or f"Send SMS message via {provider_name}. Automatically sends to the caller if no recipient specified." + ) + + parameters_properties = { + "message": {"type": "string", "description": "The text message to send"} + } + + required_params = ["message"] + + # Add 'to' parameter only if we can't determine recipient automatically + if not config.to_number and not config.auto_detect_caller: + parameters_properties["to"] = { + "type": "string", + "description": "Recipient phone number (international or local format, e.g., +1234567890 or 1234567890)", + } + required_params.append("to") + + raw_schema = { + "name": tool_name, + "description": tool_description, + "parameters": { + "type": "object", + "properties": parameters_properties, + "required": required_params, + }, + } + + # Create the actual SMS sending function with raw_arguments + async def send_sms(raw_arguments: dict) -> str: + """Send SMS message via configured provider.""" + message = raw_arguments.get("message") + + if not message: + raise ToolError("Message text is required") + + if config.to_number: + recipient = config.to_number + elif config.auto_detect_caller: + recipient = await _get_caller_phone_number() + if not recipient: + raise ToolError("Could not auto-detect caller phone number from SIP participant") + else: + recipient = raw_arguments.get("to") + if not recipient: + raise ToolError("Recipient phone number is required") + + # Validate phone number + if not _validate_phone_number(recipient): + raise ToolError( + f"Invalid phone number format: {recipient}. Phone number must contain 7-15 digits" + ) + + sender_number = config.from_number or credentials.get("from") + if not sender_number: + raise ToolError( + f"Sender phone number not configured. Set {provider_name.upper()}_PHONE_NUMBER " + "environment variable or provide from_number in config" + ) + + try: + if provider_name == "twilio": + response = await _send_twilio_sms(credentials, recipient, message, sender_number) + elif provider_name == "signalwire": + response = await _send_signalwire_sms( + credentials, recipient, message, sender_number + ) + elif provider_name == "vonage": + response = await _send_vonage_sms(credentials, recipient, message, sender_number) + else: + raise ToolError(f"Unknown provider: {provider_name}") + + if config.output_normalizer: + return config.output_normalizer(response) + + if response.success: + return json.dumps( + { + "success": True, + "message_id": response.message_id, + "to": recipient, + }, + indent=2, + ) + else: + raise ToolError(f"Failed to send SMS: {response.error}") + + except ToolError: + raise + except Exception as e: + raise ToolError(f"Failed to send SMS: {str(e)}") from e + + return function_tool( + send_sms, + raw_schema=raw_schema, + ) diff --git a/tests/test_beta_tools.py b/tests/test_beta_tools.py new file mode 100644 index 0000000000..a1a25e0484 --- /dev/null +++ b/tests/test_beta_tools.py @@ -0,0 +1,236 @@ +from __future__ import annotations + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from livekit.agents.beta.tools.http import ( + HTTPToolConfig, + create_http_tool, +) +from livekit.agents.beta.tools.sms import ( + SMSToolConfig, + create_sms_tool, +) +from livekit.agents.llm import FunctionCall +from livekit.agents.llm.tool_context import ToolError +from livekit.agents.voice import RunContext + + +class MockResponse: + def __init__( + self, + status: int = 200, + text: str = '{"result": "success"}', + headers: dict[str, str] | None = None, + ): + self.status = status + self._text = text + self.headers = headers or {} + + async def text(self) -> str: + return self._text + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +class MockSession: + def __init__(self, response: MockResponse | None = None): + self.response = response or MockResponse() + self.request_kwargs: dict[str, Any] = {} + + def _store_request(self, method: str, **kwargs): + self.request_kwargs = {"method": method, **kwargs} + return self.response + + def get(self, url: str, **kwargs): + return self._store_request("GET", url=url, **kwargs) + + def post(self, url: str, **kwargs): + return self._store_request("POST", url=url, **kwargs) + + def put(self, url: str, **kwargs): + return self._store_request("PUT", url=url, **kwargs) + + def patch(self, url: str, **kwargs): + return self._store_request("PATCH", url=url, **kwargs) + + def delete(self, url: str, **kwargs): + return self._store_request("DELETE", url=url, **kwargs) + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +def create_mock_context() -> RunContext: + """Create a mock RunContext for testing.""" + mock_session = MagicMock() + mock_speech_handle = MagicMock() + mock_speech_handle.num_steps = 1 + mock_function_call = MagicMock(spec=FunctionCall) + + context = RunContext( + session=mock_session, + speech_handle=mock_speech_handle, + function_call=mock_function_call, + ) + return context + + +def test_create_tool_validation(): + with pytest.raises(ValueError, match="URL is required"): + create_http_tool(HTTPToolConfig(url="")) + + with pytest.raises(ValueError, match="Unsupported HTTP method"): + create_http_tool(HTTPToolConfig(url="https://api.example.com", method="TRACE")) + + +@pytest.mark.parametrize( + "method", + ["GET", "POST", "PUT", "PATCH", "DELETE"], +) +async def test_http_methods(method: str): + config = HTTPToolConfig( + url="https://api.example.com/test", + method=method, + ) + tool = create_http_tool(config) + + mock_response = MockResponse(status=200, text="success") + mock_session = MockSession(response=mock_response) + mock_context = create_mock_context() + + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await tool({}, mock_context) + + assert result == "success" + assert mock_session.request_kwargs["method"] == method + + +async def test_http_request_types(): + mock_context = create_mock_context() + mock_session = MockSession() + + with patch("aiohttp.ClientSession", return_value=mock_session): + # GET sends params + tool = create_http_tool(HTTPToolConfig(url="https://api.example.com", method="GET")) + await tool({"query": "test"}, mock_context) + assert "params" in mock_session.request_kwargs + + # POST sends json + tool = create_http_tool(HTTPToolConfig(url="https://api.example.com", method="POST")) + await tool({"name": "test"}, mock_context) + assert "json" in mock_session.request_kwargs + + +async def test_http_error_handling(): + mock_context = create_mock_context() + tool = create_http_tool(HTTPToolConfig(url="https://api.example.com")) + + mock_response = MockResponse(status=404, text="Not found") + mock_session = MockSession(response=mock_response) + + with patch("aiohttp.ClientSession", return_value=mock_session): + with pytest.raises(ToolError, match="HTTP 404"): + await tool({}, mock_context) + + +async def test_output_normalizer(): + mock_context = create_mock_context() + mock_session = MockSession() + + with patch("aiohttp.ClientSession", return_value=mock_session): + tool = create_http_tool( + HTTPToolConfig( + url="https://api.example.com", + output_normalizer=lambda resp: f"Status: {resp.status_code}", + ) + ) + result = await tool({}, mock_context) + assert result == "Status: 200" + + +# =========================== +# SMS Tool Tests +# =========================== + + +def test_sms_provider_detection(): + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(ValueError, match="No SMS provider credentials"): + create_sms_tool() + + +class MockSMSResponse: + def __init__(self, status: int = 200, json_data: dict | None = None): + self.status = status + self._json_data = json_data or {} + + async def json(self): + return self._json_data + + async def text(self): + return "error" + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +class MockSMSSession: + def __init__(self, response: MockSMSResponse | None = None): + self.response = response or MockSMSResponse() + + def post(self, url: str, **kwargs): + return self.response + + async def __aenter__(self): + return self + + async def __aexit__(self, *args): + pass + + +async def test_send_sms_success(): + env_vars = { + "TWILIO_ACCOUNT_SID": "test_sid", + "TWILIO_AUTH_TOKEN": "test_token", + "TWILIO_PHONE_NUMBER": "+11234567890", + } + config = SMSToolConfig(to_number="+10987654321") + mock_response = MockSMSResponse(status=200, json_data={"sid": "SM123"}) + mock_session = MockSMSSession(response=mock_response) + + with patch.dict("os.environ", env_vars): + tool = create_sms_tool(config) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = await tool({"message": "Test"}) + assert "success" in result + + +async def test_send_sms_validation_errors(): + env_vars = { + "TWILIO_ACCOUNT_SID": "test_sid", + "TWILIO_AUTH_TOKEN": "test_token", + "TWILIO_PHONE_NUMBER": "+11234567890", + } + + with patch.dict("os.environ", env_vars): + tool = create_sms_tool(SMSToolConfig(to_number="+10987654321")) + + with pytest.raises(ToolError, match="Message text is required"): + await tool({}) + + tool = create_sms_tool(SMSToolConfig(to_number="invalid")) + with pytest.raises(ToolError, match="Invalid phone number format"): + await tool({"message": "Test"}) From 873adf7e36695bf395c2394bd061a8351be45b94 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 17 Nov 2025 11:10:32 -0800 Subject: [PATCH 2/5] SMS Tool: Remove HTTP tool, keep only SMS functionality --- .../livekit/agents/beta/tools/__init__.py | 7 +- .../agents/beta/tools/http/__init__.py | 13 - .../agents/beta/tools/http/send_http.py | 306 ------------------ tests/test_beta_tools.py | 154 +-------- 4 files changed, 2 insertions(+), 478 deletions(-) delete mode 100644 livekit-agents/livekit/agents/beta/tools/http/__init__.py delete mode 100644 livekit-agents/livekit/agents/beta/tools/http/send_http.py diff --git a/livekit-agents/livekit/agents/beta/tools/__init__.py b/livekit-agents/livekit/agents/beta/tools/__init__.py index d5936fdce0..df70c05af7 100644 --- a/livekit-agents/livekit/agents/beta/tools/__init__.py +++ b/livekit-agents/livekit/agents/beta/tools/__init__.py @@ -1,17 +1,12 @@ """Production-ready tools for common agent operations.""" -from . import http, sms -from .http import HTTPResponse, HTTPToolConfig, create_http_tool +from . import sms from .send_dtmf import send_dtmf_events from .sms import SMSToolConfig, create_sms_tool __all__ = [ - "http", "sms", "send_dtmf_events", - "HTTPResponse", - "HTTPToolConfig", - "create_http_tool", "SMSToolConfig", "create_sms_tool", ] diff --git a/livekit-agents/livekit/agents/beta/tools/http/__init__.py b/livekit-agents/livekit/agents/beta/tools/http/__init__.py deleted file mode 100644 index e65077f63b..0000000000 --- a/livekit-agents/livekit/agents/beta/tools/http/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""HTTP request tools for making API calls from agents. - -Provides a simple way to expose HTTP API calls as agent tools with -configurable parameters, headers, and response handling. -""" - -from .send_http import HTTPResponse, HTTPToolConfig, create_http_tool - -__all__ = [ - "HTTPResponse", - "HTTPToolConfig", - "create_http_tool", -] diff --git a/livekit-agents/livekit/agents/beta/tools/http/send_http.py b/livekit-agents/livekit/agents/beta/tools/http/send_http.py deleted file mode 100644 index b971552bf7..0000000000 --- a/livekit-agents/livekit/agents/beta/tools/http/send_http.py +++ /dev/null @@ -1,306 +0,0 @@ -import asyncio -from collections.abc import Callable -from dataclasses import dataclass -from typing import Any - -import aiohttp - -from .... import FunctionTool -from ....llm.tool_context import ToolError, function_tool -from ....log import logger -from ....voice import AgentSession, RunContext - - -@dataclass -class HTTPResponse: - """Structured response from HTTP request. - - Attributes: - success: Whether the request was successful (2xx status) - status_code: HTTP status code - body: Response body as string - headers: Response headers - error: Error message (if failed) - """ - - success: bool - status_code: int - body: str - headers: dict[str, str] - error: str | None = None - - -@dataclass -class HTTPToolConfig: - """Configuration for HTTP tool customization. - - Attributes: - url: Endpoint URL (required) - name: Custom tool name (optional) - description: Custom tool description (optional) - method: HTTP method (default: "POST") - headers: Optional dict of headers to send with request - timeout_ms: Request timeout in milliseconds (default: 30000) - parameters: JSON schema dict for tool parameters (defaults to empty object schema) - execution_message: Optional message to announce before executing (spoken by agent) - output_normalizer: Optional function to transform HTTPResponse to string or None - - Return string (including ""): agent uses it in next response - - Return None: agent stays silent (good for background calls) - """ - - url: str - name: str | None = None - description: str | None = None - method: str = "POST" - headers: dict[str, str] | None = None - timeout_ms: int = 30000 - parameters: dict[str, Any] | None = None - execution_message: str | None = None - output_normalizer: Callable[[HTTPResponse], str | None] | None = None - - -async def _announce_execution(tool_name: str, message: str, session: AgentSession) -> None: - """Announce execution message via agent session. - - Args: - tool_name: Name of the tool being executed - message: Message to announce - session: AgentSession from RunContext - """ - try: - await session.generate_reply( - instructions=f"You are running {tool_name} tool (do not announce the tool name) for user and should announce it using this instruction: {message}", - allow_interruptions=False, - ) - except Exception as e: - logger.debug( - "failed to announce execution for tool", - extra={"tool_name": tool_name, "error": str(e)}, - ) - - -async def _make_http_request( - url: str, - method: str, - headers: dict[str, str] | None, - timeout_ms: int, - arguments: dict[str, Any], -) -> HTTPResponse: - """Make HTTP request with given parameters. - - Args: - url: Endpoint URL - method: HTTP method - headers: Request headers - timeout_ms: Timeout in milliseconds - arguments: Parameters to send - - Returns: - HTTPResponse with success/failure details - """ - method = method.upper() - timeout = aiohttp.ClientTimeout(total=timeout_ms / 1000.0) - - async with aiohttp.ClientSession(timeout=timeout) as session: - try: - if method == "GET": - resp_ctx = session.get(url, params=arguments, headers=headers) - elif method == "POST": - resp_ctx = session.post(url, json=arguments, headers=headers) - elif method == "PUT": - resp_ctx = session.put(url, json=arguments, headers=headers) - elif method == "PATCH": - resp_ctx = session.patch(url, json=arguments, headers=headers) - else: # DELETE - resp_ctx = session.delete(url, params=arguments, headers=headers) - - async with resp_ctx as resp: - body = await resp.text() - success = 200 <= resp.status < 300 - - response = HTTPResponse( - success=success, - status_code=resp.status, - body=body, - headers=dict(resp.headers), - error=None if success else f"HTTP {resp.status}", - ) - - if not success: - logger.warning( - "HTTP request returned error status", - extra={ - "url": url, - "method": method, - "status_code": resp.status, - "response_size": len(body), - }, - ) - - return response - - except asyncio.TimeoutError as e: - logger.warning( - "HTTP request timeout", - extra={ - "url": url, - "method": method, - "timeout_ms": timeout_ms, - }, - ) - raise ToolError(f"Request timeout after {timeout_ms}ms") from e - except aiohttp.ClientError as e: - logger.error( - "HTTP request client error", - extra={ - "url": url, - "method": method, - "error": str(e), - }, - ) - raise ToolError(f"HTTP request failed: {str(e)}") from e - except Exception as e: - logger.exception( - "unexpected error during HTTP request", - extra={ - "url": url, - "method": method, - }, - ) - raise ToolError(f"Unexpected error: {str(e)}") from e - - -def create_http_tool(config: HTTPToolConfig) -> FunctionTool: - """Create an HTTP request tool. - - The tool makes HTTP requests to a specified endpoint with configurable - parameters, headers, and response handling. - - Args: - config: Configuration for the HTTP tool - - Returns: - FunctionTool that can be used in Agent.tools - - Raises: - ValueError: If config is invalid (missing URL, invalid parameters schema) - - Example: - ```python - from livekit.agents.beta.tools.http import create_http_tool, HTTPToolConfig - - # Minimal usage - tool = create_http_tool(HTTPToolConfig( - url="https://api.example.com/status" - )) - - # With parameters - config = HTTPToolConfig( - name="create_ticket", - description="Create a support ticket", - url="https://api.example.com/tickets", - method="POST", - headers={"Authorization": "Bearer token"}, - parameters={ - "type": "object", - "properties": { - "title": {"type": "string", "description": "Ticket title"}, - "priority": {"type": "string", "enum": ["low", "high"]} - }, - "required": ["title"] - } - ) - tool = create_http_tool(config) - - # With normalizer for custom response handling - config_with_norm = HTTPToolConfig( - name="get_weather", - url="https://api.weather.com/current", - method="GET", - parameters={ - "type": "object", - "properties": { - "city": {"type": "string"} - }, - "required": ["city"] - }, - output_normalizer=lambda resp: f"Temp: {resp.body}" if resp.success else None - ) - weather_tool = create_http_tool(config_with_norm) - - # Use in agent - agent = Agent(tools=[tool, weather_tool]) - ``` - """ - if not config.url: - logger.error("HTTP tool creation failed: missing URL") - raise ValueError("URL is required in HTTPToolConfig") - - supported_methods = {"GET", "POST", "PUT", "PATCH", "DELETE"} - method_upper = config.method.upper() - if method_upper not in supported_methods: - logger.error( - "HTTP tool creation failed: unsupported method", - extra={"method": config.method, "supported_methods": list(supported_methods)}, - ) - raise ValueError( - f"Unsupported HTTP method: {config.method}. " - f"Supported methods: {', '.join(sorted(supported_methods))}" - ) - - parameters_schema = config.parameters or {"type": "object", "properties": {}, "required": []} - - if not isinstance(parameters_schema, dict) or "type" not in parameters_schema: - logger.error( - "HTTP tool creation failed: invalid parameters schema", - extra={"schema_type": type(parameters_schema).__name__}, - ) - raise ValueError("Invalid parameters schema - must be a dict with 'type' field") - - tool_name = config.name or "http_request" - tool_description = config.description or f"Make {config.method} request to {config.url}" - - logger.info( - "creating HTTP tool", - extra={ - "tool_name": tool_name, - "url": config.url, - "method": config.method, - "timeout_ms": config.timeout_ms, - "has_custom_headers": config.headers is not None, - "has_execution_message": config.execution_message is not None, - "has_output_normalizer": config.output_normalizer is not None, - }, - ) - - raw_schema = { - "name": tool_name, - "description": tool_description, - "parameters": parameters_schema, - } - - async def http_handler(raw_arguments: dict, context: RunContext) -> str | None: - if config.execution_message: - await _announce_execution(tool_name, config.execution_message, context.session) - - response = await _make_http_request( - url=config.url, - method=config.method, - headers=config.headers, - timeout_ms=config.timeout_ms, - arguments=raw_arguments, - ) - - if not response.success: - error_body = response.body[:500] if len(response.body) > 500 else response.body - raise ToolError(f"HTTP {response.status_code}: {error_body}") - - if config.output_normalizer: - return config.output_normalizer(response) - - return response.body - - return function_tool( - http_handler, - raw_schema=raw_schema, - ) diff --git a/tests/test_beta_tools.py b/tests/test_beta_tools.py index a1a25e0484..9c0de6f8ba 100644 --- a/tests/test_beta_tools.py +++ b/tests/test_beta_tools.py @@ -1,166 +1,14 @@ from __future__ import annotations -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from livekit.agents.beta.tools.http import ( - HTTPToolConfig, - create_http_tool, -) from livekit.agents.beta.tools.sms import ( SMSToolConfig, create_sms_tool, ) -from livekit.agents.llm import FunctionCall from livekit.agents.llm.tool_context import ToolError -from livekit.agents.voice import RunContext - - -class MockResponse: - def __init__( - self, - status: int = 200, - text: str = '{"result": "success"}', - headers: dict[str, str] | None = None, - ): - self.status = status - self._text = text - self.headers = headers or {} - - async def text(self) -> str: - return self._text - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - -class MockSession: - def __init__(self, response: MockResponse | None = None): - self.response = response or MockResponse() - self.request_kwargs: dict[str, Any] = {} - - def _store_request(self, method: str, **kwargs): - self.request_kwargs = {"method": method, **kwargs} - return self.response - - def get(self, url: str, **kwargs): - return self._store_request("GET", url=url, **kwargs) - - def post(self, url: str, **kwargs): - return self._store_request("POST", url=url, **kwargs) - - def put(self, url: str, **kwargs): - return self._store_request("PUT", url=url, **kwargs) - - def patch(self, url: str, **kwargs): - return self._store_request("PATCH", url=url, **kwargs) - - def delete(self, url: str, **kwargs): - return self._store_request("DELETE", url=url, **kwargs) - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - -def create_mock_context() -> RunContext: - """Create a mock RunContext for testing.""" - mock_session = MagicMock() - mock_speech_handle = MagicMock() - mock_speech_handle.num_steps = 1 - mock_function_call = MagicMock(spec=FunctionCall) - - context = RunContext( - session=mock_session, - speech_handle=mock_speech_handle, - function_call=mock_function_call, - ) - return context - - -def test_create_tool_validation(): - with pytest.raises(ValueError, match="URL is required"): - create_http_tool(HTTPToolConfig(url="")) - - with pytest.raises(ValueError, match="Unsupported HTTP method"): - create_http_tool(HTTPToolConfig(url="https://api.example.com", method="TRACE")) - - -@pytest.mark.parametrize( - "method", - ["GET", "POST", "PUT", "PATCH", "DELETE"], -) -async def test_http_methods(method: str): - config = HTTPToolConfig( - url="https://api.example.com/test", - method=method, - ) - tool = create_http_tool(config) - - mock_response = MockResponse(status=200, text="success") - mock_session = MockSession(response=mock_response) - mock_context = create_mock_context() - - with patch("aiohttp.ClientSession", return_value=mock_session): - result = await tool({}, mock_context) - - assert result == "success" - assert mock_session.request_kwargs["method"] == method - - -async def test_http_request_types(): - mock_context = create_mock_context() - mock_session = MockSession() - - with patch("aiohttp.ClientSession", return_value=mock_session): - # GET sends params - tool = create_http_tool(HTTPToolConfig(url="https://api.example.com", method="GET")) - await tool({"query": "test"}, mock_context) - assert "params" in mock_session.request_kwargs - - # POST sends json - tool = create_http_tool(HTTPToolConfig(url="https://api.example.com", method="POST")) - await tool({"name": "test"}, mock_context) - assert "json" in mock_session.request_kwargs - - -async def test_http_error_handling(): - mock_context = create_mock_context() - tool = create_http_tool(HTTPToolConfig(url="https://api.example.com")) - - mock_response = MockResponse(status=404, text="Not found") - mock_session = MockSession(response=mock_response) - - with patch("aiohttp.ClientSession", return_value=mock_session): - with pytest.raises(ToolError, match="HTTP 404"): - await tool({}, mock_context) - - -async def test_output_normalizer(): - mock_context = create_mock_context() - mock_session = MockSession() - - with patch("aiohttp.ClientSession", return_value=mock_session): - tool = create_http_tool( - HTTPToolConfig( - url="https://api.example.com", - output_normalizer=lambda resp: f"Status: {resp.status_code}", - ) - ) - result = await tool({}, mock_context) - assert result == "Status: 200" - - -# =========================== -# SMS Tool Tests -# =========================== def test_sms_provider_detection(): From 2886d2ff2bb5eb9c05c97da62ec590dcf8da1707 Mon Sep 17 00:00:00 2001 From: Nikita Date: Mon, 17 Nov 2025 18:07:45 -0800 Subject: [PATCH 3/5] update sms tools --- examples/voice_agents/sms_sender_agent.py | 66 +++ examples/voice_agents/weather_agent.py | 301 ----------- .../livekit/agents/beta/tools/sms/README.md | 21 +- .../livekit/agents/beta/tools/sms/__init__.py | 6 +- .../livekit/agents/beta/tools/sms/config.py | 38 ++ .../agents/beta/tools/sms/provider_utils.py | 249 ++++++++++ .../livekit/agents/beta/tools/sms/send_sms.py | 469 ++++-------------- tests/test_beta_tools.py | 84 ---- tests/test_sms_tools.py | 74 +++ 9 files changed, 543 insertions(+), 765 deletions(-) create mode 100644 examples/voice_agents/sms_sender_agent.py delete mode 100644 examples/voice_agents/weather_agent.py create mode 100644 livekit-agents/livekit/agents/beta/tools/sms/config.py create mode 100644 livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py delete mode 100644 tests/test_beta_tools.py create mode 100644 tests/test_sms_tools.py diff --git a/examples/voice_agents/sms_sender_agent.py b/examples/voice_agents/sms_sender_agent.py new file mode 100644 index 0000000000..2480c13a19 --- /dev/null +++ b/examples/voice_agents/sms_sender_agent.py @@ -0,0 +1,66 @@ +import logging + +from dotenv import load_dotenv + +from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli +from livekit.agents.beta.tools.sms import SMSToolConfig, create_sms_tool +from livekit.plugins import cartesia, deepgram, openai, silero + +logger = logging.getLogger("sms-agent") + +load_dotenv() + +RANDOM_MESSAGES = [ + "Here's your daily reminder that you're awesome!", + "Hope your day is going great, consider this a little boost!", + "Just popping in to say hi from your friendly bot", + "This is a quick systems check: all vibes appear positive.", +] + +sms_tool = create_sms_tool( + SMSToolConfig( + name="send_playful_sms", + description=( + "Send a playful SMS to the provided `to` phone number. " + "Always include the exact message text you want to deliver." + ), + auto_detect_caller=False, + execution_message="One moment while I send that SMS.", + ) +) + + +class SMSAgent(Agent): + def __init__(self) -> None: + instructions = ( + "Keep it minimal. Immediately greet the user and tell them you are ready to send a light-hearted SMS. " + "Ask once for their mobile number with country code. " + "Do not list options or chat idly — just confirm you are waiting for the number. " + "After they provide a valid number (7–15 digits), call send_playful_sms with that number as `to` and choose any one of these messages: " + f"{'; '.join(RANDOM_MESSAGES)} " + "Send the SMS silently and then simply say “Done. Check your phone.”" + ) + super().__init__(instructions=instructions, tools=[sms_tool]) + + async def on_enter(self): + await self.session.generate_reply(instructions="Warmly greet the user and ask for their mobile number") + + +server = AgentServer() + + +@server.rtc_session() +async def entrypoint(ctx: JobContext): + session = AgentSession( + stt="deepgram/nova-3", + llm="openai/gpt-4o-mini", + tts="cartesia/sonic-3:a167e0f3-df7e-4d52-a9c3-f949145efdab", + vad=silero.VAD.load(), + ) + + await session.start(agent=SMSAgent(), room=ctx.room) + await ctx.connect() + + +if __name__ == "__main__": + cli.run_app(server) diff --git a/examples/voice_agents/weather_agent.py b/examples/voice_agents/weather_agent.py deleted file mode 100644 index de00ccdc35..0000000000 --- a/examples/voice_agents/weather_agent.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -Weather Agent - Demonstrates HTTP tool usage with real APIs - -This agent can: -- Search for locations by name -- Get current weather conditions -- Provide 7-day weather forecasts - -Uses free Open-Meteo API (no API key required) -""" - -import logging - -from dotenv import load_dotenv - -from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli -from livekit.agents.beta.tools.http import HTTPToolConfig, create_http_tool -from livekit.agents.beta.tools.sms import SMSToolConfig, create_sms_tool -from livekit.plugins import silero - -logger = logging.getLogger("weather-agent") - -load_dotenv() - - -def _format_location_response(resp) -> str: - """Parse geocoding response and return formatted location info.""" - import json - - data = json.loads(resp.body) - - if not data.get("results"): - return "Sorry, I couldn't find that location." - - result = data["results"][0] - return json.dumps( - { - "name": result.get("name"), - "latitude": result.get("latitude"), - "longitude": result.get("longitude"), - "country": result.get("country"), - "timezone": result.get("timezone"), - } - ) - - -def _format_current_weather(resp) -> str: - """Parse current weather response into readable format.""" - import json - - data = json.loads(resp.body) - current = data.get("current", {}) - - # WMO Weather interpretation codes - weather_codes = { - 0: "Clear sky", - 1: "Mainly clear", - 2: "Partly cloudy", - 3: "Overcast", - 45: "Fog", - 48: "Depositing rime fog", - 51: "Light drizzle", - 53: "Moderate drizzle", - 55: "Dense drizzle", - 56: "Light freezing drizzle", - 57: "Dense freezing drizzle", - 61: "Slight rain", - 63: "Moderate rain", - 65: "Heavy rain", - 66: "Light freezing rain", - 67: "Heavy freezing rain", - 71: "Slight snow", - 73: "Moderate snow", - 75: "Heavy snow", - 77: "Snow grains", - 80: "Slight rain showers", - 81: "Moderate rain showers", - 82: "Violent rain showers", - 85: "Slight snow showers", - 86: "Heavy snow showers", - 95: "Thunderstorm", - 96: "Thunderstorm with slight hail", - 99: "Thunderstorm with heavy hail", - } - - weather_desc = weather_codes.get(current.get("weather_code", 0), "Unknown") - temp = current.get("temperature_2m") - feels_like = current.get("apparent_temperature") - humidity = current.get("relative_humidity_2m") - wind = current.get("wind_speed_10m") - - return ( - f"Current weather: {weather_desc}. " - f"Temperature is {temp}°C (feels like {feels_like}°C). " - f"Humidity at {humidity}%, wind speed {wind} km/h." - ) - - -def _format_forecast(resp) -> str: - """Parse forecast response into readable format.""" - import json - - data = json.loads(resp.body) - daily = data.get("daily", {}) - - # just show first 3 days to keep it concise - times = daily.get("time", [])[:3] - max_temps = daily.get("temperature_2m_max", [])[:3] - min_temps = daily.get("temperature_2m_min", [])[:3] - - forecast_text = "Here's the forecast: " - for i, date in enumerate(times): - forecast_text += f"{date}: High {max_temps[i]}°C, Low {min_temps[i]}°C. " - - return forecast_text - - -# Create HTTP tools for weather APIs -search_location_tool = create_http_tool( - HTTPToolConfig( - name="search_location", - description="Search for a location by name to get its coordinates. Always use this first when the user mentions a city name.", - url="https://geocoding-api.open-meteo.com/v1/search", - method="GET", - timeout_ms=5000, - parameters={ - "type": "object", - "required": ["name"], - "properties": { - "name": { - "type": "string", - "description": "City name to search for (e.g., 'New York', 'London', 'Tokyo')", - }, - "count": { - "type": "number", - "description": "Number of results to return. Default is 1 (best match).", - }, - "language": { - "type": "string", - "description": "Language for results (en, ru, etc). Default is 'en'.", - }, - }, - }, - output_normalizer=_format_location_response, - ) -) - - -get_current_weather_tool = create_http_tool( - HTTPToolConfig( - name="get_current_weather", - description="Get the current weather for a specific location. Returns temperature, humidity, apparent temperature, weather condition, and wind speed.", - url="https://api.open-meteo.com/v1/forecast", - method="GET", - timeout_ms=10000, - parameters={ - "type": "object", - "required": ["latitude", "longitude"], - "properties": { - "latitude": { - "type": "number", - "description": "Latitude coordinate (e.g., 40.7128 for New York, 51.5074 for London)", - }, - "longitude": { - "type": "number", - "description": "Longitude coordinate (e.g., -74.0060 for New York, -0.1278 for London)", - }, - "current": { - "type": "string", - "description": "Current weather variables to fetch", - }, - "timezone": { - "type": "string", - "description": "Timezone (use 'auto' to detect automatically)", - }, - "temperature_unit": { - "type": "string", - "description": "Temperature unit: 'celsius' or 'fahrenheit'", - "enum": ["celsius", "fahrenheit"], - }, - }, - }, - output_normalizer=_format_current_weather, - ) -) - - -get_weather_forecast_tool = create_http_tool( - HTTPToolConfig( - name="get_weather_forecast", - description="Get a 7-day weather forecast for a location. Returns daily high/low temperatures, weather conditions, precipitation, and wind speed.", - url="https://api.open-meteo.com/v1/forecast", - method="GET", - timeout_ms=15000, - parameters={ - "type": "object", - "required": ["latitude", "longitude"], - "properties": { - "latitude": { - "type": "number", - "description": "Latitude coordinate (e.g., 40.7128 for New York)", - }, - "longitude": { - "type": "number", - "description": "Longitude coordinate (e.g., -74.0060 for New York)", - }, - "daily": { - "type": "string", - "description": "Daily weather variables to fetch", - }, - "timezone": { - "type": "string", - "description": "Timezone (use 'auto' to detect automatically)", - }, - "temperature_unit": { - "type": "string", - "description": "Temperature unit: 'celsius' or 'fahrenheit'", - "enum": ["celsius", "fahrenheit"], - }, - "forecast_days": { - "type": "number", - "description": "Number of forecast days (1-16). Default is 7.", - }, - }, - }, - execution_message="One moment, fetching the weather forecast for you.", - output_normalizer=_format_forecast, - ) -) - - -# Create SMS tool (automatically sends to the caller) -# Only initialize if SMS provider credentials are available -# It will skip sending the SMS if no credentials are found -try: - send_weather_sms_tool = create_sms_tool( - SMSToolConfig( - name="send_weather_sms", - description="Send weather information via SMS to the caller. Use this when the user asks to receive weather info by text message.", - to_number="+15555555555", # PUT YOUR PHONE NUMBER HERE - ) - ) -except ValueError: - logger.warning("SMS tool not available: no SMS provider credentials found") - send_weather_sms_tool = None - - -class WeatherAgent(Agent): - def __init__(self) -> None: - tools = [ - search_location_tool, - get_current_weather_tool, - get_weather_forecast_tool, - ] - - instructions = ( - "You are a helpful weather assistant. When a user asks about weather:\n" - "1. First use search_location to get coordinates for the city\n" - "2. Then use get_current_weather or get_weather_forecast with those coordinates\n" - "3. Be friendly and conversational\n" - "4. Always mention the city name in your response\n" - ) - - if send_weather_sms_tool: - tools.append(send_weather_sms_tool) - instructions += "5. If the user asks to receive weather info by SMS/text, use send_weather_sms with a concise weather summary\n" - - instructions += ( - "Note: Open-Meteo API requires specific parameters - for current weather, pass " - "current='temperature_2m,relative_humidity_2m,apparent_temperature,weather_code,wind_speed_10m' " - "and timezone='auto'. For forecast, pass " - "daily='temperature_2m_max,temperature_2m_min,weather_code,precipitation_sum,wind_speed_10m_max' " - "and timezone='auto'." - ) - - super().__init__(instructions=instructions) - self._tools = tools - - async def on_enter(self): - await self.session.generate_reply( - instructions="say hello to the user in English and ask them what city they would like to know about" - ) - - -server = AgentServer() - - -@server.rtc_session() -async def entrypoint(ctx: JobContext): - session = AgentSession( - stt="deepgram/nova-3", - llm="openai/gpt-4o-mini", - tts="cartesia/sonic-3:a167e0f3-df7e-4d52-a9c3-f949145efdab", - vad=silero.VAD.load(), - ) - - await session.start(agent=WeatherAgent(), room=ctx.room) - - -if __name__ == "__main__": - cli.run_app(server) diff --git a/livekit-agents/livekit/agents/beta/tools/sms/README.md b/livekit-agents/livekit/agents/beta/tools/sms/README.md index 62980f7cee..da8ae5da26 100644 --- a/livekit-agents/livekit/agents/beta/tools/sms/README.md +++ b/livekit-agents/livekit/agents/beta/tools/sms/README.md @@ -9,16 +9,18 @@ Supported: Twilio, Vonage, SignalWire from livekit.agents import Agent from livekit.agents.beta.tools.sms import create_sms_tool, SMSToolConfig -# Default: auto-detects recipient from SIP participant +# Default: auto-detects the caller's number from the SIP participant # LLM only needs to provide the message text sms_tool = create_sms_tool() -# Custom: change tool name, description, recipient or sender +# Custom: change tool name, description, recipient, or sender sms_tool = create_sms_tool(SMSToolConfig( name="notify_customer", # Custom function name description="Send confirmation", # Custom function description to_number="+1234567890", # Send to specific number from_number="+0987654321", # Send from specific number + auto_detect_caller=False, # Require explicit "to" argument from the LLM + execution_message="Sending a confirmation text right now.", )) agent = Agent( @@ -27,6 +29,19 @@ agent = Agent( ) ``` +### Arguments surfaced to the LLM + +`create_sms_tool` keeps the schema intentionally tiny: + +- `message` — always required; this is the SMS body the LLM must supply. +- `to` — required **only** when you disable caller auto-detection _and_ don’t hardcode `to_number`. In every other configuration the tool determines the recipient on its own, so the LLM never sees or fills this field. + +With `auto_detect_caller=True` (default) the tool looks up the SIP participant identity and omits the `to` argument entirely. + +### Announcing executions + +Set `SMSToolConfig.execution_message` when you want the agent to speak a short disclaimer before dispatching an SMS. The message is sent through `context.session.generate_reply()` with interruptions disabled so the user hears it exactly once. + ## Environment Variables **Twilio:** @@ -53,3 +68,5 @@ VONAGE_API_KEY=your_api_key VONAGE_API_SECRET=your_api_secret VONAGE_PHONE_NUMBER=1234567890 ``` + +If you supply `from_number` directly in `SMSToolConfig` it overrides the values above. diff --git a/livekit-agents/livekit/agents/beta/tools/sms/__init__.py b/livekit-agents/livekit/agents/beta/tools/sms/__init__.py index a390026351..2968b6b500 100644 --- a/livekit-agents/livekit/agents/beta/tools/sms/__init__.py +++ b/livekit-agents/livekit/agents/beta/tools/sms/__init__.py @@ -3,10 +3,14 @@ Supports Twilio, SignalWire, and Vonage SMS providers. """ -from .send_sms import SMSResponse, SMSToolConfig, create_sms_tool +from .config import SMSResponse, SMSToolConfig, SMSToolRequest +from .provider_utils import run_sms_request +from .send_sms import create_sms_tool __all__ = [ "SMSToolConfig", + "SMSToolRequest", "SMSResponse", "create_sms_tool", + "run_sms_request", ] diff --git a/livekit-agents/livekit/agents/beta/tools/sms/config.py b/livekit-agents/livekit/agents/beta/tools/sms/config.py new file mode 100644 index 0000000000..04e3749017 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/sms/config.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + + +@dataclass +class SMSResponse: + """Structured response from SMS send operation.""" + + success: bool + message_id: str | None + to: str + error: str | None = None + + +@dataclass +class SMSToolRequest: + """Provider configuration passed to the SMS utility.""" + + provider_name: str + credentials: dict[str, str] + from_number: str + timeout_ms: int = 10000 + + +@dataclass +class SMSToolConfig: + """Configuration for SMS tool customization.""" + + name: str = "send_sms" + description: str | None = None + to_number: str | None = None + from_number: str | None = None + auto_detect_caller: bool = True + timeout_ms: int = 10000 + execution_message: str | None = None + output_normalizer: Callable[[SMSResponse], str | None] | None = None diff --git a/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py b/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py new file mode 100644 index 0000000000..b5f4add3a7 --- /dev/null +++ b/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py @@ -0,0 +1,249 @@ +from __future__ import annotations + +import asyncio +import json +import os +from dataclasses import dataclass +from typing import Literal + +import aiohttp + +from livekit import rtc + +from ....log import logger +from ....utils.http_context import http_session +from ....voice import RunContext +from .config import SMSResponse, SMSToolRequest + +SupportedProvider = Literal["twilio", "signalwire", "vonage"] + + +@dataclass +class ProviderInfo: + """Provider metadata detected from the environment.""" + + name: SupportedProvider + credentials: dict[str, str] + default_from_number: str | None + + +def detect_provider() -> ProviderInfo | None: + """Auto-detect SMS provider based on environment variables.""" + if os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"): + return ProviderInfo( + name="twilio", + credentials={ + "account_sid": os.getenv("TWILIO_ACCOUNT_SID", ""), + "auth_token": os.getenv("TWILIO_AUTH_TOKEN", ""), + }, + default_from_number=os.getenv("TWILIO_PHONE_NUMBER"), + ) + + if os.getenv("SIGNALWIRE_PROJECT_ID") and os.getenv("SIGNALWIRE_TOKEN"): + return ProviderInfo( + name="signalwire", + credentials={ + "project_id": os.getenv("SIGNALWIRE_PROJECT_ID", ""), + "token": os.getenv("SIGNALWIRE_TOKEN", ""), + "space_url": os.getenv("SIGNALWIRE_SPACE_URL", ""), + }, + default_from_number=os.getenv("SIGNALWIRE_PHONE_NUMBER"), + ) + + if os.getenv("VONAGE_API_KEY") and os.getenv("VONAGE_API_SECRET"): + return ProviderInfo( + name="vonage", + credentials={ + "api_key": os.getenv("VONAGE_API_KEY", ""), + "api_secret": os.getenv("VONAGE_API_SECRET", ""), + }, + default_from_number=os.getenv("VONAGE_PHONE_NUMBER"), + ) + + return None + + +async def run_sms_request( + request: SMSToolRequest, + to_number: str, + message: str, +) -> SMSResponse: + """Send SMS through the configured provider.""" + timeout = aiohttp.ClientTimeout(total=request.timeout_ms / 1000.0) + session = http_session() + + try: + if request.provider_name == "twilio": + return await _send_twilio(session, request, to_number, message, timeout) + if request.provider_name == "signalwire": + return await _send_signalwire(session, request, to_number, message, timeout) + if request.provider_name == "vonage": + return await _send_vonage(session, request, to_number, message, timeout) + raise ValueError(f"Unsupported provider: {request.provider_name}") + except asyncio.TimeoutError: + logger.warning( + "SMS request timeout", + extra={"provider": request.provider_name, "to": to_number}, + ) + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error="Request timeout", + ) + except aiohttp.ClientError as e: + logger.warning( + "SMS request client error", + extra={"provider": request.provider_name, "to": to_number, "error": str(e)}, + ) + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=str(e), + ) + except Exception as e: + logger.exception( + "Unexpected SMS request failure", + extra={"provider": request.provider_name}, + ) + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=str(e), + ) + + +async def _send_twilio( + session: aiohttp.ClientSession, + request: SMSToolRequest, + to_number: str, + message: str, + timeout: aiohttp.ClientTimeout, +) -> SMSResponse: + credentials = request.credentials + url = f"https://api.twilio.com/2010-04-01/Accounts/{credentials['account_sid']}/Messages.json" + auth = aiohttp.BasicAuth(credentials["account_sid"], credentials["auth_token"]) + payload = {"To": to_number, "From": request.from_number, "Body": message} + async with session.post(url, auth=auth, data=payload, timeout=timeout) as resp: + if resp.status in (200, 201): + result = await resp.json() + return SMSResponse(success=True, message_id=result.get("sid"), to=to_number) + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=f"HTTP {resp.status}: {await resp.text()}", + ) + + +async def _send_signalwire( + session: aiohttp.ClientSession, + request: SMSToolRequest, + to_number: str, + message: str, + timeout: aiohttp.ClientTimeout, +) -> SMSResponse: + credentials = request.credentials + space_url = credentials.get("space_url") + if not space_url: + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error="SIGNALWIRE_SPACE_URL not configured", + ) + + account_id = credentials["project_id"] + url = f"https://{space_url}/api/laml/2010-04-01/Accounts/{account_id}/Messages.json" + auth = aiohttp.BasicAuth(account_id, credentials["token"]) + payload = { + "To": to_number if to_number.startswith("+") else f"+{to_number}", + "From": request.from_number + if request.from_number.startswith("+") + else f"+{request.from_number}", + "Body": message, + } + + async with session.post(url, auth=auth, data=payload, timeout=timeout) as resp: + if resp.status in (200, 201): + result = await resp.json() + return SMSResponse(success=True, message_id=result.get("sid"), to=to_number) + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=f"HTTP {resp.status}: {await resp.text()}", + ) + + +async def _send_vonage( + session: aiohttp.ClientSession, + request: SMSToolRequest, + to_number: str, + message: str, + timeout: aiohttp.ClientTimeout, +) -> SMSResponse: + credentials = request.credentials + url = "https://rest.nexmo.com/sms/json" + payload = { + "api_key": credentials["api_key"], + "api_secret": credentials["api_secret"], + "to": to_number, + "from": request.from_number, + "text": message, + } + + async with session.post(url, json=payload, timeout=timeout) as resp: + body_text = await resp.text() + if resp.status not in (200, 201): + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error=f"HTTP {resp.status}: {body_text}", + ) + + try: + parsed = json.loads(body_text) + except json.JSONDecodeError: + return SMSResponse( + success=False, + message_id=None, + to=to_number, + error="Unable to parse Vonage response", + ) + + messages = parsed.get("messages", []) + if messages and messages[0].get("status") == "0": + return SMSResponse( + success=True, + message_id=messages[0].get("message-id"), + to=to_number, + ) + + error_text = messages[0].get("error-text", "Unknown error") if messages else "Unknown error" + return SMSResponse(success=False, message_id=None, to=to_number, error=error_text) + + +def get_caller_phone_number(context: RunContext) -> str | None: + """Extract caller phone number from SIP participant via RunContext.""" + try: + session = context.session + if session.room is None: + return None + for participant in session.room.remote_participants.values(): + if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP: + return participant.identity + except Exception: + return None + return None + + +def validate_phone_number(phone: str) -> bool: + """Validate phone number format (international and local formats supported).""" + if not phone or not isinstance(phone, str): + return False + digits_only = "".join(filter(str.isdigit, phone)) + return 7 <= len(digits_only) <= 15 diff --git a/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py index b36a17fe32..c8f4bfb1a7 100644 --- a/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py +++ b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py @@ -1,416 +1,131 @@ -import asyncio -import json -import os -from collections.abc import Callable -from dataclasses import dataclass - -import aiohttp +from __future__ import annotations -from livekit import rtc +import json +from typing import Any from .... import FunctionTool -from ....job import get_job_context from ....llm.tool_context import ToolError, function_tool +from ....log import logger +from ....voice import RunContext +from .config import SMSToolConfig, SMSToolRequest +from .provider_utils import detect_provider, get_caller_phone_number, run_sms_request, validate_phone_number -@dataclass -class SMSResponse: - """Structured response from SMS send operation. - - Attributes: - success: Whether the SMS was sent successfully - message_id: Provider's message ID (if available) - to: Recipient phone number - error: Error message (if failed) - """ - - success: bool - message_id: str | None - to: str - error: str | None = None - - -@dataclass -class SMSToolConfig: - """Configuration for SMS tool customization. - - Attributes: - name: Custom tool name (default: "send_sms") - description: Custom tool description - to_number: Recipient phone number (if set, disables auto_detect_caller) - from_number: Sender phone number (if not set, uses PROVIDER_PHONE_NUMBER env var) - auto_detect_caller: Auto-detect caller phone from SIP participant (default: True) - output_normalizer: Optional function to transform SMSResponse to string - """ - - name: str | None = None - description: str | None = None - to_number: str | None = None - from_number: str | None = None - auto_detect_caller: bool = True - output_normalizer: Callable[[SMSResponse], str] | None = None - - -def _detect_provider() -> tuple[str, dict] | None: - """Auto-detect SMS provider based on environment variables. - - Returns: - Tuple of (provider_name, credentials) or None if no provider configured - """ - if os.getenv("TWILIO_ACCOUNT_SID") and os.getenv("TWILIO_AUTH_TOKEN"): - return ( - "twilio", - { - "account_sid": os.getenv("TWILIO_ACCOUNT_SID"), - "auth_token": os.getenv("TWILIO_AUTH_TOKEN"), - "from": os.getenv("TWILIO_PHONE_NUMBER"), - }, - ) +def create_sms_tool(config: SMSToolConfig | None = None) -> FunctionTool: + """Create an SMS sending tool with auto-detected provider.""" + cfg = config or SMSToolConfig() - if os.getenv("SIGNALWIRE_PROJECT_ID") and os.getenv("SIGNALWIRE_TOKEN"): - return ( - "signalwire", - { - "project_id": os.getenv("SIGNALWIRE_PROJECT_ID"), - "token": os.getenv("SIGNALWIRE_TOKEN"), - "space_url": os.getenv("SIGNALWIRE_SPACE_URL"), - "from": os.getenv("SIGNALWIRE_PHONE_NUMBER"), - }, + provider = detect_provider() + if provider is None: + raise ValueError( + "No SMS provider credentials found. " + "Set environment variables for Twilio (TWILIO_*), SignalWire (SIGNALWIRE_*), " + "or Vonage (VONAGE_*) to enable SMS sending." ) + if not cfg.name: + raise ValueError("Tool name is required for SMS tool configuration.") - if os.getenv("VONAGE_API_KEY") and os.getenv("VONAGE_API_SECRET"): - return ( - "vonage", - { - "api_key": os.getenv("VONAGE_API_KEY"), - "api_secret": os.getenv("VONAGE_API_SECRET"), - "from": os.getenv("VONAGE_PHONE_NUMBER"), - }, + sender_number = cfg.from_number or provider.default_from_number + if not sender_number: + raise ValueError( + f"Sender phone number not configured. " + f"Set the {provider.name.upper()}_PHONE_NUMBER environment variable or provide from_number in SMSToolConfig." ) - return None - - -async def _get_caller_phone_number() -> str | None: - """Extract caller phone number from SIP participant. - - Returns: - Phone number from SIP participant identity or None if not available - """ - try: - job_ctx = get_job_context() - for participant in job_ctx.room.remote_participants.values(): - if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP: - return participant.identity - except Exception: - return None - - -def _validate_phone_number(phone: str) -> bool: - """Validate phone number format (international and local formats supported). - - Args: - phone: Phone number to validate + request = SMSToolRequest( + provider_name=provider.name, + credentials=provider.credentials, + from_number=sender_number, + timeout_ms=cfg.timeout_ms, + ) - Returns: - True if valid phone number (7-15 digits after cleaning) - """ - if not phone or not isinstance(phone, str): - return False + raw_schema = _build_schema(cfg) - digits_only = "".join(c for c in phone if c.isdigit()) - return 7 <= len(digits_only) <= 15 + async def sms_handler(raw_arguments: dict[str, Any], context: RunContext) -> str: + message = raw_arguments.get("message") + if not message: + raise ToolError("Message text is required.") + if cfg.to_number: + recipient = cfg.to_number + elif cfg.auto_detect_caller: + recipient = get_caller_phone_number(context) + else: + recipient = raw_arguments.get("to") -async def _send_sms_http( - provider_name: str, - url: str, - to_number: str, - message: str, - from_number: str, - *, - auth: aiohttp.BasicAuth | None = None, - json_body: dict | None = None, - form_body: dict | None = None, - parse_response: Callable[[dict, str], SMSResponse], -) -> SMSResponse: - """Generic HTTP SMS sender with unified error handling.""" - async with aiohttp.ClientSession() as session: - try: - kwargs = {"timeout": aiohttp.ClientTimeout(total=10)} - if auth: - kwargs["auth"] = auth - if json_body: - kwargs["json"] = json_body - if form_body: - kwargs["data"] = form_body + if not isinstance(recipient, str) or not recipient: + raise ToolError("Could not determine the recipient phone number.") - async with session.post(url, **kwargs) as resp: - if resp.status in (200, 201): - result = await resp.json() - return parse_response(result, to_number) - else: - error_text = await resp.text() - return SMSResponse( - success=False, - message_id=None, - to=to_number, - error=f"HTTP {resp.status}: {error_text}", - ) - except asyncio.TimeoutError: - return SMSResponse( - success=False, message_id=None, to=to_number, error="Request timeout" + if not validate_phone_number(recipient): + raise ToolError( + f"Invalid phone number format: {recipient}. Expected 7-15 digits (with optional symbols)." ) - except Exception as e: - return SMSResponse(success=False, message_id=None, to=to_number, error=str(e)) - -async def _send_twilio_sms( - credentials: dict, to_number: str, message: str, from_number: str -) -> SMSResponse: - """Send SMS via Twilio API.""" - url = f"https://api.twilio.com/2010-04-01/Accounts/{credentials['account_sid']}/Messages.json" - auth = aiohttp.BasicAuth(credentials["account_sid"], credentials["auth_token"]) - form_body = {"To": to_number, "From": from_number, "Body": message} + if cfg.execution_message: + try: + await context.session.generate_reply( + instructions=cfg.execution_message, allow_interruptions=False + ) + except Exception: # pragma: no cover - best effort announcement + logger.debug("Failed to deliver SMS execution message", exc_info=True) - def parse(result: dict, to: str) -> SMSResponse: - return SMSResponse(success=True, message_id=result.get("sid"), to=to) + context.disallow_interruptions() - return await _send_sms_http( - "Twilio", - url, - to_number, - message, - from_number, - auth=auth, - form_body=form_body, - parse_response=parse, - ) + response = await run_sms_request(request, recipient, message) + if not response.success: + raise ToolError(f"Failed to send SMS: {response.error}") + if cfg.output_normalizer: + normalized = cfg.output_normalizer(response) + if normalized is not None: + return normalized -async def _send_signalwire_sms( - credentials: dict, to_number: str, message: str, from_number: str -) -> SMSResponse: - """Send SMS via SignalWire API.""" - space_url = credentials.get("space_url") - if not space_url: - return SMSResponse( - success=False, - message_id=None, - to=to_number, - error="SIGNALWIRE_SPACE_URL not configured", + return json.dumps( + { + "success": True, + "message_id": response.message_id, + "to": response.to, + }, + indent=2, ) - url = f"https://{space_url}/api/laml/2010-04-01/Accounts/{credentials['project_id']}/Messages.json" - auth = aiohttp.BasicAuth(credentials["project_id"], credentials["token"]) - # SignalWire requires + prefix - form_body = { - "To": to_number if to_number.startswith("+") else f"+{to_number}", - "From": from_number if from_number.startswith("+") else f"+{from_number}", - "Body": message, - } - - def parse(result: dict, to: str) -> SMSResponse: - return SMSResponse(success=True, message_id=result.get("sid"), to=to) - - return await _send_sms_http( - "SignalWire", - url, - to_number, - message, - from_number, - auth=auth, - form_body=form_body, - parse_response=parse, - ) - - -async def _send_vonage_sms( - credentials: dict, to_number: str, message: str, from_number: str -) -> SMSResponse: - """Send SMS via Vonage (Nexmo) API.""" - url = "https://rest.nexmo.com/sms/json" - json_body = { - "api_key": credentials["api_key"], - "api_secret": credentials["api_secret"], - "to": to_number, - "from": from_number, - "text": message, - } - - def parse(result: dict, to: str) -> SMSResponse: - messages = result.get("messages", []) - if messages and messages[0].get("status") == "0": - return SMSResponse(success=True, message_id=messages[0].get("message-id"), to=to) - error = messages[0].get("error-text", "Unknown error") if messages else "Unknown error" - return SMSResponse(success=False, message_id=None, to=to, error=error) - - return await _send_sms_http( - "Vonage", url, to_number, message, from_number, json_body=json_body, parse_response=parse + return function_tool( + sms_handler, + raw_schema=raw_schema, ) -def create_sms_tool(config: SMSToolConfig | None = None) -> FunctionTool: - """Create an SMS sending tool with auto-detected provider. - - The tool automatically detects which SMS provider to use based on - environment variables. Supports Twilio, SignalWire, and Vonage. - - Environment Variables: - Twilio: - - TWILIO_ACCOUNT_SID - - TWILIO_AUTH_TOKEN - - TWILIO_PHONE_NUMBER (optional, can be set in config) - - SignalWire: - - SIGNALWIRE_PROJECT_ID - - SIGNALWIRE_TOKEN - - SIGNALWIRE_SPACE_URL - - SIGNALWIRE_PHONE_NUMBER (optional, can be set in config) - - Vonage: - - VONAGE_API_KEY - - VONAGE_API_SECRET - - VONAGE_PHONE_NUMBER (optional, can be set in config) - - Args: - config: Optional configuration to customize tool behavior - - Returns: - FunctionTool that can be used in Agent.tools - - Example: - ```python - from livekit.agents.beta.tools.sms import create_sms_tool, SMSToolConfig - - # Basic usage - auto-detects provider - sms_tool = create_sms_tool() - - # With customization - config = SMSToolConfig( - name="notify_customer", - description="Send confirmation SMS to customer", - auto_detect_caller=True, - ) - sms_tool = create_sms_tool(config) - - # Use in agent - agent = Agent( - instructions="You are a helpful assistant", - tools=[sms_tool] - ) - ``` - """ - config = config or SMSToolConfig() - - provider_info = _detect_provider() - if not provider_info: - raise ValueError( - "No SMS provider credentials found in environment variables. " - "Set TWILIO_*, SIGNALWIRE_*, or VONAGE_* env vars to enable SMS. " - "Required variables:\n" - " Twilio: TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN\n" - " SignalWire: SIGNALWIRE_PROJECT_ID, SIGNALWIRE_TOKEN, SIGNALWIRE_SPACE_URL\n" - " Vonage: VONAGE_API_KEY, VONAGE_API_SECRET" - ) - - provider_name, credentials = provider_info - - tool_name = config.name or "send_sms" - tool_description = ( - config.description - or f"Send SMS message via {provider_name}. Automatically sends to the caller if no recipient specified." - ) - - parameters_properties = { - "message": {"type": "string", "description": "The text message to send"} +def _build_schema(config: SMSToolConfig) -> dict[str, Any]: + properties: dict[str, Any] = { + "message": { + "type": "string", + "description": "The text message to send to the user.", + } } + required = ["message"] - required_params = ["message"] - - # Add 'to' parameter only if we can't determine recipient automatically if not config.to_number and not config.auto_detect_caller: - parameters_properties["to"] = { + logger.debug( + "SMS tool exposes `to` parameter because no recipient is configured; " + "ensure the LLM is trusted before enabling this mode." + ) + properties["to"] = { "type": "string", - "description": "Recipient phone number (international or local format, e.g., +1234567890 or 1234567890)", + "description": "Recipient phone number (e.g., +1234567890) in E.164 format", } - required_params.append("to") + required.append("to") - raw_schema = { - "name": tool_name, - "description": tool_description, + schema: dict[str, Any] = { + "name": config.name, + "description": config.description, "parameters": { "type": "object", - "properties": parameters_properties, - "required": required_params, + "properties": properties, + "required": required, }, } - # Create the actual SMS sending function with raw_arguments - async def send_sms(raw_arguments: dict) -> str: - """Send SMS message via configured provider.""" - message = raw_arguments.get("message") - - if not message: - raise ToolError("Message text is required") - - if config.to_number: - recipient = config.to_number - elif config.auto_detect_caller: - recipient = await _get_caller_phone_number() - if not recipient: - raise ToolError("Could not auto-detect caller phone number from SIP participant") - else: - recipient = raw_arguments.get("to") - if not recipient: - raise ToolError("Recipient phone number is required") - - # Validate phone number - if not _validate_phone_number(recipient): - raise ToolError( - f"Invalid phone number format: {recipient}. Phone number must contain 7-15 digits" - ) + if not config.description: + schema.pop("description") - sender_number = config.from_number or credentials.get("from") - if not sender_number: - raise ToolError( - f"Sender phone number not configured. Set {provider_name.upper()}_PHONE_NUMBER " - "environment variable or provide from_number in config" - ) - - try: - if provider_name == "twilio": - response = await _send_twilio_sms(credentials, recipient, message, sender_number) - elif provider_name == "signalwire": - response = await _send_signalwire_sms( - credentials, recipient, message, sender_number - ) - elif provider_name == "vonage": - response = await _send_vonage_sms(credentials, recipient, message, sender_number) - else: - raise ToolError(f"Unknown provider: {provider_name}") - - if config.output_normalizer: - return config.output_normalizer(response) - - if response.success: - return json.dumps( - { - "success": True, - "message_id": response.message_id, - "to": recipient, - }, - indent=2, - ) - else: - raise ToolError(f"Failed to send SMS: {response.error}") - - except ToolError: - raise - except Exception as e: - raise ToolError(f"Failed to send SMS: {str(e)}") from e - - return function_tool( - send_sms, - raw_schema=raw_schema, - ) + return schema diff --git a/tests/test_beta_tools.py b/tests/test_beta_tools.py deleted file mode 100644 index 9c0de6f8ba..0000000000 --- a/tests/test_beta_tools.py +++ /dev/null @@ -1,84 +0,0 @@ -from __future__ import annotations - -from unittest.mock import patch - -import pytest - -from livekit.agents.beta.tools.sms import ( - SMSToolConfig, - create_sms_tool, -) -from livekit.agents.llm.tool_context import ToolError - - -def test_sms_provider_detection(): - with patch.dict("os.environ", {}, clear=True): - with pytest.raises(ValueError, match="No SMS provider credentials"): - create_sms_tool() - - -class MockSMSResponse: - def __init__(self, status: int = 200, json_data: dict | None = None): - self.status = status - self._json_data = json_data or {} - - async def json(self): - return self._json_data - - async def text(self): - return "error" - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - -class MockSMSSession: - def __init__(self, response: MockSMSResponse | None = None): - self.response = response or MockSMSResponse() - - def post(self, url: str, **kwargs): - return self.response - - async def __aenter__(self): - return self - - async def __aexit__(self, *args): - pass - - -async def test_send_sms_success(): - env_vars = { - "TWILIO_ACCOUNT_SID": "test_sid", - "TWILIO_AUTH_TOKEN": "test_token", - "TWILIO_PHONE_NUMBER": "+11234567890", - } - config = SMSToolConfig(to_number="+10987654321") - mock_response = MockSMSResponse(status=200, json_data={"sid": "SM123"}) - mock_session = MockSMSSession(response=mock_response) - - with patch.dict("os.environ", env_vars): - tool = create_sms_tool(config) - with patch("aiohttp.ClientSession", return_value=mock_session): - result = await tool({"message": "Test"}) - assert "success" in result - - -async def test_send_sms_validation_errors(): - env_vars = { - "TWILIO_ACCOUNT_SID": "test_sid", - "TWILIO_AUTH_TOKEN": "test_token", - "TWILIO_PHONE_NUMBER": "+11234567890", - } - - with patch.dict("os.environ", env_vars): - tool = create_sms_tool(SMSToolConfig(to_number="+10987654321")) - - with pytest.raises(ToolError, match="Message text is required"): - await tool({}) - - tool = create_sms_tool(SMSToolConfig(to_number="invalid")) - with pytest.raises(ToolError, match="Invalid phone number format"): - await tool({"message": "Test"}) diff --git a/tests/test_sms_tools.py b/tests/test_sms_tools.py new file mode 100644 index 0000000000..4c45b99ddd --- /dev/null +++ b/tests/test_sms_tools.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from livekit.agents.beta.tools.sms import SMSToolConfig, create_sms_tool +from livekit.agents.beta.tools.sms.config import SMSResponse +from livekit.agents.llm import FunctionCall +from livekit.agents.llm.tool_context import ToolError +from livekit.agents.voice import RunContext + + +def create_mock_context() -> RunContext: + mock_session = MagicMock() + mock_session.generate_reply = AsyncMock() + mock_session.room = MagicMock(remote_participants={}) + + mock_speech_handle = MagicMock() + mock_speech_handle.disallow_interruptions = MagicMock() + mock_function_call = MagicMock(spec=FunctionCall) + + context = RunContext( + session=mock_session, + speech_handle=mock_speech_handle, + function_call=mock_function_call, + ) + context.disallow_interruptions = MagicMock() + return context + + +def test_sms_provider_detection(): + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(ValueError, match="No SMS provider credentials"): + create_sms_tool() + + +async def test_send_sms_success(): + env_vars = { + "TWILIO_ACCOUNT_SID": "test_sid", + "TWILIO_AUTH_TOKEN": "test_token", + "TWILIO_PHONE_NUMBER": "+11234567890", + } + mock_context = create_mock_context() + + with patch.dict("os.environ", env_vars): + tool = create_sms_tool(SMSToolConfig(to_number="+10987654321")) + + with patch( + "livekit.agents.beta.tools.sms.send_sms.run_sms_request", + return_value=SMSResponse(success=True, message_id="SM123", to="+10987654321"), + ): + result = await tool({"message": "Test"}, mock_context) + + assert "success" in result + + +async def test_send_sms_validation_errors(): + env_vars = { + "TWILIO_ACCOUNT_SID": "test_sid", + "TWILIO_AUTH_TOKEN": "test_token", + "TWILIO_PHONE_NUMBER": "+11234567890", + } + mock_context = create_mock_context() + + with patch.dict("os.environ", env_vars): + tool = create_sms_tool(SMSToolConfig(to_number="+10987654321")) + + with pytest.raises(ToolError, match="Message text is required"): + await tool({}, mock_context) + + tool = create_sms_tool(SMSToolConfig(to_number="invalid")) + with pytest.raises(ToolError, match="Invalid phone number format"): + await tool({"message": "Test"}, mock_context) From 21db6a6794265a32c38c532d1164322685ec7cb1 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 18 Nov 2025 10:29:09 -0800 Subject: [PATCH 4/5] cleanup branch, fix number parsing --- examples/voice_agents/sms_sender_agent.py | 46 +++++++++++++------ .../agents/beta/tools/sms/provider_utils.py | 12 ++--- .../livekit/agents/beta/tools/sms/send_sms.py | 13 ++++-- 3 files changed, 46 insertions(+), 25 deletions(-) diff --git a/examples/voice_agents/sms_sender_agent.py b/examples/voice_agents/sms_sender_agent.py index 2480c13a19..1a08e82de4 100644 --- a/examples/voice_agents/sms_sender_agent.py +++ b/examples/voice_agents/sms_sender_agent.py @@ -4,7 +4,7 @@ from livekit.agents import Agent, AgentServer, AgentSession, JobContext, cli from livekit.agents.beta.tools.sms import SMSToolConfig, create_sms_tool -from livekit.plugins import cartesia, deepgram, openai, silero +from livekit.plugins import silero logger = logging.getLogger("sms-agent") @@ -17,39 +17,55 @@ "This is a quick systems check: all vibes appear positive.", ] +IS_SIP_SESSION = True # Enables auto-detection of caller's number in SIP session +# Disable it for non-SIP sessions (e.g. console runs) + sms_tool = create_sms_tool( SMSToolConfig( name="send_playful_sms", description=( - "Send a playful SMS to the provided `to` phone number. " + IS_SIP_SESSION and "Send a playful SMS to the caller's phone number. " + or "Send a playful SMS to the provided `to` phone number. " "Always include the exact message text you want to deliver." ), - auto_detect_caller=False, + auto_detect_caller=IS_SIP_SESSION, execution_message="One moment while I send that SMS.", ) ) +instructions = ( + "Keep it minimal. Immediately greet the user and tell them you are ready to send a light-hearted SMS. " + "Ask once for their mobile number with country code. " + "Do not list options or chat idly — just confirm you are waiting for the number. " + "After they provide a valid number (7-15 digits), call send_playful_sms with that number as `to` and choose any one of these messages: " + f"{'; '.join(RANDOM_MESSAGES)} " + "Send the SMS silently and then simply say “Done. Check your phone.”" +) + +if IS_SIP_SESSION: + instructions = ( + "Keep it minimal. Immediately greet the user and tell them you are ready to send a light-hearted SMS. " + "Ask to choose a message to send" + f"{'; '.join(RANDOM_MESSAGES)} " + "Send the SMS silently and then simply say “Done. Check your phone.”" + ) + class SMSAgent(Agent): def __init__(self) -> None: - instructions = ( - "Keep it minimal. Immediately greet the user and tell them you are ready to send a light-hearted SMS. " - "Ask once for their mobile number with country code. " - "Do not list options or chat idly — just confirm you are waiting for the number. " - "After they provide a valid number (7–15 digits), call send_playful_sms with that number as `to` and choose any one of these messages: " - f"{'; '.join(RANDOM_MESSAGES)} " - "Send the SMS silently and then simply say “Done. Check your phone.”" - ) super().__init__(instructions=instructions, tools=[sms_tool]) async def on_enter(self): - await self.session.generate_reply(instructions="Warmly greet the user and ask for their mobile number") - + await self.session.generate_reply( + instructions= + IS_SIP_SESSION + and "Warmly greet the user and ask to choose a message to send" + or "Warmly greet the user and ask for their mobile number (skip if you don't have to field in sms tool)" + ) server = AgentServer() - -@server.rtc_session() +@server.rtc_session(agent_name="sms-sender") async def entrypoint(ctx: JobContext): session = AgentSession( stt="deepgram/nova-3", diff --git a/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py b/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py index b5f4add3a7..1c866d47b6 100644 --- a/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py +++ b/livekit-agents/livekit/agents/beta/tools/sms/provider_utils.py @@ -10,6 +10,7 @@ from livekit import rtc +from ....job import get_job_context from ....log import logger from ....utils.http_context import http_session from ....voice import RunContext @@ -228,14 +229,13 @@ async def _send_vonage( def get_caller_phone_number(context: RunContext) -> str | None: - """Extract caller phone number from SIP participant via RunContext.""" + """Extract caller phone number from SIP participant via JobContext.""" try: - session = context.session - if session.room is None: - return None - for participant in session.room.remote_participants.values(): + job_ctx = get_job_context() + for participant in job_ctx.room.remote_participants.values(): if participant.kind == rtc.ParticipantKind.PARTICIPANT_KIND_SIP: - return participant.identity + # remove sip_ prefix also from the identity + return participant.identity.replace("sip_", "") except Exception: return None return None diff --git a/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py index c8f4bfb1a7..62960de22a 100644 --- a/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py +++ b/livekit-agents/livekit/agents/beta/tools/sms/send_sms.py @@ -3,15 +3,19 @@ import json from typing import Any -from .... import FunctionTool -from ....llm.tool_context import ToolError, function_tool +from ....llm.tool_context import RawFunctionTool, ToolError, function_tool from ....log import logger from ....voice import RunContext from .config import SMSToolConfig, SMSToolRequest -from .provider_utils import detect_provider, get_caller_phone_number, run_sms_request, validate_phone_number +from .provider_utils import ( + detect_provider, + get_caller_phone_number, + run_sms_request, + validate_phone_number, +) -def create_sms_tool(config: SMSToolConfig | None = None) -> FunctionTool: +def create_sms_tool(config: SMSToolConfig | None = None) -> RawFunctionTool: """Create an SMS sending tool with auto-detected provider.""" cfg = config or SMSToolConfig() @@ -46,6 +50,7 @@ async def sms_handler(raw_arguments: dict[str, Any], context: RunContext) -> str if not message: raise ToolError("Message text is required.") + recipient: str | None if cfg.to_number: recipient = cfg.to_number elif cfg.auto_detect_caller: From 947c9c3fa96eb010f7357cc2b05e49316d52e216 Mon Sep 17 00:00:00 2001 From: Nikita Date: Tue, 18 Nov 2025 10:36:35 -0800 Subject: [PATCH 5/5] fix format --- examples/voice_agents/sms_sender_agent.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/examples/voice_agents/sms_sender_agent.py b/examples/voice_agents/sms_sender_agent.py index 1a08e82de4..6dabfd26c9 100644 --- a/examples/voice_agents/sms_sender_agent.py +++ b/examples/voice_agents/sms_sender_agent.py @@ -17,14 +17,15 @@ "This is a quick systems check: all vibes appear positive.", ] -IS_SIP_SESSION = True # Enables auto-detection of caller's number in SIP session +IS_SIP_SESSION = True # Enables auto-detection of caller's number in SIP session # Disable it for non-SIP sessions (e.g. console runs) sms_tool = create_sms_tool( SMSToolConfig( name="send_playful_sms", description=( - IS_SIP_SESSION and "Send a playful SMS to the caller's phone number. " + IS_SIP_SESSION + and "Send a playful SMS to the caller's phone number. " or "Send a playful SMS to the provided `to` phone number. " "Always include the exact message text you want to deliver." ), @@ -57,14 +58,15 @@ def __init__(self) -> None: async def on_enter(self): await self.session.generate_reply( - instructions= - IS_SIP_SESSION - and "Warmly greet the user and ask to choose a message to send" - or "Warmly greet the user and ask for their mobile number (skip if you don't have to field in sms tool)" - ) + instructions=IS_SIP_SESSION + and "Warmly greet the user and ask to choose a message to send" + or "Warmly greet the user and ask for their mobile number (skip if you don't have to field in sms tool)" + ) + server = AgentServer() + @server.rtc_session(agent_name="sms-sender") async def entrypoint(ctx: JobContext): session = AgentSession(