mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
feat: refactor MCP tools to use terminal names instead of IDs - Replace terminalId parameter with terminalName across all tools - Add window name uniqueness validation for create/rename operations - Implement terminal name-to-window resolution in all MCP tools - Add home directory tilde expansion in working directory paths - Update terminal resources to use encoded names in URIs - Remove terminal IDs from API responses (list-terminals, resources) - Add type checking and error handling for Python agent 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> ```
190 lines
6.6 KiB
Python
190 lines
6.6 KiB
Python
"""
|
|
LiveKit Voice Agent with MCP Support (Python)
|
|
Migrated from Node.js version with added MCP integration
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any, AsyncIterable, Coroutine
|
|
|
|
from dotenv import load_dotenv
|
|
from livekit import rtc
|
|
from livekit.agents import (
|
|
AutoSubscribe,
|
|
JobContext,
|
|
JobProcess,
|
|
WorkerOptions,
|
|
cli,
|
|
llm,
|
|
voice,
|
|
inference,
|
|
)
|
|
from livekit.agents.llm.mcp import MCPServerHTTP
|
|
from livekit.agents.llm.tool_context import FunctionTool, RawFunctionTool
|
|
from livekit.agents.voice import ModelSettings
|
|
from livekit.plugins import anthropic, openai, silero
|
|
from livekit.plugins.turn_detector.english import EnglishModel
|
|
|
|
# Load environment variables
|
|
load_dotenv()
|
|
|
|
|
|
def load_system_prompt() -> str:
|
|
"""Load system prompt from agent-prompt.md file."""
|
|
prompt_path = Path(__file__).parent / "agent-prompt.md"
|
|
return prompt_path.read_text()
|
|
|
|
|
|
# Load system prompt from external file for easier editing
|
|
SYSTEM_PROMPT = load_system_prompt()
|
|
|
|
|
|
class TimedAgent(voice.Agent):
|
|
"""Agent that waits for TTS to complete before executing tool calls."""
|
|
|
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
super().__init__(*args, **kwargs)
|
|
self._current_speech_handle: Any = None
|
|
|
|
async def llm_node(
|
|
self,
|
|
chat_ctx: llm.ChatContext,
|
|
tools: list[FunctionTool | RawFunctionTool],
|
|
model_settings: ModelSettings,
|
|
) -> AsyncIterable[llm.ChatChunk | str]:
|
|
"""Override llm_node to buffer tool calls until after text."""
|
|
tool_chunk_buffer: list[llm.ChatChunk] = []
|
|
text_chunks: list[llm.ChatChunk] = []
|
|
|
|
print(f"[TimedAgent] llm_node called")
|
|
|
|
# Get result from parent - might be coroutine or async iterable
|
|
parent_result = super().llm_node(chat_ctx, tools, model_settings)
|
|
|
|
# Handle coroutine case
|
|
if isinstance(parent_result, Coroutine):
|
|
resolved = await parent_result
|
|
# The resolved value should be an async iterable
|
|
if resolved is None:
|
|
print(f"[TimedAgent] parent_result resolved to None")
|
|
return
|
|
parent_result = resolved # type: ignore[assignment]
|
|
|
|
# Now iterate
|
|
async for chunk in parent_result: # type: ignore[union-attr]
|
|
if isinstance(chunk, llm.ChatChunk) and chunk.delta:
|
|
# Collect text chunks
|
|
if chunk.delta.content:
|
|
print(f"[TimedAgent] Text chunk: {chunk.delta.content[:50]}...")
|
|
text_chunks.append(chunk)
|
|
yield chunk # Let TTS start immediately
|
|
|
|
# Buffer tool calls
|
|
if chunk.delta.tool_calls:
|
|
print(f"[TimedAgent] Tool call chunk: {chunk.delta.tool_calls}")
|
|
tool_chunk_buffer.append(chunk)
|
|
else:
|
|
print(f"[TimedAgent] Other chunk type: {type(chunk)}")
|
|
yield chunk
|
|
|
|
print(f"[TimedAgent] Done iterating. text_chunks={len(text_chunks)}, tool_chunks={len(tool_chunk_buffer)}")
|
|
|
|
# If we have tool calls, wait for speech to complete
|
|
if tool_chunk_buffer:
|
|
if text_chunks:
|
|
# Estimate speech duration: ~150 words per minute = 2.5 words/sec = 0.4 sec/word
|
|
total_words = sum(
|
|
len(chunk.delta.content.split())
|
|
for chunk in text_chunks
|
|
if chunk.delta and chunk.delta.content
|
|
)
|
|
# Add extra buffer time for TTS processing and network
|
|
estimated_duration = (total_words * 0.4) + 1.0
|
|
print(f"[TimedAgent] Waiting {estimated_duration}s for {total_words} words to be spoken")
|
|
await asyncio.sleep(estimated_duration)
|
|
else:
|
|
# No text but we have tool calls - wait a default amount
|
|
print(f"[TimedAgent] No text chunks but have tool calls - waiting 2s default")
|
|
await asyncio.sleep(2.0)
|
|
|
|
# Now yield the tool calls
|
|
print(f"[TimedAgent] Yielding {len(tool_chunk_buffer)} tool calls")
|
|
for tool_chunk in tool_chunk_buffer:
|
|
yield tool_chunk
|
|
|
|
|
|
async def entrypoint(ctx: JobContext) -> None:
|
|
"""Main entry point for the voice agent."""
|
|
|
|
# Get API keys from environment
|
|
openrouter_api_key = os.getenv("OPENROUTER_API_KEY")
|
|
if not openrouter_api_key:
|
|
raise ValueError("OPENROUTER_API_KEY environment variable is required")
|
|
|
|
# Get MCP server URL from environment
|
|
mcp_server_url = os.getenv("MCP_SERVER_URL")
|
|
|
|
# Prepare MCP servers list
|
|
mcp_servers: list[MCPServerHTTP] = []
|
|
if mcp_server_url:
|
|
print(f"✓ MCP Server configured: {mcp_server_url}")
|
|
server = MCPServerHTTP(
|
|
url=mcp_server_url,
|
|
timeout=10
|
|
)
|
|
mcp_servers.append(server)
|
|
else:
|
|
print("⚠ No MCP_SERVER_URL found in environment")
|
|
|
|
# Connect to the room
|
|
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
|
|
|
# Create the voice agent with MCP tools
|
|
agent = TimedAgent(
|
|
instructions=SYSTEM_PROMPT,
|
|
mcp_servers=mcp_servers, # Native MCP support!
|
|
)
|
|
|
|
# Create the agent session with BYOK (Bring Your Own Key)
|
|
# Using Claude via OpenRouter for redundancy against downtime
|
|
session: Any = voice.AgentSession(
|
|
stt=openai.STT(
|
|
model="gpt-4o-transcribe",
|
|
),
|
|
vad=silero.VAD.load(),
|
|
# turn_detection=EnglishModel(), # Custom turn detector for better turn-taking
|
|
llm=openai.LLM(
|
|
model="anthropic/claude-sonnet-4.5",
|
|
base_url="https://openrouter.ai/api/v1",
|
|
api_key=openrouter_api_key,
|
|
),
|
|
tts=inference.TTS(
|
|
model="elevenlabs/eleven_turbo_v2_5",
|
|
voice="Xb7hH8MSUJpSbSDYk0k2",
|
|
language="en"
|
|
),
|
|
# Tool execution limits
|
|
max_tool_steps=10, # Max consecutive tool calls per turn (default: 3)
|
|
# Interruption configuration
|
|
allow_interruptions=True, # Allow user to interrupt agent mid-speech
|
|
min_interruption_words=1, # Require at least 1 word to avoid false interruptions
|
|
# Generation configuration
|
|
preemptive_generation=True, # Disable preemptive generation for more accurate responses
|
|
)
|
|
|
|
# Start the session
|
|
await session.start(agent=agent, room=ctx.room)
|
|
|
|
print(f"✓ Agent started successfully in room: {ctx.room.name}")
|
|
print(f"✓ MCP servers: {len(mcp_servers)} configured")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run the agent worker
|
|
cli.run_app(
|
|
WorkerOptions(
|
|
entrypoint_fnc=entrypoint,
|
|
)
|
|
)
|