Files
zeekay 2d1bdd997f refactor: cleanup and consolidate tool packages
- Delete deprecated files (agent_tool_v1_deprecated.py, swarm_tool_v1_deprecated.py)
- Consolidate duplicate LLM tools (merge llm_tool.py into llm_unified.py)
- Rename agent CriticTool to CodeReviewTool to avoid conflict with reasoning critic
- Fix stub classes in swarm_tool.py to raise ImportError instead of silent pass
- Update CLAUDE.md with accurate tool counts (62 tools across 17 packages)
2025-12-26 05:05:41 -08:00

875 lines
28 KiB
Python

"""Unified LLM tool with multiple actions including consensus mode."""
import os
import json
import asyncio
from typing import (
Any,
Dict,
List,
Unpack,
Optional,
Annotated,
TypedDict,
final,
override,
)
from pathlib import Path
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_tools.core import BaseTool, ToolContext, auto_timeout, create_tool_context
# Check if litellm is available
try:
import litellm
LITELLM_AVAILABLE = True
except ImportError:
LITELLM_AVAILABLE = False
# Parameter types
Action = Annotated[
str,
Field(
description="Action to perform: query, consensus, list, models, enable, disable, test",
default="query",
),
]
Model = Annotated[
Optional[str],
Field(
description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
default=None,
),
]
Models = Annotated[
Optional[List[str]],
Field(
description="List of models for consensus mode",
default=None,
),
]
Prompt = Annotated[
Optional[str],
Field(
description="The prompt to send to the LLM",
default=None,
),
]
SystemPrompt = Annotated[
Optional[str],
Field(
description="System prompt to set context",
default=None,
),
]
Temperature = Annotated[
float,
Field(
description="Temperature for response randomness (0-2)",
default=0.7,
),
]
MaxTokens = Annotated[
Optional[int],
Field(
description="Maximum tokens in response",
default=None,
),
]
JsonMode = Annotated[
bool,
Field(
description="Request JSON formatted response",
default=False,
),
]
Stream = Annotated[
bool,
Field(
description="Stream the response",
default=False,
),
]
Provider = Annotated[
Optional[str],
Field(
description="Provider name for list/enable/disable actions",
default=None,
),
]
IncludeRaw = Annotated[
bool,
Field(
description="Include raw responses in consensus mode",
default=False,
),
]
JudgeModel = Annotated[
Optional[str],
Field(
description="Model to use as judge/aggregator in consensus",
default=None,
),
]
DevilsAdvocate = Annotated[
bool,
Field(
description="Enable devil's advocate mode (10th model critiques others)",
default=False,
),
]
ConsensusSize = Annotated[
Optional[int],
Field(
description="Number of models to use in consensus (default: 3)",
default=None,
),
]
class LLMParams(TypedDict, total=False):
"""Parameters for LLM tool."""
action: str
model: Optional[str]
models: Optional[List[str]]
prompt: Optional[str]
system_prompt: Optional[str]
temperature: float
max_tokens: Optional[int]
json_mode: bool
stream: bool
provider: Optional[str]
include_raw: bool
judge_model: Optional[str]
devils_advocate: bool
consensus_size: Optional[int]
@final
class UnifiedLLMTool(BaseTool):
"""Unified LLM tool with multiple actions."""
# Config file for settings
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
# Default consensus models in order of preference
DEFAULT_CONSENSUS_MODELS = [
"gpt-4o", # OpenAI's latest
"claude-3-opus-20240229", # Claude's most capable
"gemini/gemini-1.5-pro", # Google's best
"groq/llama3-70b-8192", # Fast Groq
"mistral/mistral-large-latest", # Mistral's best
"perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
]
# API key environment variables
API_KEY_ENV_VARS = {
"openai": ["OPENAI_API_KEY"],
"anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
"groq": ["GROQ_API_KEY"],
"mistral": ["MISTRAL_API_KEY"],
"perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
"together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
"cohere": ["COHERE_API_KEY"],
"replicate": ["REPLICATE_API_KEY"],
"huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
"vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
"azure": ["AZURE_API_KEY"],
"voyage": ["VOYAGE_API_KEY"],
"deepseek": ["DEEPSEEK_API_KEY"],
}
def __init__(self):
"""Initialize the unified LLM tool."""
self.available_providers = self._detect_available_providers()
self.config = self._load_config()
def _detect_available_providers(self) -> Dict[str, List[str]]:
"""Detect which providers have API keys configured."""
available = {}
for provider, env_vars in self.API_KEY_ENV_VARS.items():
for var in env_vars:
if os.getenv(var):
available[provider] = env_vars
break
return available
def _load_config(self) -> Dict[str, Any]:
"""Load configuration from file."""
if self.CONFIG_FILE.exists():
try:
with open(self.CONFIG_FILE, "r") as f:
return json.load(f)
except Exception:
pass
# Default config
return {
"disabled_providers": [],
"consensus_models": None, # Use defaults if None
"default_judge_model": "gpt-4o",
"consensus_size": 3,
}
def _save_config(self):
"""Save configuration to file."""
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(self.CONFIG_FILE, "w") as f:
json.dump(self.config, f, indent=2)
@property
@override
def name(self) -> str:
"""Get the tool name."""
return "llm"
@property
@override
def description(self) -> str:
"""Get the tool description."""
available = list(self.available_providers.keys())
return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
Usage:
llm "What is the capital of France?"
llm "Explain this code" --model gpt-4o
llm --action consensus "Is this approach correct?" --devils-advocate
llm --action models --provider openai
Available: {", ".join(available) if available else "None"}"""
@override
@auto_timeout("llm_unified")
async def call(
self,
ctx: MCPContext,
**params: Unpack[LLMParams],
) -> str:
"""Execute LLM action."""
# Create tool context only if we have a proper MCP context
tool_ctx = None
try:
if hasattr(ctx, "client") and ctx.client and hasattr(ctx.client, "server"):
tool_ctx = create_tool_context(ctx)
if tool_ctx:
await tool_ctx.set_tool_info(self.name)
except Exception:
# Running in test mode without MCP context
pass
if not LITELLM_AVAILABLE:
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
# Extract action
action = params.get("action", "query")
# Route to appropriate handler
if action == "query":
return await self._handle_query(tool_ctx, params)
elif action == "consensus":
return await self._handle_consensus(tool_ctx, params)
elif action == "list":
return self._handle_list()
elif action == "models":
return self._handle_models(params.get("provider"))
elif action == "enable":
return self._handle_enable(params.get("provider"))
elif action == "disable":
return self._handle_disable(params.get("provider"))
elif action == "test":
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
else:
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle single model query."""
model = params.get("model")
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for query action"
# Auto-select model if not specified
if not model:
if self.available_providers:
# Use first available model
if "openai" in self.available_providers:
model = "gpt-4o-mini"
elif "anthropic" in self.available_providers:
model = "claude-3-haiku-20240307"
elif "google" in self.available_providers:
model = "gemini/gemini-1.5-flash"
else:
# Use first provider's default
provider = list(self.available_providers.keys())[0]
model = f"{provider}/default"
else:
return "Error: No model specified and no API keys found"
# Check if we have API key for this model
provider = self._get_provider_for_model(model)
if provider and provider not in self.available_providers:
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
# Build messages
messages = []
if params.get("system_prompt"):
messages.append({"role": "system", "content": params["system_prompt"]})
messages.append({"role": "user", "content": prompt})
# Build kwargs
kwargs = {
"model": model,
"messages": messages,
"temperature": params.get("temperature", 0.7),
}
if params.get("max_tokens"):
kwargs["max_tokens"] = params["max_tokens"]
if params.get("json_mode"):
kwargs["response_format"] = {"type": "json_object"}
if params.get("stream"):
kwargs["stream"] = True
try:
if tool_ctx:
await tool_ctx.info(f"Querying {model}...")
if kwargs.get("stream"):
# Handle streaming response
response_text = ""
async for chunk in await litellm.acompletion(**kwargs):
if chunk.choices[0].delta.content:
response_text += chunk.choices[0].delta.content
return response_text
else:
# Regular response
response = await litellm.acompletion(**kwargs)
return response.choices[0].message.content
except Exception as e:
error_msg = str(e)
if "model_not_found" in error_msg or "does not exist" in error_msg:
return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
else:
return f"Error calling LLM: {error_msg}"
async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
"""Handle consensus mode with multiple models."""
prompt = params.get("prompt")
if not prompt:
return "Error: prompt is required for consensus action"
# Determine models to use
models = params.get("models")
if not models:
# Use configured or default models
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
models = self._get_consensus_models(consensus_size)
if not models:
return "Error: No models available for consensus. Set API keys for at least 2 providers."
if len(models) < 2:
return "Error: Consensus requires at least 2 models"
# Check for devil's advocate mode
devils_advocate = params.get("devils_advocate", False)
if devils_advocate and len(models) < 3:
return "Error: Devil's advocate mode requires at least 3 models"
if tool_ctx:
await tool_ctx.info(f"Running consensus with {len(models)} models...")
# Query models in parallel
system_prompt = params.get("system_prompt")
temperature = params.get("temperature", 0.7)
max_tokens = params.get("max_tokens")
# Split models if using devil's advocate
if devils_advocate:
consensus_models = models[:-1]
devil_model = models[-1]
else:
consensus_models = models
devil_model = None
# Query consensus models
responses = await self._query_models_parallel(
consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
)
# Get devil's advocate response if enabled
devil_response = None
if devil_model:
# Create devil's advocate prompt
responses_text = "\n\n".join(
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
)
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
Original Question: {prompt}
Responses from other models:
{responses_text}
Provide your critical analysis:"""
devil_result = await self._query_single_model(
devil_model, devil_prompt, system_prompt, temperature, max_tokens
)
if devil_result["success"]:
devil_response = {
"model": devil_model,
"response": devil_result["response"],
"time_ms": devil_result["time_ms"],
}
# Aggregate responses
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
include_raw = params.get("include_raw", False)
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
def _handle_list(self) -> str:
"""List available providers."""
output = ["=== LLM Providers ==="]
# Get all possible providers
all_providers = sorted(self.API_KEY_ENV_VARS.keys())
disabled = self.config.get("disabled_providers", [])
output.append(f"Total providers: {len(all_providers)}")
output.append(f"Available: {len(self.available_providers)}")
output.append(f"Disabled: {len(disabled)}\n")
for provider in all_providers:
status_parts = []
# Check if API key exists
if provider in self.available_providers:
status_parts.append("✅ API key found")
else:
status_parts.append("❌ No API key")
# Check if disabled
if provider in disabled:
status_parts.append("🚫 Disabled")
# Show environment variables
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
status = " | ".join(status_parts)
output.append(f"{provider}: {status}")
output.append(f" Environment variables: {', '.join(env_vars)}")
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
return "\n".join(output)
def _handle_models(self, provider: Optional[str] = None) -> str:
"""List available models."""
try:
all_models = self._get_all_models()
if not all_models:
return "No models available or LiteLLM not properly initialized"
output = ["=== Available LLM Models ==="]
if provider:
# Show models for specific provider
provider_lower = provider.lower()
models = all_models.get(provider_lower, [])
if not models:
return f"No models found for provider '{provider}'"
output.append(f"\n{provider.upper()} ({len(models)} models):")
output.append("-" * 40)
# Show first 50 models
for model in models[:50]:
output.append(f" {model}")
if len(models) > 50:
output.append(f" ... and {len(models) - 50} more")
else:
# Show summary of all providers
total_models = sum(len(models) for models in all_models.values())
output.append(f"Total models available: {total_models}")
output.append("")
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
available = "✅" if provider_name in self.available_providers else "❌"
output.append(f"{available} {provider_name}: {len(models)} models")
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
return "\n".join(output)
except Exception as e:
return f"Error listing models: {str(e)}"
def _handle_enable(self, provider: Optional[str]) -> str:
"""Enable a provider."""
if not provider:
return "Error: provider is required for enable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider in disabled:
disabled.remove(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully enabled {provider}"
else:
return f"{provider} is already enabled"
def _handle_disable(self, provider: Optional[str]) -> str:
"""Disable a provider."""
if not provider:
return "Error: provider is required for disable action"
provider = provider.lower()
disabled = self.config.get("disabled_providers", [])
if provider not in disabled:
disabled.append(provider)
self.config["disabled_providers"] = disabled
self._save_config()
return f"Successfully disabled {provider}"
else:
return f"{provider} is already disabled"
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
"""Test a model or provider."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
# If provider specified, test its default model
if provider and not model:
provider = provider.lower()
if provider == "openai":
model = "gpt-3.5-turbo"
elif provider == "anthropic":
model = "claude-3-haiku-20240307"
elif provider == "google":
model = "gemini/gemini-1.5-flash"
elif provider == "groq":
model = "groq/llama3-8b-8192"
else:
model = f"{provider}/default"
# Test the model
test_prompt = "Say 'Hello from Hanzo MCP!' in exactly 5 words."
try:
if tool_ctx:
await tool_ctx.info(f"Testing {model}...")
response = await litellm.acompletion(
model=model,
messages=[{"role": "user", "content": test_prompt}],
temperature=0,
max_tokens=20,
)
result = response.choices[0].message.content
return f"✅ {model} is working!\nResponse: {result}"
except Exception as e:
return f"❌ {model} failed: {str(e)}"
def _get_consensus_models(self, size: int) -> List[str]:
"""Get models for consensus based on availability."""
# Use configured models if set
configured = self.config.get("consensus_models")
if configured:
return configured[:size]
# Otherwise, build list from available providers
models = []
disabled = self.config.get("disabled_providers", [])
# Try default models first
for model in self.DEFAULT_CONSENSUS_MODELS:
if len(models) >= size:
break
provider = self._get_provider_for_model(model)
if provider and provider in self.available_providers and provider not in disabled:
models.append(model)
# If still need more, add from available providers
if len(models) < size:
for provider in self.available_providers:
if provider in disabled:
continue
if provider == "openai" and "gpt-4o" not in models:
models.append("gpt-4o")
elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
models.append("claude-3-opus-20240229")
elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
models.append("gemini/gemini-1.5-pro")
if len(models) >= size:
break
return models
async def _query_models_parallel(
self,
models: List[str],
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
tool_ctx,
) -> List[Dict[str, Any]]:
"""Query multiple models in parallel."""
async def query_with_info(model: str) -> Dict[str, Any]:
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
return {
"model": model,
"response": result.get("response"),
"success": result.get("success", False),
"error": result.get("error"),
"time_ms": result.get("time_ms", 0),
}
# Run all queries in parallel
tasks = [query_with_info(model) for model in models]
results = await asyncio.gather(*tasks)
# Report results
successful = sum(1 for r in results if r["success"])
if tool_ctx:
await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
return results
async def _query_single_model(
self,
model: str,
prompt: str,
system_prompt: Optional[str],
temperature: float,
max_tokens: Optional[int],
) -> Dict[str, Any]:
"""Query a single model and return result with metadata."""
import time
start_time = time.time()
try:
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
}
if max_tokens:
kwargs["max_tokens"] = max_tokens
response = await litellm.acompletion(**kwargs)
return {
"success": True,
"response": response.choices[0].message.content,
"time_ms": int((time.time() - start_time) * 1000),
}
except Exception as e:
return {
"success": False,
"error": str(e),
"time_ms": int((time.time() - start_time) * 1000),
}
async def _aggregate_consensus(
self,
responses: List[Dict[str, Any]],
original_prompt: str,
judge_model: str,
include_raw: bool,
devil_response: Optional[Dict[str, Any]],
tool_ctx,
) -> str:
"""Aggregate consensus responses using a judge model."""
# Prepare response data
successful_responses = [r for r in responses if r["success"]]
if not successful_responses:
return "Error: All models failed to respond"
# Format responses for aggregation
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
if devil_response:
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
# Create aggregation prompt
aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
<original_question>
{original_prompt}
</original_question>
<model_responses>
{responses_text}
</model_responses>
Please provide:
1. A synthesis of the key points where models agree
2. Notable differences or disagreements between responses
3. A balanced conclusion incorporating the best insights
{f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
Be concise and highlight the most important findings."""
# Get aggregation
try:
if tool_ctx:
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
if not judge_result["success"]:
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
# Format output
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
output.append(judge_result["response"])
# Add model list
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
if devil_response:
output.append(f"Devil's Advocate: {devil_response['model']}")
# Add timing info
avg_time = sum(r["time_ms"] for r in responses) / len(responses)
output.append(f"\nAverage response time: {avg_time:.0f}ms")
# Include raw responses if requested
if include_raw:
output.append("\n\n=== Raw Responses ===")
for r in successful_responses:
output.append(f"\n{r['model']}:")
output.append("-" * 40)
output.append(r["response"])
if devil_response:
output.append(f"\nDevil's Advocate ({devil_response['model']}):")
output.append("-" * 40)
output.append(devil_response["response"])
return "\n".join(output)
except Exception as e:
return f"Error during aggregation: {str(e)}"
def _get_provider_for_model(self, model: str) -> Optional[str]:
"""Determine the provider for a given model."""
model_lower = model.lower()
# Check explicit provider prefix
if "/" in model:
return model.split("/")[0]
# Check model prefixes
if model_lower.startswith("gpt"):
return "openai"
elif model_lower.startswith("claude"):
return "anthropic"
elif model_lower.startswith("gemini"):
return "google"
elif model_lower.startswith("command"):
return "cohere"
# Default to OpenAI
return "openai"
def _get_all_models(self) -> Dict[str, List[str]]:
"""Get all available models from LiteLLM."""
try:
import litellm
# Get all models
all_models = litellm.model_list
# Organize by provider
providers = {}
for model in all_models:
# Extract provider
if "/" in model:
provider = model.split("/")[0]
elif model.startswith("gpt"):
provider = "openai"
elif model.startswith("claude"):
provider = "anthropic"
elif model.startswith("gemini"):
provider = "google"
elif model.startswith("command"):
provider = "cohere"
else:
provider = "other"
if provider not in providers:
providers[provider] = []
providers[provider].append(model)
# Sort models within each provider
for provider in providers:
providers[provider] = sorted(providers[provider])
return providers
except Exception:
return {}
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
pass
# Alias for backwards compatibility
LLMTool = UnifiedLLMTool