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:
Mohamed Boudra
2025-10-14 17:46:29 +02:00
parent f3b8cc689d
commit 1bceb3cb4b
8 changed files with 1865 additions and 136 deletions
+110 -12
View File
@@ -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