```
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> ```
This commit is contained in:
+110
-12
@@ -6,7 +6,7 @@ Migrated from Node.js version with added MCP integration
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
from typing import Any, AsyncIterable, Coroutine
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from livekit import rtc
|
||||
@@ -21,6 +21,10 @@ from livekit.agents import (
|
||||
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()
|
||||
@@ -36,14 +40,93 @@ def load_system_prompt() -> str:
|
||||
SYSTEM_PROMPT = load_system_prompt()
|
||||
|
||||
|
||||
async def entrypoint(ctx: JobContext):
|
||||
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 = []
|
||||
mcp_servers: list[MCPServerHTTP] = []
|
||||
if mcp_server_url:
|
||||
print(f"✓ MCP Server configured: {mcp_server_url}")
|
||||
server = MCPServerHTTP(
|
||||
@@ -58,21 +141,36 @@ async def entrypoint(ctx: JobContext):
|
||||
await ctx.connect(auto_subscribe=AutoSubscribe.AUDIO_ONLY)
|
||||
|
||||
# Create the voice agent with MCP tools
|
||||
agent = voice.Agent(
|
||||
agent = TimedAgent(
|
||||
instructions=SYSTEM_PROMPT,
|
||||
mcp_servers=mcp_servers, # Native MCP support!
|
||||
)
|
||||
|
||||
# Create the agent session with LiveKit Inference
|
||||
# Using same configuration as Node.js version
|
||||
session = voice.AgentSession(
|
||||
stt="assemblyai/universal-streaming:en",
|
||||
llm="openai/gpt-4.1-mini",
|
||||
tts= inference.TTS(
|
||||
model="elevenlabs/eleven_turbo_v2_5",
|
||||
voice="Xb7hH8MSUJpSbSDYk0k2",
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user