fix: improve AI tool detection and fallback mechanism (v0.3.31)

- Fix Hanzo Node detection to test actual chat endpoint instead of health check
- Check both ports 3690 (default) and 8000 for Hanzo Node
- Add session-based failed tool tracking to avoid repeated attempts
- Cache working tool selection for better performance
- Fix hanzo-mcp integration with proper dependencies
- Add comprehensive test suite for tool detection (14 tests)
- Test and verify OpenAI Codex integration
This commit is contained in:
2025-09-07 15:33:06 -05:00
parent b95489a436
commit f7aecfbf52
154 changed files with 6167 additions and 1027 deletions
+15 -2
View File
@@ -1,11 +1,24 @@
"""Hanzo AI - Implementation of Hanzo capabilities using MCP."""
# Polyfill typing.override for Python < 3.12
try: # pragma: no cover
from typing import override as _override # type: ignore
except Exception: # pragma: no cover
import typing as _typing
def override(obj): # type: ignore
return obj
_typing.override = override # type: ignore[attr-defined]
# Configure FastMCP logging globally for stdio transport
import os
import warnings
# Suppress litellm deprecation warnings about event loop
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
warnings.filterwarnings(
"ignore", message="There is no current event loop", category=DeprecationWarning
)
if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
try:
@@ -15,4 +28,4 @@ if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
except ImportError:
pass
__version__ = "0.7.7"
__version__ = "0.8.4"
@@ -137,7 +137,9 @@ class Analytics:
self.capture("tool_used", properties)
def track_error(self, error: Exception, context: Optional[Dict[str, Any]] = None) -> None:
def track_error(
self, error: Exception, context: Optional[Dict[str, Any]] = None
) -> None:
"""Track an error event."""
if not self.config.capture_errors:
return
@@ -157,7 +159,9 @@ class Analytics:
return default
try:
return self._client.feature_enabled(flag_key, self.config.distinct_id, default=default)
return self._client.feature_enabled(
flag_key, self.config.distinct_id, default=default
)
except Exception:
return default
@@ -167,7 +171,9 @@ class Analytics:
return default
try:
return self._client.get_feature_flag(flag_key, self.config.distinct_id, default=default)
return self._client.get_feature_flag(
flag_key, self.config.distinct_id, default=default
)
except Exception:
return default
+57 -15
View File
@@ -83,7 +83,11 @@ class ClaudeBridge(FastMCP):
response = await self._forward_to_claude(message, context)
self.conversation_history.append(
{"from": self.config.target_instance, "to": self.config.source_instance, "response": response}
{
"from": self.config.target_instance,
"to": self.config.source_instance,
"response": response,
}
)
return response
@@ -130,7 +134,9 @@ class ClaudeBridge(FastMCP):
"""
logger.info(f"Bridge {self.config.instance_id}: Task delegation")
delegation_prompt = self._build_delegation_prompt(task, requirements, constraints)
delegation_prompt = self._build_delegation_prompt(
task, requirements, constraints
)
result = await self._forward_to_claude(delegation_prompt)
return {
@@ -143,7 +149,9 @@ class ClaudeBridge(FastMCP):
@self.tool()
async def get_claude_opinion(
question: str, options: Optional[List[str]] = None, criteria: Optional[List[str]] = None
question: str,
options: Optional[List[str]] = None,
criteria: Optional[List[str]] = None,
) -> Dict[str, Any]:
"""Get another Claude's opinion on a decision.
@@ -170,7 +178,9 @@ class ClaudeBridge(FastMCP):
}
@self.tool()
async def share_context_with_claude(key: str, value: Any, description: Optional[str] = None) -> bool:
async def share_context_with_claude(
key: str, value: Any, description: Optional[str] = None
) -> bool:
"""Share context with another Claude instance.
Args:
@@ -253,7 +263,9 @@ class ClaudeBridge(FastMCP):
"shared_context_keys": list(self.shared_context.keys()),
}
def _build_review_prompt(self, code: str, description: str, focus_areas: Optional[List[str]]) -> str:
def _build_review_prompt(
self, code: str, description: str, focus_areas: Optional[List[str]]
) -> str:
"""Build a code review prompt."""
prompt = f"""
Please review the following code:
@@ -281,7 +293,9 @@ class ClaudeBridge(FastMCP):
return prompt
def _build_delegation_prompt(self, task: str, requirements: List[str], constraints: Optional[List[str]]) -> str:
def _build_delegation_prompt(
self, task: str, requirements: List[str], constraints: Optional[List[str]]
) -> str:
"""Build a task delegation prompt."""
prompt = f"""
Please complete the following task:
@@ -306,7 +320,9 @@ class ClaudeBridge(FastMCP):
return prompt
def _build_opinion_prompt(self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]) -> str:
def _build_opinion_prompt(
self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]
) -> str:
"""Build an opinion request prompt."""
prompt = f"""
I need your opinion on the following:
@@ -335,7 +351,9 @@ class ClaudeBridge(FastMCP):
return prompt
async def _forward_to_claude(self, prompt: str, context: Optional[str] = None) -> str:
async def _forward_to_claude(
self, prompt: str, context: Optional[str] = None
) -> str:
"""Forward a request to the target Claude instance.
In production, this would make an actual API call to the Claude instance.
@@ -347,7 +365,9 @@ class ClaudeBridge(FastMCP):
full_prompt = f"Context: {context}\n\n{prompt}"
# Log the forwarding
logger.info(f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}")
logger.info(
f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}"
)
logger.debug(f"Prompt: {full_prompt[:200]}...")
# In production, this would:
@@ -393,7 +413,10 @@ async def run_bridge_server(config: BridgeConfig):
config: Bridge configuration
"""
# Configure logging
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
logger.info(f"Starting MCP Bridge for Claude instance {config.instance_id}")
logger.info(f"Role: {config.role}")
@@ -408,17 +431,36 @@ async def run_bridge_server(config: BridgeConfig):
read_stream=read_stream,
write_stream=write_stream,
initialization_options=InitializationOptions(
server_name=bridge.name, server_version="1.0.0", capabilities=bridge.get_capabilities()
server_name=bridge.name,
server_version="1.0.0",
capabilities=bridge.get_capabilities(),
),
)
def main():
"""Main entry point for the bridge."""
parser = argparse.ArgumentParser(description="MCP Bridge for Claude-to-Claude communication")
parser.add_argument("--target-port", type=int, required=True, help="Port of the target Claude instance")
parser.add_argument("--instance-id", type=int, required=True, help="ID of the target Claude instance")
parser.add_argument("--role", type=str, required=True, help="Role of the target instance (primary, critic_1, etc.)")
parser = argparse.ArgumentParser(
description="MCP Bridge for Claude-to-Claude communication"
)
parser.add_argument(
"--target-port",
type=int,
required=True,
help="Port of the target Claude instance",
)
parser.add_argument(
"--instance-id",
type=int,
required=True,
help="ID of the target Claude instance",
)
parser.add_argument(
"--role",
type=str,
required=True,
help="Role of the target instance (primary, critic_1, etc.)",
)
args = parser.parse_args()
+60 -24
View File
@@ -1,4 +1,10 @@
"""Command-line interface for the Hanzo AI server."""
"""Command-line interface for the Hanzo AI server.
This module intentionally defers heavy imports (like the server and its
dependencies) until after we determine the transport and configure logging.
This prevents any stdout/stderr noise from imports that would corrupt the
MCP stdio transport used by Claude Desktop and other MCP clients.
"""
import os
import sys
@@ -9,48 +15,62 @@ import argparse
from typing import Any, cast
from pathlib import Path
from hanzo_mcp.server import HanzoMCPServer
def main() -> None:
"""Run the CLI for the Hanzo AI server."""
# Pre-parse arguments to check transport type early
import sys
# Pre-parse arguments to check transport type early, BEFORE importing server
early_parser = argparse.ArgumentParser(add_help=False)
early_parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
early_args, _ = early_parser.parse_known_args()
# Configure logging VERY early based on transport
suppress_stdout = False
original_stdout = sys.stdout
if early_args.transport == "stdio":
# Set environment variable for server to detect stdio mode
import os
# Set environment variable for server to detect stdio mode as early as possible
os.environ["HANZO_MCP_TRANSPORT"] = "stdio"
# Aggressively quiet common dependency loggers/warnings in stdio mode
os.environ.setdefault("PYTHONWARNINGS", "ignore")
os.environ.setdefault("LITELLM_LOG", "ERROR")
os.environ.setdefault("LITELLM_LOGGING_LEVEL", "ERROR")
os.environ.setdefault("FASTMCP_LOG_LEVEL", "ERROR")
# For stdio transport, disable ALL logging immediately
from fastmcp.utilities.logging import configure_logging
# Suppress FastMCP logging (if available) and all standard logging
try:
from fastmcp.utilities.logging import configure_logging # type: ignore
# Set to ERROR to suppress INFO/WARNING messages from FastMCP
configure_logging(level="ERROR")
configure_logging(level="ERROR")
except Exception:
pass
# Also configure standard logging to ERROR level
logging.basicConfig(
level=logging.ERROR, # Only show errors
handlers=[], # No handlers for stdio to prevent protocol corruption
)
# Redirect stderr to devnull for stdio transport to prevent any output
import sys
sys.stderr = open(os.devnull, "w")
from hanzo_mcp import __version__
# Suppress stdout during potentially noisy imports unless user requested help/version
if not any(flag in sys.argv for flag in ("--version", "-h", "--help")):
sys.stdout = open(os.devnull, "w")
suppress_stdout = True
parser = argparse.ArgumentParser(description="MCP server implementing Hanzo AI capabilities")
# Import the server only AFTER transport/logging have been configured to avoid import-time noise
from hanzo_mcp.server import HanzoMCPServer
parser.add_argument("--version", action="version", version=f"hanzo-mcp {__version__}")
# Avoid importing hanzo_mcp package just to get version (it can have side-effects).
try:
from importlib.metadata import version as _pkg_version # py3.8+
_version = _pkg_version("hanzo-mcp")
except Exception:
_version = "unknown"
parser = argparse.ArgumentParser(
description="MCP server implementing Hanzo AI capabilities"
)
parser.add_argument("--version", action="version", version=f"hanzo-mcp {_version}")
_ = parser.add_argument(
"--transport",
@@ -195,6 +215,14 @@ def main() -> None:
args = parser.parse_args()
# Restore stdout after parsing, before any explicit output or server start
if suppress_stdout:
try:
sys.stdout.close() # Close devnull handle
except Exception:
pass
sys.stdout = original_stdout
# Cast args attributes to appropriate types to avoid 'Any' warnings
name: str = cast(str, args.name)
install: bool = cast(bool, args.install)
@@ -214,8 +242,12 @@ def main() -> None:
port: int = cast(int, args.port)
log_level: str = cast(str, args.log_level)
project_dir: str | None = cast(str | None, args.project_dir)
allowed_paths: list[str] = cast(list[str], args.allowed_paths) if args.allowed_paths else []
project_paths: list[str] = cast(list[str], args.project_paths) if args.project_paths else []
allowed_paths: list[str] = (
cast(list[str], args.allowed_paths) if args.allowed_paths else []
)
project_paths: list[str] = (
cast(list[str], args.project_paths) if args.project_paths else []
)
# Handle project_dir parameter (add to both allowed_paths and project_paths)
if project_dir:
@@ -225,7 +257,9 @@ def main() -> None:
project_paths.append(project_dir)
if install:
install_claude_desktop_config(name, allowed_paths, disable_write_tools, disable_search_tools, host, port)
install_claude_desktop_config(
name, allowed_paths, disable_write_tools, disable_search_tools, host, port
)
return
# Get logger
@@ -370,7 +404,9 @@ def install_claude_desktop_config(
args.append("--disable-search-tools")
# Create config object
config: dict[str, Any] = {"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}}
config: dict[str, Any] = {
"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}
}
# Check if the file already exists
if config_file.exists():
@@ -344,7 +344,10 @@ def apply_cli_overrides(args: argparse.Namespace) -> Dict[str, Any]:
vector_config["enabled"] = True
if hasattr(args, "vector_store_path") and args.vector_store_path:
vector_config["data_path"] = args.vector_store_path
if hasattr(args, "embedding_model") and args.embedding_model != "text-embedding-3-small":
if (
hasattr(args, "embedding_model")
and args.embedding_model != "text-embedding-3-small"
):
vector_config["embedding_model"] = args.embedding_model
if vector_config:
@@ -377,7 +380,9 @@ def list_tools(settings: HanzoMCPSettings) -> None:
logger.info(f"\nTotal: {len(TOOL_REGISTRY)} tools")
enabled_count = len(settings.get_enabled_tools())
logger.info(f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}")
logger.info(
f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}"
)
def main() -> None:
@@ -437,11 +442,15 @@ def main() -> None:
agent_base_url=settings.agent.base_url,
agent_max_iterations=settings.agent.max_iterations,
agent_max_tool_uses=settings.agent.max_tool_uses,
enable_agent_tool=settings.agent.enabled or settings.is_tool_enabled("dispatch_agent"),
enable_agent_tool=settings.agent.enabled
or settings.is_tool_enabled("dispatch_agent"),
disable_write_tools=not any(
settings.is_tool_enabled(t) for t in ["write", "edit", "multi_edit", "content_replace"]
settings.is_tool_enabled(t)
for t in ["write", "edit", "multi_edit", "content_replace"]
),
disable_search_tools=not any(
settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]
),
disable_search_tools=not any(settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]),
host=settings.server.host,
port=settings.server.port,
enabled_tools=settings.enabled_tools, # Pass individual tool configuration
@@ -53,7 +53,9 @@ Examples:
create_plugin_template(output_dir, args.name)
print(f"\n✅ Plugin template created successfully!")
print(f"\nTo use your plugin:")
print(f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}")
print(
f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}"
)
print(f"2. Restart Hanzo MCP to load the plugin")
print(f"3. Add '{args.name}' to your mode's tool list")
except Exception as e:
@@ -79,9 +81,13 @@ Examples:
print(f"\n {name}:")
print(f" Source: {plugin.source_path}")
if plugin.metadata:
print(f" Version: {plugin.metadata.get('version', 'unknown')}")
print(
f" Version: {plugin.metadata.get('version', 'unknown')}"
)
print(f" Author: {plugin.metadata.get('author', 'unknown')}")
print(f" Description: {plugin.metadata.get('description', '')}")
print(
f" Description: {plugin.metadata.get('description', '')}"
)
except Exception as e:
print(f"❌ Error listing plugins: {e}", file=sys.stderr)
sys.exit(1)
@@ -17,7 +17,11 @@ class ComputeNodeDetector:
# Try NVIDIA GPUs
try:
result = subprocess.run(
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
[
"nvidia-smi",
"--query-gpu=name,memory.total",
"--format=csv,noheader",
],
capture_output=True,
text=True,
timeout=2,
@@ -26,7 +30,14 @@ class ComputeNodeDetector:
for line in result.stdout.strip().split("\n"):
if line:
name, memory = line.split(", ")
gpus.append({"type": "cuda", "name": name, "memory": memory, "id": f"cuda:{len(gpus)}"})
gpus.append(
{
"type": "cuda",
"name": name,
"memory": memory,
"id": f"cuda:{len(gpus)}",
}
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@@ -35,7 +46,10 @@ class ComputeNodeDetector:
try:
# Check for Metal support
result = subprocess.run(
["system_profiler", "SPDisplaysDataType"], capture_output=True, text=True, timeout=2
["system_profiler", "SPDisplaysDataType"],
capture_output=True,
text=True,
timeout=2,
)
if result.returncode == 0 and "Metal" in result.stdout:
# Parse GPU info from system_profiler
@@ -44,7 +58,12 @@ class ComputeNodeDetector:
if "Chipset Model:" in line:
gpu_name = line.split(":")[1].strip()
gpus.append(
{"type": "metal", "name": gpu_name, "memory": "Shared", "id": f"metal:{len(gpus)}"}
{
"type": "metal",
"name": gpu_name,
"memory": "Shared",
"id": f"metal:{len(gpus)}",
}
)
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
@@ -66,7 +85,14 @@ class ComputeNodeDetector:
result = sock.connect_ex(("localhost", int(webgpu_port)))
sock.close()
if result == 0:
webgpu_nodes.append({"type": "webgpu", "name": "Chrome WebGPU", "memory": "Browser", "id": "webgpu:0"})
webgpu_nodes.append(
{
"type": "webgpu",
"name": "Chrome WebGPU",
"memory": "Browser",
"id": "webgpu:0",
}
)
except Exception:
pass
@@ -53,7 +53,9 @@ class ProjectConfig:
tasks: List[Dict[str, Any]] = field(default_factory=list)
enabled_tools: Dict[str, bool] = field(default_factory=dict)
disabled_tools: List[str] = field(default_factory=list)
mcp_servers: List[str] = field(default_factory=list) # Names of enabled MCP servers for this project
mcp_servers: List[str] = field(
default_factory=list
) # Names of enabled MCP servers for this project
@dataclass
@@ -107,7 +109,9 @@ class HanzoMCPSettings:
# MCP Hub configuration
mcp_servers: Dict[str, MCPServerConfig] = field(default_factory=dict)
hub_enabled: bool = False
trusted_servers: List[str] = field(default_factory=list) # Whitelist of trusted server names
trusted_servers: List[str] = field(
default_factory=list
) # Whitelist of trusted server names
# Project-specific configurations
projects: Dict[str, ProjectConfig] = field(default_factory=dict)
@@ -119,7 +123,9 @@ class HanzoMCPSettings:
def __post_init__(self):
"""Initialize default tool states if not specified."""
if not self.enabled_tools:
self.enabled_tools = {name: config.enabled for name, config in TOOL_REGISTRY.items()}
self.enabled_tools = {
name: config.enabled for name, config in TOOL_REGISTRY.items()
}
# Apply disabled_tools list to enabled_tools dict
for tool_name in self.disabled_tools:
@@ -133,7 +139,9 @@ class HanzoMCPSettings:
return False
return self.enabled_tools.get(
tool_name,
TOOL_REGISTRY.get(tool_name, type("obj", (object,), {"enabled": False})).enabled,
TOOL_REGISTRY.get(
tool_name, type("obj", (object,), {"enabled": False})
).enabled,
)
def enable_tool(self, tool_name: str) -> bool:
@@ -391,7 +399,11 @@ def _load_from_env() -> Dict[str, Any]:
config.setdefault("agent", {})["enabled"] = True
# Check for MODE/PERSONALITY/HANZO_MODE
if mode := os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE"):
if (
mode := os.environ.get("HANZO_MODE")
or os.environ.get("PERSONALITY")
or os.environ.get("MODE")
):
config["active_mode"] = mode
# Check for other environment overrides
@@ -484,7 +496,9 @@ def save_settings(settings: HanzoMCPSettings, global_config: bool = True) -> Pat
return config_path
def _merge_config(base_settings: HanzoMCPSettings, config_dict: Dict[str, Any]) -> HanzoMCPSettings:
def _merge_config(
base_settings: HanzoMCPSettings, config_dict: Dict[str, Any]
) -> HanzoMCPSettings:
"""Merge configuration dictionary into settings object."""
# Convert to dict, merge, then convert back
base_dict = asdict(base_settings)
@@ -500,6 +514,17 @@ def _merge_config(base_settings: HanzoMCPSettings, config_dict: Dict[str, Any])
merged = deep_merge(base_dict, config_dict)
# Backwards/forwards compatibility: support a structured "tools" section
# where each tool can define { enabled: bool, ...options } and map it to
# the existing enabled_tools/disabled_tools layout.
tools_cfg = merged.get("tools", {})
if isinstance(tools_cfg, dict):
enabled_tools = dict(merged.get("enabled_tools", {}))
for tool_name, tool_data in tools_cfg.items():
if isinstance(tool_data, dict) and "enabled" in tool_data:
enabled_tools[tool_name] = bool(tool_data.get("enabled"))
merged["enabled_tools"] = enabled_tools
# Reconstruct the settings object
mcp_servers = {}
for name, server_data in merged.get("mcp_servers", {}).items():
@@ -204,4 +204,6 @@ def disable_tool(tool_name: str) -> bool:
def is_tool_enabled(tool_name: str) -> bool:
"""Check if a tool is enabled."""
return TOOL_REGISTRY.get(tool_name, ToolConfig("", ToolCategory.COMMON, False)).enabled
return TOOL_REGISTRY.get(
tool_name, ToolConfig("", ToolCategory.COMMON, False)
).enabled
@@ -0,0 +1,520 @@
"""Base Agent - Unified foundation for all AI agent implementations.
This module provides the single base class for all agent operations,
following DRY principles and ensuring consistent behavior across all agents.
"""
from __future__ import annotations
import os
import asyncio
import logging
from abc import ABC, abstractmethod
from typing import Any, Dict, List, Generic, TypeVar, Optional, Protocol, runtime_checkable
from pathlib import Path
from datetime import datetime
from dataclasses import field, dataclass
from .model_registry import ModelConfig, registry
logger = logging.getLogger(__name__)
# Type variables for generic context
TContext = TypeVar("TContext")
TResult = TypeVar("TResult")
@runtime_checkable
class AgentContext(Protocol):
"""Protocol for agent execution context."""
async def log(self, message: str, level: str = "info") -> None:
"""Log a message."""
pass
async def progress(self, message: str, percentage: Optional[float] = None) -> None:
"""Report progress."""
pass
@dataclass
class AgentConfig:
"""Configuration for agent execution."""
model: str = "claude-3-5-sonnet-20241022"
timeout: int = 300
max_retries: int = 3
working_dir: Optional[Path] = None
environment: Dict[str, str] = field(default_factory=dict)
stream_output: bool = False
use_worktree: bool = False
def __post_init__(self) -> None:
"""Resolve model name and validate configuration."""
self.model = registry.resolve(self.model)
if self.working_dir and not isinstance(self.working_dir, Path):
self.working_dir = Path(self.working_dir)
@dataclass
class AgentResult:
"""Result from agent execution."""
success: bool
output: Optional[str] = None
error: Optional[str] = None
duration: Optional[float] = None
metadata: Dict[str, Any] = field(default_factory=dict)
@property
def content(self) -> str:
"""Get the primary content (output or error)."""
return self.output if self.success else (self.error or "Unknown error")
class BaseAgent(ABC, Generic[TContext]):
"""Base class for all AI agents.
This is the single foundation for all agent implementations,
ensuring consistent behavior and eliminating code duplication.
"""
def __init__(self, config: Optional[AgentConfig] = None) -> None:
"""Initialize agent with configuration.
Args:
config: Agent configuration
"""
self.config = config or AgentConfig()
self._start_time: Optional[datetime] = None
self._end_time: Optional[datetime] = None
@property
@abstractmethod
def name(self) -> str:
"""Agent name."""
pass
@property
@abstractmethod
def description(self) -> str:
"""Agent description."""
pass
async def execute(
self,
prompt: str,
context: Optional[TContext] = None,
**kwargs: Any,
) -> AgentResult:
"""Execute agent with prompt.
Args:
prompt: The prompt or task
context: Execution context
**kwargs: Additional parameters
Returns:
Agent execution result
"""
self._start_time = datetime.now()
try:
# Setup environment
env = self._prepare_environment()
# Log start
if context and isinstance(context, AgentContext):
await context.log(f"Starting {self.name} with model {self.config.model}")
# Execute with retries
result = await self._execute_with_retries(prompt, context, env, **kwargs)
# Calculate duration
self._end_time = datetime.now()
duration = (self._end_time - self._start_time).total_seconds()
return AgentResult(
success=True,
output=result,
duration=duration,
metadata={"model": self.config.model, "agent": self.name},
)
except Exception as e:
self._end_time = datetime.now()
duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
logger.error(f"Agent {self.name} failed: {e}")
return AgentResult(
success=False,
error=str(e),
duration=duration,
metadata={"model": self.config.model, "agent": self.name},
)
def _prepare_environment(self) -> Dict[str, str]:
"""Prepare environment variables for execution.
Returns:
Environment variables dictionary
"""
env = os.environ.copy()
# Add model-specific API key
model_config = registry.get(self.config.model)
if model_config and model_config.api_key_env:
key_var = model_config.api_key_env
if key_var in os.environ:
env[key_var] = os.environ[key_var]
# Add Hanzo unified auth
if "HANZO_API_KEY" in os.environ:
env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
# Add custom environment
env.update(self.config.environment)
return env
async def _execute_with_retries(
self,
prompt: str,
context: Optional[TContext],
env: Dict[str, str],
**kwargs: Any,
) -> str:
"""Execute with retry logic.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
Execution output
Raises:
Exception: If all retries fail
"""
last_error = None
for attempt in range(self.config.max_retries):
try:
# Call the implementation
result = await self._execute_impl(prompt, context, env, **kwargs)
return result
except asyncio.TimeoutError:
last_error = f"Timeout after {self.config.timeout} seconds"
if context and isinstance(context, AgentContext):
await context.log(f"Attempt {attempt + 1} timed out", "warning")
except Exception as e:
last_error = str(e)
if context and isinstance(context, AgentContext):
await context.log(f"Attempt {attempt + 1} failed: {e}", "warning")
# Don't retry on certain errors
if "unauthorized" in str(e).lower() or "forbidden" in str(e).lower():
raise
# Wait before retry (exponential backoff)
if attempt < self.config.max_retries - 1:
await asyncio.sleep(2 ** attempt)
raise Exception(f"All {self.config.max_retries} attempts failed. Last error: {last_error}")
@abstractmethod
async def _execute_impl(
self,
prompt: str,
context: Optional[TContext],
env: Dict[str, str],
**kwargs: Any,
) -> str:
"""Implementation-specific execution.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
Execution output
"""
pass
class CLIAgent(BaseAgent[TContext]):
"""Base class for CLI-based agents."""
@property
@abstractmethod
def cli_command(self) -> str:
"""CLI command to execute."""
pass
def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
"""Build the CLI command.
Args:
prompt: The prompt
**kwargs: Additional parameters
Returns:
Command arguments list
"""
command = [self.cli_command]
# Add model if specified
model_config = registry.get(self.config.model)
if model_config:
command.extend(["--model", model_config.full_name])
# Add prompt
command.append(prompt)
return command
async def _execute_impl(
self,
prompt: str,
context: Optional[TContext],
env: Dict[str, str],
**kwargs: Any,
) -> str:
"""Execute CLI command.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
Command output
"""
command = self.build_command(prompt, **kwargs)
# Determine if we need stdin
needs_stdin = self.cli_command in ["claude", "cline"]
# Execute command
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE if needs_stdin else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=str(self.config.working_dir) if self.config.working_dir else None,
env=env,
)
# Handle timeout
try:
stdout, stderr = await asyncio.wait_for(
process.communicate(prompt.encode() if needs_stdin else None),
timeout=self.config.timeout,
)
except asyncio.TimeoutError:
process.kill()
raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
# Check for errors
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Command failed"
raise Exception(error_msg)
return stdout.decode()
class APIAgent(BaseAgent[TContext]):
"""Base class for API-based agents."""
async def _execute_impl(
self,
prompt: str,
context: Optional[TContext],
env: Dict[str, str],
**kwargs: Any,
) -> str:
"""Execute via API.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
API response
"""
# This would be implemented by specific API agents
# using the appropriate client library
raise NotImplementedError("API agents must implement _execute_impl")
class AgentOrchestrator:
"""Orchestrator for managing multiple agents."""
def __init__(self, default_config: Optional[AgentConfig] = None) -> None:
"""Initialize orchestrator.
Args:
default_config: Default configuration for agents
"""
self.default_config = default_config or AgentConfig()
self._agents: Dict[str, BaseAgent] = {}
self._semaphore: Optional[asyncio.Semaphore] = None
def register(self, agent: BaseAgent) -> None:
"""Register an agent.
Args:
agent: Agent to register
"""
self._agents[agent.name] = agent
def get_agent(self, name: str) -> Optional[BaseAgent]:
"""Get agent by name.
Args:
name: Agent name
Returns:
Agent instance or None
"""
return self._agents.get(name)
async def execute_single(
self,
agent_name: str,
prompt: str,
context: Optional[Any] = None,
**kwargs: Any,
) -> AgentResult:
"""Execute single agent.
Args:
agent_name: Name of agent to use
prompt: The prompt
context: Execution context
**kwargs: Additional parameters
Returns:
Execution result
"""
agent = self.get_agent(agent_name)
if not agent:
return AgentResult(
success=False,
error=f"Agent '{agent_name}' not found",
)
return await agent.execute(prompt, context, **kwargs)
async def execute_parallel(
self,
tasks: List[Dict[str, Any]],
max_concurrent: int = 5,
) -> List[AgentResult]:
"""Execute multiple agents in parallel.
Args:
tasks: List of task definitions
max_concurrent: Maximum concurrent executions
Returns:
List of results
"""
self._semaphore = asyncio.Semaphore(max_concurrent)
async def run_with_semaphore(task: Dict[str, Any]) -> AgentResult:
async with self._semaphore:
return await self.execute_single(
task["agent"],
task["prompt"],
task.get("context"),
**task.get("kwargs", {}),
)
return await asyncio.gather(
*[run_with_semaphore(task) for task in tasks],
return_exceptions=False,
)
async def execute_consensus(
self,
prompt: str,
agents: List[str],
threshold: float = 0.66,
) -> Dict[str, Any]:
"""Execute consensus operation with multiple agents.
Args:
prompt: The prompt
agents: List of agent names
threshold: Agreement threshold
Returns:
Consensus results
"""
# Execute all agents in parallel
tasks = [{"agent": agent, "prompt": prompt} for agent in agents]
results = await self.execute_parallel(tasks)
# Analyze consensus
successful = [r for r in results if r.success]
agreement = len(successful) / len(results) if results else 0
return {
"consensus_reached": agreement >= threshold,
"agreement_score": agreement,
"individual_results": results,
"agents_used": agents,
}
async def execute_chain(
self,
initial_prompt: str,
agents: List[str],
) -> List[AgentResult]:
"""Execute agents in a chain, passing output forward.
Args:
initial_prompt: Initial prompt
agents: List of agent names
Returns:
List of results from each step
"""
results = []
current_prompt = initial_prompt
for agent_name in agents:
result = await self.execute_single(agent_name, current_prompt)
results.append(result)
if result.success and result.output:
# Use output as input for next agent
current_prompt = f"Review and improve:\n{result.output}"
else:
# Chain broken
break
return results
__all__ = [
"AgentContext",
"AgentConfig",
"AgentResult",
"BaseAgent",
"CLIAgent",
"APIAgent",
"AgentOrchestrator",
]
@@ -0,0 +1,436 @@
"""Unified Model Registry - Single source of truth for all AI model mappings.
This module provides a centralized registry for AI model configurations,
eliminating duplication and ensuring consistency across the codebase.
Thread-safe singleton implementation.
"""
from __future__ import annotations
import threading
from enum import Enum
from typing import Any, Set, Dict, List, Optional
from dataclasses import field, dataclass
class ModelProvider(Enum):
"""Enumeration of AI model providers."""
ANTHROPIC = "anthropic"
OPENAI = "openai"
GOOGLE = "google"
XAI = "xai"
OLLAMA = "ollama"
DEEPSEEK = "deepseek"
MISTRAL = "mistral"
META = "meta"
HANZO = "hanzo"
@dataclass(frozen=True)
class ModelConfig:
"""Configuration for a single AI model."""
full_name: str
provider: ModelProvider
aliases: Set[str] = field(default_factory=set)
default_params: Dict[str, Any] = field(default_factory=dict)
supports_vision: bool = False
supports_tools: bool = False
supports_streaming: bool = True
context_window: int = 8192
max_output: int = 4096
api_key_env: Optional[str] = None
cli_command: Optional[str] = None
class ModelRegistry:
"""Centralized registry for all AI models.
Thread-safe singleton implementation ensuring single source of truth
for model configurations across the codebase.
"""
_instance: Optional[ModelRegistry] = None
_lock = threading.Lock()
_models: Dict[str, ModelConfig] = {}
_initialized = False
def __new__(cls) -> ModelRegistry:
"""Thread-safe singleton pattern."""
if cls._instance is None:
with cls._lock:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
"""Initialize model registry once."""
if not self._initialized:
with self._lock:
if not self._initialized:
self._initialize_models()
self._initialized = True
def _initialize_models(self) -> None:
"""Initialize all model configurations."""
# Claude models
self._register(ModelConfig(
full_name="claude-3-5-sonnet-20241022",
provider=ModelProvider.ANTHROPIC,
aliases={"claude", "cc", "claude-code", "sonnet", "sonnet-4.1"},
supports_vision=True,
supports_tools=True,
context_window=200000,
max_output=8192,
api_key_env="ANTHROPIC_API_KEY",
cli_command="claude",
))
self._register(ModelConfig(
full_name="claude-opus-4-1-20250805",
provider=ModelProvider.ANTHROPIC,
aliases={"opus", "opus-4.1", "claude-opus"},
supports_vision=True,
supports_tools=True,
context_window=200000,
max_output=8192,
api_key_env="ANTHROPIC_API_KEY",
cli_command="claude",
))
self._register(ModelConfig(
full_name="claude-3-haiku-20240307",
provider=ModelProvider.ANTHROPIC,
aliases={"haiku", "claude-haiku"},
supports_vision=True,
supports_tools=True,
context_window=200000,
max_output=4096,
api_key_env="ANTHROPIC_API_KEY",
cli_command="claude",
))
# OpenAI models
self._register(ModelConfig(
full_name="gpt-4-turbo",
provider=ModelProvider.OPENAI,
aliases={"gpt4", "gpt-4", "codex"},
supports_vision=True,
supports_tools=True,
context_window=128000,
max_output=4096,
api_key_env="OPENAI_API_KEY",
cli_command="openai",
))
self._register(ModelConfig(
full_name="gpt-5-turbo",
provider=ModelProvider.OPENAI,
aliases={"gpt5", "gpt-5"},
supports_vision=True,
supports_tools=True,
context_window=256000,
max_output=16384,
api_key_env="OPENAI_API_KEY",
cli_command="openai",
))
self._register(ModelConfig(
full_name="o1-preview",
provider=ModelProvider.OPENAI,
aliases={"o1", "openai-o1"},
supports_vision=False,
supports_tools=False,
context_window=128000,
max_output=32768,
api_key_env="OPENAI_API_KEY",
cli_command="openai",
))
# Google models
self._register(ModelConfig(
full_name="gemini-2.0-flash-exp",
provider=ModelProvider.GOOGLE,
aliases={"gemini-2", "gemini-2.0", "gemini2"},
supports_vision=True,
supports_tools=True,
context_window=1000000,
max_output=8192,
api_key_env="GEMINI_API_KEY",
cli_command="gemini",
))
self._register(ModelConfig(
full_name="gemini-exp-1206",
provider=ModelProvider.GOOGLE,
aliases={"gemini-2.5", "gemini-2.5-pro", "gemini-pro-2.5"},
supports_vision=True,
supports_tools=True,
context_window=2000000,
max_output=8192,
api_key_env="GEMINI_API_KEY",
cli_command="gemini",
))
self._register(ModelConfig(
full_name="gemini-1.5-pro",
provider=ModelProvider.GOOGLE,
aliases={"gemini", "gemini-pro", "gemini-1.5"},
supports_vision=True,
supports_tools=True,
context_window=2000000,
max_output=8192,
api_key_env="GEMINI_API_KEY",
cli_command="gemini",
))
self._register(ModelConfig(
full_name="gemini-1.5-flash",
provider=ModelProvider.GOOGLE,
aliases={"gemini-flash", "flash"},
supports_vision=True,
supports_tools=True,
context_window=1000000,
max_output=8192,
api_key_env="GEMINI_API_KEY",
cli_command="gemini",
))
# xAI models
self._register(ModelConfig(
full_name="grok-4",
provider=ModelProvider.XAI,
aliases={"grok", "xai-grok", "grok-2"}, # grok-2 for backward compat
supports_vision=True, # Grok-4 supports multimodal
supports_tools=True,
context_window=128000,
max_output=8192,
api_key_env="XAI_API_KEY",
cli_command="grok",
))
# Ollama models
self._register(ModelConfig(
full_name="ollama/llama-3.2-3b",
provider=ModelProvider.OLLAMA,
aliases={"llama", "llama-3.2", "llama3"},
supports_vision=False,
supports_tools=False,
context_window=128000,
max_output=4096,
api_key_env=None, # Local model
cli_command="ollama",
))
self._register(ModelConfig(
full_name="ollama/mistral:7b",
provider=ModelProvider.MISTRAL,
aliases={"mistral", "mistral-7b"},
supports_vision=False,
supports_tools=False,
context_window=32000,
max_output=4096,
api_key_env=None, # Local model
cli_command="ollama",
))
# DeepSeek models
self._register(ModelConfig(
full_name="deepseek-coder-v2",
provider=ModelProvider.DEEPSEEK,
aliases={"deepseek", "deepseek-coder"},
supports_vision=False,
supports_tools=True,
context_window=128000,
max_output=8192,
api_key_env="DEEPSEEK_API_KEY",
cli_command="deepseek",
))
def _register(self, config: ModelConfig) -> None:
"""Register a model configuration.
Args:
config: Model configuration to register
"""
# Register by full name
self._models[config.full_name] = config
# Register all aliases
for alias in config.aliases:
self._models[alias.lower()] = config
def get(self, model_name: str) -> Optional[ModelConfig]:
"""Get model configuration by name or alias.
Args:
model_name: Model name or alias
Returns:
Model configuration or None if not found
"""
return self._models.get(model_name.lower())
def resolve(self, model_name: str) -> str:
"""Resolve model name or alias to full model name.
Args:
model_name: Model name or alias
Returns:
Full model name, or original if not found
"""
config = self.get(model_name)
return config.full_name if config else model_name
def get_by_provider(self, provider: ModelProvider) -> List[ModelConfig]:
"""Get all unique models for a specific provider.
Args:
provider: Model provider
Returns:
List of unique model configurations
"""
seen_names = set()
results = []
for config in self._models.values():
if config.provider == provider and config.full_name not in seen_names:
seen_names.add(config.full_name)
results.append(config)
return results
def get_models_supporting(
self,
vision: Optional[bool] = None,
tools: Optional[bool] = None,
streaming: Optional[bool] = None,
) -> List[ModelConfig]:
"""Get unique models supporting specific features.
Args:
vision: Filter by vision support
tools: Filter by tool support
streaming: Filter by streaming support
Returns:
List of unique matching model configurations
"""
seen_names = set()
results = []
for config in self._models.values():
if config.full_name in seen_names:
continue
if vision is not None and config.supports_vision != vision:
continue
if tools is not None and config.supports_tools != tools:
continue
if streaming is not None and config.supports_streaming != streaming:
continue
seen_names.add(config.full_name)
results.append(config)
return results
def get_api_key_env(self, model_name: str) -> Optional[str]:
"""Get the API key environment variable for a model.
Args:
model_name: Model name or alias
Returns:
Environment variable name or None
"""
config = self.get(model_name)
return config.api_key_env if config else None
def get_cli_command(self, model_name: str) -> Optional[str]:
"""Get the CLI command for a model.
Args:
model_name: Model name or alias
Returns:
CLI command or None
"""
config = self.get(model_name)
return config.cli_command if config else None
def list_all_models(self) -> List[str]:
"""List all unique model full names.
Returns:
Sorted list of unique full model names
"""
seen_names = set()
for config in self._models.values():
seen_names.add(config.full_name)
return sorted(list(seen_names))
def list_all_aliases(self) -> Dict[str, str]:
"""List all aliases and their full names.
Returns:
Dictionary mapping aliases to full names
"""
result = {}
for key, config in self._models.items():
if key != config.full_name:
result[key] = config.full_name
return result
# Global singleton instance
registry = ModelRegistry()
# Convenience functions
def resolve_model(model_name: str) -> str:
"""Resolve model name or alias to full model name.
Args:
model_name: Model name or alias
Returns:
Full model name
"""
return registry.resolve(model_name)
def get_model_config(model_name: str) -> Optional[ModelConfig]:
"""Get model configuration.
Args:
model_name: Model name or alias
Returns:
Model configuration or None
"""
return registry.get(model_name)
def get_api_key_env(model_name: str) -> Optional[str]:
"""Get API key environment variable for model.
Args:
model_name: Model name or alias
Returns:
Environment variable name or None
"""
return registry.get_api_key_env(model_name)
__all__ = [
"ModelProvider",
"ModelConfig",
"ModelRegistry",
"registry",
"resolve_model",
"get_model_config",
"get_api_key_env",
]
@@ -159,8 +159,12 @@ class DevServer:
# Since MCP servers run in the same process, we need to handle this differently
# For now, we'll log a message indicating a restart is needed
logger = logging.getLogger(__name__)
logger.warning("\n⚠️ Server restart required. Please restart the MCP client to reload changes.")
logger.info("💡 Tip: In development, consider using the MCP test client for easier reloading.")
logger.warning(
"\n⚠️ Server restart required. Please restart the MCP client to reload changes."
)
logger.info(
"💡 Tip: In development, consider using the MCP test client for easier reloading."
)
async def run_async(self, transport: str = "stdio"):
"""Run the development server asynchronously."""
@@ -214,8 +218,12 @@ def run_dev_server():
"""Entry point for development server."""
import argparse
parser = argparse.ArgumentParser(description="Run Hanzo AI in development mode with hot reload")
parser.add_argument("--name", type=str, default="hanzo-dev", help="Name of the MCP server")
parser = argparse.ArgumentParser(
description="Run Hanzo AI in development mode with hot reload"
)
parser.add_argument(
"--name", type=str, default="hanzo-dev", help="Name of the MCP server"
)
parser.add_argument("--project-dir", type=str, help="Project directory to serve")
parser.add_argument(
"--allowed-path",
@@ -231,7 +239,9 @@ def run_dev_server():
choices=["stdio", "sse"],
help="Transport type (default: stdio)",
)
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host for SSE transport")
parser.add_argument(
"--host", type=str, default="127.0.0.1", help="Host for SSE transport"
)
parser.add_argument("--port", type=int, default=3000, help="Port for SSE transport")
args = parser.parse_args()
@@ -52,7 +52,9 @@ def create_project_system_prompt(project_path: str):
platform, _, os_version = get_os_info()
# Get directory structure
directory_structure = get_directory_structure(working_directory, max_depth=3, include_filtered=False)
directory_structure = get_directory_structure(
working_directory, max_depth=3, include_filtered=False
)
# Get git information
git_info = get_git_info(working_directory)
@@ -76,7 +78,9 @@ def create_project_system_prompt(project_path: str):
return project_system_prompt
def register_all_prompts(mcp_server: FastMCP, projects: list[str] | None = None) -> None:
def register_all_prompts(
mcp_server: FastMCP, projects: list[str] | None = None
) -> None:
@mcp_server.prompt(name="Compact current conversation")
def compact() -> str:
"""
@@ -49,9 +49,13 @@ def format_todo_list_concise(todos: list[dict[str, Any]]) -> str:
}.get(status, "[?]")
# Create priority indicator
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(priority, "⚪")
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(
priority, "⚪"
)
formatted_lines.append(f"{status_indicator} {priority_indicator} {content} (id: {todo_id})")
formatted_lines.append(
f"{status_indicator} {priority_indicator} {content} (id: {todo_id})"
)
return "\n".join(formatted_lines)
@@ -101,4 +105,6 @@ def get_project_todo_reminder(session_id: str | None = None) -> str:
# Format the todo list and return the reminder with content
formatted_todos = format_todo_list_concise(todos)
return PROJECT_TODO_REMINDER.format(session_id=session_id, todo_list=formatted_todos)
return PROJECT_TODO_REMINDER.format(
session_id=session_id, todo_list=formatted_todos
)
@@ -596,7 +596,9 @@ def create_tool_category_prompt(category: str, tools: list[str]):
"batch": BATCH_TOOL_EXAMPLES,
}
base_prompt = tool_descriptions.get(category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n")
base_prompt = tool_descriptions.get(
category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n"
)
if category not in tool_descriptions:
base_prompt += "\n".join(f"- **{tool}**: [Tool description]" for tool in tools)
@@ -51,7 +51,9 @@ def get_os_info() -> tuple[str, str, str]:
return system, release, version
def get_directory_structure(path: str, max_depth: int = 3, include_filtered: bool = False) -> str:
def get_directory_structure(
path: str, max_depth: int = 3, include_filtered: bool = False
) -> str:
"""Get a directory structure similar to directory_tree tool.
Args:
@@ -100,7 +102,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
try:
# Sort entries: directories first, then files alphabetically
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
entries = sorted(
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
)
for entry in entries:
if entry.is_dir():
@@ -143,7 +147,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
# Format based on type
if item["type"] == "directory":
if "skipped" in item:
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
lines.append(
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
)
else:
lines.append(f"{indent}{item['name']}/")
# Add children with increased indentation if present
@@ -222,7 +228,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
change_type = item.change_type
status_lines.append(f"{change_type[0].upper()} {item.a_path}")
if len(staged_files) > 25:
status_lines.append(f"... and {len(staged_files) - 25} more staged files")
status_lines.append(
f"... and {len(staged_files) - 25} more staged files"
)
# Check for unstaged changes
unstaged_files = list(repo.index.diff(None))
@@ -230,7 +238,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
for item in unstaged_files[:25]: # Limit to first 25
status_lines.append(f"M {item.a_path}")
if len(unstaged_files) > 25:
status_lines.append(f"... and {len(unstaged_files) - 25} more modified files")
status_lines.append(
f"... and {len(unstaged_files) - 25} more modified files"
)
# Check for untracked files
untracked_files = repo.untracked_files
@@ -238,9 +248,13 @@ def get_git_info(path: str) -> dict[str, str | None]:
for file in untracked_files[:25]: # Limit to first 25
status_lines.append(f"?? {file}")
if len(untracked_files) > 25:
status_lines.append(f"... and {len(untracked_files) - 25} more untracked files")
status_lines.append(
f"... and {len(untracked_files) - 25} more untracked files"
)
git_status = "\n".join(status_lines) if status_lines else "Working tree clean"
git_status = (
"\n".join(status_lines) if status_lines else "Working tree clean"
)
except Exception:
git_status = "Unable to get git status"
+6 -2
View File
@@ -9,7 +9,9 @@ import threading
from typing import Literal, cast, final
# Suppress litellm deprecation warnings about event loop
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
warnings.filterwarnings(
"ignore", message="There is no current event loop", category=DeprecationWarning
)
try:
from fastmcp import FastMCP
@@ -171,7 +173,9 @@ class HanzoMCPServer:
signal.signal(signal.SIGINT, signal_handler)
# Start background cleanup thread for periodic cleanup
self._cleanup_thread = threading.Thread(target=self._background_cleanup, daemon=True)
self._cleanup_thread = threading.Thread(
target=self._background_cleanup, daemon=True
)
self._cleanup_thread.start()
self._cleanup_registered = True
@@ -1,55 +1,70 @@
"""Tools package for Hanzo AI.
This package contains all the tools for the Hanzo AI server.
It provides a interface for registering all tools with an MCP server.
This package contains all tools for the Hanzo MCP server. To keep imports
robust across environments (e.g., older Python during CI), heavy imports are
guarded. Submodules can still be imported directly, e.g.:
This includes a "think" tool implementation based on Anthropic's research showing
improved performance for complex tool-based interactions when Claude has a dedicated
space for structured thinking. It also includes an "agent" tool that enables Claude
to delegate tasks to sub-agents for concurrent execution and specialized processing.
from hanzo_mcp.tools.llm.llm_tool import LLMTool
"""
from mcp.server import FastMCP
# Defer annotation evaluation to avoid import-time NameErrors in constrained envs
from __future__ import annotations
from hanzo_mcp.tools.todo import register_todo_tools
from hanzo_mcp.tools.agent import register_agent_tools
from hanzo_mcp.tools.shell import register_shell_tools
from hanzo_mcp.tools.common import (
register_batch_tool,
register_critic_tool,
register_thinking_tool,
)
from hanzo_mcp.tools.vector import register_vector_tools
from hanzo_mcp.tools.jupyter import register_jupyter_tools
from hanzo_mcp.tools.database import DatabaseManager, register_database_tools
from hanzo_mcp.tools.filesystem import register_filesystem_tools
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.stats import StatsTool
from hanzo_mcp.tools.common.tool_list import ToolListTool
from hanzo_mcp.tools.common.permissions import PermissionManager
from hanzo_mcp.tools.common.tool_enable import ToolEnableTool
from hanzo_mcp.tools.common.tool_disable import ToolDisableTool
# NOTE: Keep top-level imports resilient for environments without optional deps
try: # pragma: no cover - import guards for CI environments
from mcp.server import FastMCP # type: ignore
except Exception: # pragma: no cover
# Try to import memory tools, but don't fail if hanzo-memory is not installed
try:
from hanzo_mcp.tools.memory import register_memory_tools
class FastMCP: # type: ignore
pass
MEMORY_TOOLS_AVAILABLE = True
except ImportError:
MEMORY_TOOLS_AVAILABLE = False
register_memory_tools = None
from hanzo_mcp.tools.llm import (
LLMTool,
ConsensusTool,
LLMManageTool,
create_provider_tools,
)
from hanzo_mcp.tools.mcp import MCPTool, McpAddTool, McpStatsTool, McpRemoveTool
from hanzo_mcp.tools.editor import NeovimEditTool, NeovimCommandTool, NeovimSessionTool
from hanzo_mcp.tools.common.mode import activate_mode_from_env
from hanzo_mcp.tools.config.mode_tool import mode_tool
from hanzo_mcp.tools.common.mode_loader import ModeLoader
from hanzo_mcp.tools.common.plugin_loader import load_user_plugins
try: # pragma: no cover
from hanzo_mcp.tools.llm import (
LLMTool,
ConsensusTool,
LLMManageTool,
create_provider_tools,
)
from hanzo_mcp.tools.mcp import (
MCPTool,
McpAddTool,
McpStatsTool,
McpRemoveTool,
)
from hanzo_mcp.tools.todo import register_todo_tools
from hanzo_mcp.tools.agent import register_agent_tools
from hanzo_mcp.tools.shell import register_shell_tools
from hanzo_mcp.tools.editor import (
NeovimEditTool,
NeovimCommandTool,
NeovimSessionTool,
)
from hanzo_mcp.tools.vector import register_vector_tools
from hanzo_mcp.tools.jupyter import register_jupyter_tools
from hanzo_mcp.tools.database import DatabaseManager, register_database_tools
from hanzo_mcp.tools.filesystem import register_filesystem_tools
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.mode import activate_mode_from_env
from hanzo_mcp.tools.common.stats import StatsTool
from hanzo_mcp.tools.common.tool_list import ToolListTool
from hanzo_mcp.tools.config.mode_tool import mode_tool
from hanzo_mcp.tools.common.mode_loader import ModeLoader
from hanzo_mcp.tools.common.permissions import PermissionManager
from hanzo_mcp.tools.common.tool_enable import ToolEnableTool
from hanzo_mcp.tools.common.tool_disable import ToolDisableTool
# Try memory tools
try:
from hanzo_mcp.tools.memory import register_memory_tools
MEMORY_TOOLS_AVAILABLE = True
except Exception:
MEMORY_TOOLS_AVAILABLE = False
register_memory_tools = None # type: ignore
except Exception:
# Minimal surface to allow submodule imports elsewhere
pass
# Try to import LSP tool
try:
@@ -109,7 +124,9 @@ def register_all_tools(
logger = logging.getLogger(__name__)
if plugins:
logger.info(f"Loaded {len(plugins)} user plugin tools: {', '.join(plugins.keys())}")
logger.info(
f"Loaded {len(plugins)} user plugin tools: {', '.join(plugins.keys())}"
)
except Exception as e:
import logging
@@ -122,7 +139,9 @@ def register_all_tools(
# First check for mode activation from environment
activate_mode_from_env()
tool_config = ModeLoader.get_enabled_tools_from_mode(base_enabled_tools=enabled_tools, force_mode=force_mode)
tool_config = ModeLoader.get_enabled_tools_from_mode(
base_enabled_tools=enabled_tools, force_mode=force_mode
)
# Apply mode environment variables
ModeLoader.apply_environment_from_mode()
else:
@@ -167,7 +186,9 @@ def register_all_tools(
search_paths = [str(path) for path in permission_manager.allowed_paths]
project_manager = ProjectVectorManager(
global_db_path=vector_config.get("data_path"),
embedding_model=vector_config.get("embedding_model", "text-embedding-3-small"),
embedding_model=vector_config.get(
"embedding_model", "text-embedding-3-small"
),
dimension=vector_config.get("dimension", 1536),
)
# Auto-detect projects from search paths
@@ -194,7 +215,9 @@ def register_all_tools(
}
if any(jupyter_enabled.values()):
jupyter_tools = register_jupyter_tools(mcp_server, permission_manager, enabled_tools=jupyter_enabled)
jupyter_tools = register_jupyter_tools(
mcp_server, permission_manager, enabled_tools=jupyter_enabled
)
for tool in jupyter_tools:
all_tools[tool.name] = tool
@@ -205,7 +228,11 @@ def register_all_tools(
all_tools[tool.name] = tool
# Register agent tools if enabled
agent_enabled = enable_agent_tool or is_tool_enabled("agent", False) or is_tool_enabled("dispatch_agent", False)
agent_enabled = (
enable_agent_tool
or is_tool_enabled("agent", False)
or is_tool_enabled("dispatch_agent", False)
)
swarm_enabled = is_tool_enabled("swarm", False)
if agent_enabled or swarm_enabled:
@@ -221,7 +248,12 @@ def register_all_tools(
)
# Filter based on what's enabled
for tool in agent_tools:
if tool.name == "agent" and agent_enabled or tool.name == "swarm" and swarm_enabled:
if (
tool.name == "agent"
and agent_enabled
or tool.name == "swarm"
and swarm_enabled
):
all_tools[tool.name] = tool
elif tool.name in ["claude", "codex", "gemini", "grok", "code_auth"]:
# CLI tools and auth are always included when agent tools are enabled
@@ -8,16 +8,26 @@ from mcp.server import FastMCP
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
# Import unified CLI tools (single source of truth)
from hanzo_mcp.tools.agent.cli_tools import (
GrokCLITool,
AiderCLITool,
ClineCLITool,
CodexCLITool,
ClaudeCLITool,
GeminiCLITool,
HanzoDevCLITool,
OpenHandsCLITool,
ClaudeCodeCLITool, # cc alias
OpenHandsShortCLITool, # oh alias
register_cli_tools,
)
# Import the main implementations (using hanzo-agents SDK)
from hanzo_mcp.tools.agent.agent_tool import AgentTool
from hanzo_mcp.tools.agent.swarm_tool import SwarmTool
from hanzo_mcp.tools.agent.network_tool import NetworkTool
from hanzo_mcp.tools.common.permissions import PermissionManager
from hanzo_mcp.tools.agent.grok_cli_tool import GrokCLITool
from hanzo_mcp.tools.agent.code_auth_tool import CodeAuthTool
from hanzo_mcp.tools.agent.codex_cli_tool import CodexCLITool
from hanzo_mcp.tools.agent.claude_cli_tool import ClaudeCLITool
from hanzo_mcp.tools.agent.gemini_cli_tool import GeminiCLITool
def register_agent_tools(
@@ -57,37 +67,48 @@ def register_agent_tools(
max_tool_uses=agent_max_tool_uses,
)
# Create swarm tool
swarm_tool = SwarmTool(
permission_manager=permission_manager,
model=agent_model,
api_key=agent_api_key,
base_url=agent_base_url,
max_tokens=agent_max_tokens,
agent_max_iterations=agent_max_iterations,
agent_max_tool_uses=agent_max_tool_uses,
)
# Register a swarm alias that forwards to AgentTool with default concurrency
class SwarmAliasTool(BaseTool):
name = "swarm"
description = (
"Alias for agent with concurrency. swarm == agent:5 by default.\n"
"Use 'swarm' for parallel multi-agent runs; 'swarm:N' for N agents."
)
# Create CLI agent tools
claude_cli_tool = ClaudeCLITool(
permission_manager=permission_manager,
model=agent_model, # Can override default Sonnet
)
def __init__(self, agent_tool: AgentTool):
self._agent = agent_tool
codex_cli_tool = CodexCLITool(
permission_manager=permission_manager,
model=agent_model if agent_model and "gpt" in agent_model else None,
)
async def call(self, ctx, **params): # type: ignore[override]
# Default to 5 agents unless explicitly provided
params = dict(params)
params.setdefault("concurrency", 5)
return await self._agent.call(ctx, **params)
gemini_cli_tool = GeminiCLITool(
permission_manager=permission_manager,
model=agent_model if agent_model and "gemini" in agent_model else None,
)
def register(self, mcp_server: FastMCP): # type: ignore[override]
tool_self = self
grok_cli_tool = GrokCLITool(
permission_manager=permission_manager,
model=agent_model if agent_model and "grok" in agent_model else None,
)
@mcp_server.tool(name=self.name, description=self.description)
async def swarm(
ctx,
prompts: str | list[str], # forwarded
concurrency: int | None = None,
model: str | None = None,
use_memory: bool | None = None,
memory_backend: str | None = None,
) -> str:
p = {
"prompts": prompts,
}
if concurrency is not None:
p["concurrency"] = concurrency
if model is not None:
p["model"] = model
if use_memory is not None:
p["use_memory"] = use_memory
if memory_backend is not None:
p["memory_backend"] = memory_backend
return await tool_self.call(ctx, **p)
return tool_self
# Create auth management tool
code_auth_tool = CodeAuthTool()
@@ -98,24 +119,14 @@ def register_agent_tools(
default_mode="hybrid", # Prefer local, fallback to cloud
)
# Register tools
# Register core agent tools
ToolRegistry.register_tool(mcp_server, agent_tool)
ToolRegistry.register_tool(mcp_server, swarm_tool)
ToolRegistry.register_tool(mcp_server, SwarmAliasTool(agent_tool))
ToolRegistry.register_tool(mcp_server, network_tool)
ToolRegistry.register_tool(mcp_server, claude_cli_tool)
ToolRegistry.register_tool(mcp_server, codex_cli_tool)
ToolRegistry.register_tool(mcp_server, gemini_cli_tool)
ToolRegistry.register_tool(mcp_server, grok_cli_tool)
ToolRegistry.register_tool(mcp_server, code_auth_tool)
# Register all CLI tools (includes claude, codex, gemini, grok, etc.)
cli_tools = register_cli_tools(mcp_server, permission_manager)
# Return list of registered tools
return [
agent_tool,
swarm_tool,
network_tool,
claude_cli_tool,
codex_cli_tool,
gemini_cli_tool,
grok_cli_tool,
code_auth_tool,
]
return [agent_tool, network_tool, code_auth_tool] + cli_tools
@@ -118,17 +118,23 @@ class AgentParams(TypedDict, total=False):
class RPCAgent:
"""Long-running RPC agent."""
def __init__(self, agent_id: str, model: str, system_prompt: str, tools: List[BaseTool]):
def __init__(
self, agent_id: str, model: str, system_prompt: str, tools: List[BaseTool]
):
self.agent_id = agent_id
self.model = model
self.system_prompt = system_prompt
self.tools = tools
self.messages: List[ChatCompletionMessageParam] = [{"role": "system", "content": system_prompt}]
self.messages: List[ChatCompletionMessageParam] = [
{"role": "system", "content": system_prompt}
]
self.created_at = time.time()
self.last_used = time.time()
self.call_count = 0
async def call_method(self, method: str, args: Dict[str, Any], tool_ctx: ToolContext) -> str:
async def call_method(
self, method: str, args: Dict[str, Any], tool_ctx: ToolContext
) -> str:
"""Call a method on the RPC agent."""
self.last_used = time.time()
self.call_count += 1
@@ -183,9 +189,15 @@ class AgentTool(BaseTool):
# Available tools
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
self.available_tools.extend(
get_read_only_filesystem_tools(self.permission_manager)
)
self.available_tools.extend(
get_read_only_jupyter_tools(self.permission_manager)
)
self.available_tools.append(
BatchTool({t.name: t for t in self.available_tools})
)
@property
@override
@@ -260,10 +272,14 @@ Modes:
if len(prompt_list) == 1:
await tool_ctx.info("Launching agent")
result = await self._execute_agent(prompt_list[0], params.get("model"), tool_ctx)
result = await self._execute_agent(
prompt_list[0], params.get("model"), tool_ctx
)
else:
await tool_ctx.info(f"Launching {len(prompt_list)} agents in parallel")
result = await self._execute_multiple_agents(prompt_list, params.get("model"), tool_ctx)
result = await self._execute_multiple_agents(
prompt_list, params.get("model"), tool_ctx
)
execution_time = time.time() - start_time
@@ -373,13 +389,17 @@ Use 'agent --action call --agent-id {agent_id} --method <method> --args <args>'
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
return bool(re.search(absolute_path_pattern, prompt))
async def _execute_agent(self, prompt: str, model: Optional[str], tool_ctx: ToolContext) -> str:
async def _execute_agent(
self, prompt: str, model: Optional[str], tool_ctx: ToolContext
) -> str:
"""Execute a single agent (simplified - would use full logic from agent_tool.py)."""
# This would integrate the full agent execution logic from agent_tool.py
# For now, return a placeholder
return f"Executed agent with prompt: {prompt[:100]}..."
async def _execute_multiple_agents(self, prompts: List[str], model: Optional[str], tool_ctx: ToolContext) -> str:
async def _execute_multiple_agents(
self, prompts: List[str], model: Optional[str], tool_ctx: ToolContext
) -> str:
"""Execute multiple agents in parallel."""
tasks = []
for prompt in prompts:
@@ -109,6 +109,7 @@ class AgentToolParams(TypedDict, total=False):
model: Optional[str]
use_memory: Optional[bool]
memory_backend: Optional[str]
concurrency: Optional[int]
class MCPAgentState(State):
@@ -196,7 +197,9 @@ class MCPAgent(Agent):
adapter = MCPToolAdapter(mcp_tool, ctx)
self.register_tool(adapter)
async def run(self, state: MCPAgentState, history: History, network: Network) -> InferenceResult:
async def run(
self, state: MCPAgentState, history: History, network: Network
) -> InferenceResult:
"""Execute the agent."""
# Get current prompt
if state.current_prompt_index >= len(state.prompts):
@@ -321,8 +324,12 @@ Usage notes:
# Set up available tools
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
self.available_tools.extend(
get_read_only_filesystem_tools(self.permission_manager)
)
self.available_tools.extend(
get_read_only_jupyter_tools(self.permission_manager)
)
# Add edit tools
self.available_tools.append(Edit(self.permission_manager))
@@ -334,7 +341,9 @@ Usage notes:
self.available_tools.append(ReviewTool())
self.available_tools.append(IChingTool())
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
self.available_tools.append(
BatchTool({t.name: t for t in self.available_tools})
)
@override
async def call(
@@ -379,8 +388,20 @@ Usage notes:
await tool_ctx.error("hanzo-agents SDK is required but not available")
return "Error: hanzo-agents SDK is required for agent tool functionality. Please install it with: pip install hanzo-agents"
# Use hanzo-agents SDK
await tool_ctx.info(f"Launching {len(prompt_list)} agent(s) using hanzo-agents SDK")
# Determine concurrency (parallel agents)
concurrency = params.get("concurrency")
if concurrency is not None and isinstance(concurrency, int) and concurrency > 0:
# Expand prompt list to match concurrency
if len(prompt_list) == 1:
prompt_list = prompt_list * concurrency
elif len(prompt_list) < concurrency:
# Repeat prompts to reach concurrency
times = (concurrency + len(prompt_list) - 1) // len(prompt_list)
prompt_list = (prompt_list * times)[:concurrency]
await tool_ctx.info(
f"Launching {len(prompt_list)} agent(s) using hanzo-agents SDK"
)
# Determine model and agent type
model = params.get("model", self.model_override)
@@ -151,8 +151,12 @@ Usage notes:
self.max_iterations = max_iterations
self.max_tool_uses = max_tool_uses
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
self.available_tools.extend(
get_read_only_filesystem_tools(self.permission_manager)
)
self.available_tools.extend(
get_read_only_jupyter_tools(self.permission_manager)
)
# Always add edit tools - agents should have edit access
self.available_tools.append(Edit(self.permission_manager))
@@ -170,7 +174,9 @@ Usage notes:
# Add I Ching tool for creative guidance
self.available_tools.append(IChingTool())
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
self.available_tools.append(
BatchTool({t.name: t for t in self.available_tools})
)
# Initialize protocols
self.critic_protocol = CriticProtocol()
@@ -233,7 +239,9 @@ Example of correct usage:
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
for prompt in prompt_list:
if not re.search(absolute_path_pattern, prompt):
await tool_ctx.error(f"Prompt does not contain absolute path: {prompt[:50]}...")
await tool_ctx.error(
f"Prompt does not contain absolute path: {prompt[:50]}..."
)
return """Error: All prompts must contain at least one absolute path.
IMPORTANT REMINDER FOR CLAUDE:
@@ -263,7 +271,9 @@ AGENT RESPONSE:
AGENT RESPONSES:
{result}"""
await tool_ctx.info(f"Multi-agent execution completed in {execution_time:.2f}s")
await tool_ctx.info(
f"Multi-agent execution completed in {execution_time:.2f}s"
)
return formatted_result
async def _execute_agent(self, prompt: str, tool_ctx: ToolContext) -> str:
@@ -300,7 +310,9 @@ AGENT RESPONSES:
# Execute agent
await tool_ctx.info(f"Executing agent task: {prompt[:50]}...")
result = await self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
result = await self._execute_agent_with_tools(
system_prompt, prompt, agent_tools, openai_tools, tool_ctx
)
except Exception as e:
# Log and return error result
error_message = f"Error executing agent: {str(e)}"
@@ -309,7 +321,9 @@ AGENT RESPONSES:
return result if result else "No results returned from agent"
async def _execute_multiple_agents(self, prompts: list[str], tool_ctx: ToolContext) -> str:
async def _execute_multiple_agents(
self, prompts: list[str], tool_ctx: ToolContext
) -> str:
"""Execute multiple agents concurrently.
Args:
@@ -338,7 +352,9 @@ AGENT RESPONSES:
tasks = []
for i, prompt in enumerate(prompts):
await tool_ctx.info(f"Creating agent task {i + 1}: {prompt[:50]}...")
task = self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
task = self._execute_agent_with_tools(
system_prompt, prompt, agent_tools, openai_tools, tool_ctx
)
tasks.append(task)
# Execute all agents concurrently
@@ -397,7 +413,9 @@ AGENT RESPONSES:
total_tool_use_count = 0
iteration_count = 0
max_tool_uses = self.max_tool_uses # Safety limit to prevent infinite loops
max_iterations = self.max_iterations # Add a maximum number of iterations for safety
max_iterations = (
self.max_iterations
) # Add a maximum number of iterations for safety
# Execute until the agent completes or reaches the limit
while total_tool_use_count < max_tool_uses and iteration_count < max_iterations:
@@ -462,7 +480,9 @@ AGENT RESPONSES:
function_args = {}
# Find the matching tool
tool = next((t for t in available_tools if t.name == function_name), None)
tool = next(
(t for t in available_tools if t.name == function_name), None
)
if not tool:
tool_result = f"Error: Tool '{function_name}' not found"
# Special handling for clarification requests
@@ -485,7 +505,9 @@ AGENT RESPONSES:
options=options,
)
tool_result = self.format_clarification_in_output(question, answer)
tool_result = self.format_clarification_in_output(
question, answer
)
except Exception as e:
tool_result = f"Error processing clarification: {str(e)}"
# Special handling for critic requests
@@ -530,7 +552,9 @@ AGENT RESPONSES:
tool_result = f"Error processing review: {str(e)}"
else:
try:
tool_result = await tool.call(ctx=tool_ctx.mcp_context, **function_args)
tool_result = await tool.call(
ctx=tool_ctx.mcp_context, **function_args
)
except Exception as e:
tool_result = f"Error executing {function_name}: {str(e)}"
@@ -548,7 +572,9 @@ AGENT RESPONSES:
)
# Log progress
await tool_ctx.info(f"Processed {len(message.tool_calls)} tool calls. Total: {total_tool_use_count}")
await tool_ctx.info(
f"Processed {len(message.tool_calls)} tool calls. Total: {total_tool_use_count}"
)
except Exception as e:
await tool_ctx.error(f"Error in model call: {str(e)}")
@@ -576,7 +602,8 @@ AGENT RESPONSES:
)
return (
final_response.choices[0].message.content or "Agent reached max iteration limit without a response."
final_response.choices[0].message.content
or "Agent reached max iteration limit without a response."
) # pyright: ignore
except Exception as e:
await tool_ctx.error(f"Error in final model call: {str(e)}")
@@ -96,7 +96,9 @@ class ClaudeDesktopAuth:
except Exception:
return None
async def login_interactive(self, account: Optional[str] = None, headless: bool = False) -> Tuple[bool, str]:
async def login_interactive(
self, account: Optional[str] = None, headless: bool = False
) -> Tuple[bool, str]:
"""Login to Claude Desktop interactively.
Args:
@@ -190,7 +192,9 @@ class ClaudeDesktopAuth:
# For now, return a placeholder
return False, "Headless login not yet implemented"
async def _exchange_code_for_session(self, code: str, account: Optional[str]) -> bool:
async def _exchange_code_for_session(
self, code: str, account: Optional[str]
) -> bool:
"""Exchange auth code for session token."""
# This would make API calls to exchange the code
# For now, create a mock session
@@ -353,7 +357,9 @@ class ClaudeDesktopAuth:
# Generate agent-specific account
return f"agent_{agent_id}@claude.local"
async def ensure_agent_auth(self, agent_id: str, force_new: bool = False) -> Tuple[bool, str]:
async def ensure_agent_auth(
self, agent_id: str, force_new: bool = False
) -> Tuple[bool, str]:
"""Ensure an agent is authenticated with its own account.
Args:
@@ -97,7 +97,9 @@ class CLIAgentBase(BaseTool):
# Check if installed
if not self.is_installed():
error_msg = f"{self.provider_name} CLI ({self.command_name}) is not installed. "
error_msg = (
f"{self.provider_name} CLI ({self.command_name}) is not installed. "
)
error_msg += f"Please install it first: https://github.com/anthropics/{self.command_name}"
await tool_ctx.error(error_msg)
return f"Error: {error_msg}"
@@ -113,11 +115,15 @@ class CLIAgentBase(BaseTool):
cli_args = self.get_cli_args(prompt, **kwargs)
# Log command
await tool_ctx.info(f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}...")
await tool_ctx.info(
f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}..."
)
try:
# Create temp file for prompt if needed
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
with tempfile.NamedTemporaryFile(
mode="w", suffix=".txt", delete=False
) as f:
f.write(prompt)
prompt_file = f.name
@@ -125,7 +131,12 @@ class CLIAgentBase(BaseTool):
if "--prompt-file" in cli_args:
# Replace placeholder with actual file
cli_args = [
(arg.replace("--prompt-file", prompt_file) if arg == "--prompt-file" else arg) for arg in cli_args
(
arg.replace("--prompt-file", prompt_file)
if arg == "--prompt-file"
else arg
)
for arg in cli_args
]
# Execute command
@@ -140,9 +151,13 @@ class CLIAgentBase(BaseTool):
# Send prompt via stdin if not using file
if "--prompt-file" not in cli_args:
stdout, stderr = await asyncio.wait_for(process.communicate(input=prompt.encode()), timeout=timeout)
stdout, stderr = await asyncio.wait_for(
process.communicate(input=prompt.encode()), timeout=timeout
)
else:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
stdout, stderr = await asyncio.wait_for(
process.communicate(), timeout=timeout
)
# Clean up temp file
try:
@@ -160,7 +175,9 @@ class CLIAgentBase(BaseTool):
return result
except asyncio.TimeoutError:
await tool_ctx.error(f"{self.provider_name} timed out after {timeout} seconds")
await tool_ctx.error(
f"{self.provider_name} timed out after {timeout} seconds"
)
return f"Error: Command timed out after {timeout} seconds"
except Exception as e:
await tool_ctx.error(f"{self.provider_name} error: {str(e)}")
@@ -0,0 +1,543 @@
"""CLI tool implementations for direct batch execution.
This module provides CLI tool wrappers that can be used directly in batch operations,
including claude (cc), codex, gemini, grok, openhands (oh), hanzo-dev, cline, and aider.
"""
from __future__ import annotations
import os
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 import FastMCP
from mcp.server.fastmcp import Context
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.context import create_tool_context
from hanzo_mcp.tools.common.permissions import PermissionManager
# Parameter types for CLI tools
Prompt = Annotated[
str,
Field(
description="The prompt or command to send to the CLI tool",
min_length=1,
),
]
Model = Annotated[
Optional[str],
Field(
description="Optional model override for the CLI tool",
default=None,
),
]
WorkingDir = Annotated[
Optional[str],
Field(
description="Working directory for the command",
default=None,
),
]
Timeout = Annotated[
Optional[int],
Field(
description="Timeout in seconds for the command",
default=300, # 5 minutes default
),
]
class CLIToolParams(TypedDict, total=False):
"""Common parameters for CLI tools."""
prompt: str
model: Optional[str]
working_dir: Optional[str]
timeout: Optional[int]
class BaseCLITool(BaseTool):
"""Base class for CLI tool implementations."""
def __init__(
self,
permission_manager: Optional[PermissionManager] = None,
default_model: Optional[str] = None,
api_key_env: Optional[str] = None,
):
"""Initialize CLI tool.
Args:
permission_manager: Permission manager for access control
default_model: Default model to use
api_key_env: Environment variable name for API key
"""
self.permission_manager = permission_manager
self.default_model = default_model
self.api_key_env = api_key_env
def get_auth_env(self) -> dict[str, str]:
"""Get authentication environment variables."""
env = os.environ.copy()
# Add API key if configured
if self.api_key_env and self.api_key_env in os.environ:
env[self.api_key_env] = os.environ[self.api_key_env]
# Add Hanzo API key for unified auth
if "HANZO_API_KEY" in os.environ:
env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
return env
async def execute_cli(
self,
command: list[str],
input_text: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
"""Execute CLI command with proper error handling.
Args:
command: Command and arguments
input_text: Optional stdin input
working_dir: Working directory
timeout: Timeout in seconds
Returns:
Command output
"""
try:
# Set up environment with auth
env = self.get_auth_env()
# Execute command
process = await asyncio.create_subprocess_exec(
*command,
stdin=asyncio.subprocess.PIPE if input_text else None,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
cwd=working_dir,
env=env,
)
# Send input and get output
stdout, stderr = await asyncio.wait_for(
process.communicate(input_text.encode() if input_text else None),
timeout=timeout,
)
# Check for errors
if process.returncode != 0:
error_msg = stderr.decode() if stderr else "Unknown error"
return f"Error: {error_msg}"
return stdout.decode()
except asyncio.TimeoutError:
return f"Error: Command timed out after {timeout} seconds"
except Exception as e:
return f"Error executing command: {str(e)}"
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self # Create a reference to self for use in the closure
@mcp_server.tool(name=self.name, description=self.description)
async def tool_wrapper(
prompt: str,
ctx: Context[Any, Any, Any],
model: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
result: str = await tool_self.call(
ctx, prompt=prompt, model=model, working_dir=working_dir, timeout=timeout
)
return result
class ClaudeCLITool(BaseCLITool):
"""Claude CLI tool (also available as 'cc' alias)."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="ANTHROPIC_API_KEY",
)
@property
def name(self) -> str:
return "claude"
@property
def description(self) -> str:
return "Execute Claude CLI for AI assistance using Anthropic's models"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["claude"]
if model:
command.extend(["--model", model])
# Execute
return await self.execute_cli(
command,
input_text=prompt,
working_dir=working_dir,
timeout=timeout,
)
class ClaudeCodeCLITool(ClaudeCLITool):
"""Claude Code CLI tool (cc alias)."""
@property
def name(self) -> str:
return "cc"
@property
def description(self) -> str:
return "Claude Code CLI (alias for claude)"
class CodexCLITool(BaseCLITool):
"""OpenAI Codex/GPT-4 CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gpt-4-turbo",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "codex"
@property
def description(self) -> str:
return "Execute OpenAI Codex/GPT-4 CLI for code generation and AI assistance"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command (using openai CLI or custom wrapper)
command: list[str] = ["openai", "api", "chat.completions.create"]
if model:
command.extend(["-m", model])
command.extend(["-g", "user", prompt])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class GeminiCLITool(BaseCLITool):
"""Google Gemini CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gemini-1.5-pro",
api_key_env="GEMINI_API_KEY",
)
@property
def name(self) -> str:
return "gemini"
@property
def description(self) -> str:
return "Execute Google Gemini CLI for multimodal AI assistance"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["gemini"]
if model:
command.extend(["--model", model])
command.append(prompt)
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class GrokCLITool(BaseCLITool):
"""xAI Grok CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="grok-4",
api_key_env="XAI_API_KEY",
)
@property
def name(self) -> str:
return "grok"
@property
def description(self) -> str:
return "Execute xAI Grok CLI for real-time AI assistance"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt: str = params.get("prompt", "")
model: Optional[str] = params.get("model") or self.default_model
working_dir: Optional[str] = params.get("working_dir")
timeout: int = params.get("timeout", 300)
# Build command
command: list[str] = ["grok"]
if model:
command.extend(["--model", model])
command.append(prompt)
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class OpenHandsCLITool(BaseCLITool):
"""OpenHands (OpenDevin) CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "openhands"
@property
def description(self) -> str:
return "Execute OpenHands (OpenDevin) for autonomous coding assistance"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600) # 10 minutes for OpenHands
# Build command
command: list[str] = ["openhands", "run", prompt]
if model:
command.extend(["--model", model])
command.extend(["--workspace", working_dir])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class OpenHandsShortCLITool(OpenHandsCLITool):
"""OpenHands CLI tool (oh alias)."""
@property
def name(self) -> str:
return "oh"
@property
def description(self) -> str:
return "OpenHands CLI (alias for openhands)"
class HanzoDevCLITool(BaseCLITool):
"""Hanzo Dev AI coding assistant."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="HANZO_API_KEY",
)
@property
def name(self) -> str:
return "hanzo_dev"
@property
def description(self) -> str:
return "Execute Hanzo Dev for AI-powered code editing and development"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["hanzo", "dev"]
if model:
command.extend(["--model", model])
command.extend(["--prompt", prompt])
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class ClineCLITool(BaseCLITool):
"""Cline (formerly Claude Engineer) CLI tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="claude-3-5-sonnet-20241022",
api_key_env="ANTHROPIC_API_KEY",
)
@property
def name(self) -> str:
return "cline"
@property
def description(self) -> str:
return "Execute Cline for autonomous coding with Claude"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["cline", prompt]
command.extend(["--no-interactive"]) # Non-interactive mode for batch
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
class AiderCLITool(BaseCLITool):
"""Aider AI pair programming tool."""
def __init__(self, permission_manager: Optional[PermissionManager] = None):
super().__init__(
permission_manager=permission_manager,
default_model="gpt-4-turbo",
api_key_env="OPENAI_API_KEY",
)
@property
def name(self) -> str:
return "aider"
@property
def description(self) -> str:
return "Execute Aider for AI pair programming"
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
prompt = params.get("prompt", "")
model = params.get("model") or self.default_model
working_dir: str = params.get("working_dir") or os.getcwd()
timeout: int = params.get("timeout", 600)
# Build command
command: list[str] = ["aider"]
if model:
command.extend(["--model", model])
command.extend(["--message", prompt])
command.extend(["--yes"]) # Auto-approve changes
command.extend(["--no-stream"]) # No streaming for batch
# Execute
return await self.execute_cli(
command,
working_dir=working_dir,
timeout=timeout,
)
def register_cli_tools(
mcp_server: FastMCP,
permission_manager: Optional[PermissionManager] = None,
) -> list[BaseTool]:
"""Register all CLI tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
Returns:
List of registered CLI tools
"""
tools: list[BaseTool] = [
ClaudeCLITool(permission_manager),
ClaudeCodeCLITool(permission_manager), # cc alias
CodexCLITool(permission_manager),
GeminiCLITool(permission_manager),
GrokCLITool(permission_manager),
OpenHandsCLITool(permission_manager),
OpenHandsShortCLITool(permission_manager), # oh alias
HanzoDevCLITool(permission_manager),
ClineCLITool(permission_manager),
AiderCLITool(permission_manager),
]
# Register each tool
for tool in tools:
tool.register(mcp_server)
return tools
# Export all CLI tool classes
__all__ = [
"ClaudeCLITool",
"ClaudeCodeCLITool",
"CodexCLITool",
"GeminiCLITool",
"GrokCLITool",
"OpenHandsCLITool",
"OpenHandsShortCLITool",
"HanzoDevCLITool",
"ClineCLITool",
"AiderCLITool",
"register_cli_tools",
]
@@ -322,7 +322,9 @@ class CodeAuthManager:
api_key = None
if parent_info.get("has_keyring"):
try:
api_key = keyring.get_password(f"hanzo-{parent_info['provider']}", parent_account)
api_key = keyring.get_password(
f"hanzo-{parent_info['provider']}", parent_account
)
except Exception:
pass
@@ -117,7 +117,9 @@ Providers: claude, openai, azure, deepseek, google, groq"""
model = params.get("model")
description = params.get("description")
success, msg = self.auth_manager.create_account(account, provider, api_key, model, description)
success, msg = self.auth_manager.create_account(
account, provider, api_key, model, description
)
return msg
elif action == "login":
@@ -146,7 +148,9 @@ Providers: claude, openai, azure, deepseek, google, groq"""
parent_account = params.get("parent_account")
# Try to create agent account
success, result = self.auth_manager.create_agent_account(agent_id, provider, parent_account)
success, result = self.auth_manager.create_agent_account(
agent_id, provider, parent_account
)
if success:
# Get credentials
@@ -117,7 +117,9 @@ class AutoCritic:
) -> str:
"""Perform automated critical review."""
review_func = self.review_patterns.get(review_type, self._review_general)
return review_func(work_description, code_snippets, file_paths, specific_concerns)
return review_func(
work_description, code_snippets, file_paths, specific_concerns
)
def _review_code_quality(
self,
@@ -135,11 +137,15 @@ class AutoCritic:
for snippet in code_snippets:
# Check for proper error handling
if "error" in snippet.lower() and "if err" not in snippet:
issues.append("❌ Missing error handling - always check errors in Go")
issues.append(
"❌ Missing error handling - always check errors in Go"
)
# Check for magic numbers
if any(char.isdigit() for char in snippet) and "const" not in snippet:
suggestions.append("💡 Consider extracting magic numbers to named constants")
suggestions.append(
"💡 Consider extracting magic numbers to named constants"
)
# Check for proper imports
if "import" in snippet:
@@ -154,7 +160,9 @@ class AutoCritic:
suggestions.append("💡 Consider edge cases and error scenarios")
if file_paths and len(file_paths) > 5:
suggestions.append("💡 Large number of files modified - consider breaking into smaller PRs")
suggestions.append(
"💡 Large number of files modified - consider breaking into smaller PRs"
)
# Build response
response = "🔍 CODE QUALITY REVIEW:\n\n"
@@ -165,7 +173,9 @@ class AutoCritic:
response += "✅ No major code quality issues detected.\n\n"
if suggestions:
response += "Suggestions for Improvement:\n" + "\n".join(suggestions) + "\n\n"
response += (
"Suggestions for Improvement:\n" + "\n".join(suggestions) + "\n\n"
)
if specific_concerns:
response += f"Regarding your concern: '{specific_concerns}'\n"
@@ -173,7 +183,9 @@ class AutoCritic:
response += "→ Imports look properly formatted. Ensure they're in the standard order: stdlib, external, internal.\n"
response += "\nOverall: " + (
"⚠️ Address the issues before proceeding." if issues else "✅ Good work, but always room for improvement!"
"⚠️ Address the issues before proceeding."
if issues
else "✅ Good work, but always room for improvement!"
)
return response
@@ -257,7 +269,9 @@ Security Checklist:
response = "🔍 COMPLETENESS REVIEW:\n\n"
if "fix" in tasks_mentioned and "test" not in tasks_mentioned:
response += "❌ No mention of tests - have you verified the fix with tests?\n"
response += (
"❌ No mention of tests - have you verified the fix with tests?\n"
)
if "import" in tasks_mentioned:
response += "✓ Import fixes mentioned\n"
@@ -368,6 +382,8 @@ class CriticProtocol:
except KeyError:
review_enum = ReviewType.GENERAL
review = self.auto_critic.review(review_enum, work_description, code_snippets, file_paths, specific_concerns)
review = self.auto_critic.review(
review_enum, work_description, code_snippets, file_paths, specific_concerns
)
return f"Review {self.review_count}/{self.max_reviews}:\n\n{review}"
@@ -423,13 +423,17 @@ class IChing:
lines += "1"
return Hexagram(lines)
def select_principles(self, hexagram: Hexagram, challenge: str) -> List[HanzoPrinciple]:
def select_principles(
self, hexagram: Hexagram, challenge: str
) -> List[HanzoPrinciple]:
"""Select relevant Hanzo principles based on hexagram and challenge."""
# Use hexagram pattern to deterministically but creatively select principles
selected = []
# Primary principle based on hexagram pattern
primary_index = sum(int(bit) * (2**i) for i, bit in enumerate(hexagram.lines)) % len(self.principles)
primary_index = sum(
int(bit) * (2**i) for i, bit in enumerate(hexagram.lines)
) % len(self.principles)
selected.append(self.principles[primary_index])
# Supporting principles based on challenge keywords
@@ -454,7 +458,9 @@ class IChing:
# Add complementary principle based on changing lines
changing_lines = hexagram.get_changing_lines()
if changing_lines:
complement_index = (primary_index + sum(changing_lines)) % len(self.principles)
complement_index = (primary_index + sum(changing_lines)) % len(
self.principles
)
selected.append(self.principles[complement_index])
# Ensure uniqueness and limit to 3-5 principles
@@ -467,7 +473,9 @@ class IChing:
return unique_selected[:5]
def generate_guidance(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
def generate_guidance(
self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str
) -> str:
"""Generate creative guidance combining I Ching wisdom and Hanzo principles."""
guidance = f"☯️ I CHING GUIDANCE FOR ENGINEERING CHALLENGE ☯️\n\n"
guidance += f"**Your Challenge:** {challenge}\n\n"
@@ -490,15 +498,21 @@ class IChing:
if "Creative" in hexagram.title:
guidance += "• This is a time for bold innovation. Don't hold back on ambitious ideas.\n"
elif "Receptive" in hexagram.title:
guidance += "• Listen deeply to user needs and system constraints before acting.\n"
guidance += (
"• Listen deeply to user needs and system constraints before acting.\n"
)
elif "Difficulty" in hexagram.title:
guidance += "• Challenges are teachers. Each obstacle reveals the path forward.\n"
guidance += (
"• Challenges are teachers. Each obstacle reveals the path forward.\n"
)
elif "Waiting" in hexagram.title:
guidance += "• Strategic patience required. Prepare thoroughly before implementation.\n"
elif "Conflict" in hexagram.title:
guidance += "• Technical disagreements? Seek data-driven resolution.\n"
elif "Peace" in hexagram.title:
guidance += "• Harmony achieved. Now build sustainably on this foundation.\n"
guidance += (
"• Harmony achieved. Now build sustainably on this foundation.\n"
)
# Principle-specific actionable advice
principle_actions = {
@@ -520,7 +534,9 @@ class IChing:
changing_lines = hexagram.get_changing_lines()
if changing_lines:
guidance += f"\n**Lines in Transition:** {', '.join(str(i + 1) for i in changing_lines)}\n"
guidance += "• Change is imminent in these areas. Prepare for transformation.\n"
guidance += (
"• Change is imminent in these areas. Prepare for transformation.\n"
)
# Final synthesis
guidance += "\n**The Way Forward:**\n"
@@ -531,7 +547,9 @@ class IChing:
return guidance
def _synthesize_action_plan(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
def _synthesize_action_plan(
self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str
) -> str:
"""Create a specific action plan based on the reading."""
plan = ""
@@ -540,15 +558,21 @@ class IChing:
plan += "1. **Diagnose systematically** - Use empirical debugging, not guesswork\n"
plan += "2. **Fix root cause** - Address the source, not just symptoms\n"
plan += "3. **Prevent recurrence** - Add tests and monitoring\n"
elif any(word in challenge.lower() for word in ["scale", "performance", "slow"]):
elif any(
word in challenge.lower() for word in ["scale", "performance", "slow"]
):
plan += "1. **Measure first** - Profile to find actual bottlenecks\n"
plan += "2. **Parallelize** - Use concurrency where possible\n"
plan += "3. **Simplify** - Remove complexity before optimizing\n"
elif any(word in challenge.lower() for word in ["design", "architect", "structure"]):
elif any(
word in challenge.lower() for word in ["design", "architect", "structure"]
):
plan += "1. **Start simple** - MVP first, elaborate later\n"
plan += "2. **Stay flexible** - Design for change\n"
plan += "3. **Think holistically** - Consider entire system\n"
elif any(word in challenge.lower() for word in ["team", "collaborate", "people"]):
elif any(
word in challenge.lower() for word in ["team", "collaborate", "people"]
):
plan += "1. **Enable autonomy** - Trust your team\n"
plan += "2. **Maintain balance** - Sustainable pace wins\n"
plan += "3. **Share knowledge** - Elevate everyone\n"
@@ -77,7 +77,9 @@ class NetworkTool(BaseTool):
"""
self.permission_manager = permission_manager
self.default_mode = default_mode
self.cluster_endpoint = cluster_endpoint or os.environ.get("HANZO_CLUSTER_ENDPOINT", "http://localhost:8000")
self.cluster_endpoint = cluster_endpoint or os.environ.get(
"HANZO_CLUSTER_ENDPOINT", "http://localhost:8000"
)
self._cluster = None
async def _ensure_cluster(self):
@@ -158,7 +160,9 @@ class NetworkTool(BaseTool):
results["results"].append(
{
"agent": "local-cluster",
"response": local_result.get("choices", [{}])[0].get("text", ""),
"response": local_result.get("choices", [{}])[0].get(
"text", ""
),
"local": True,
}
)
@@ -173,33 +177,17 @@ class NetworkTool(BaseTool):
results["error"] = f"Local execution failed: {str(e)}"
return json.dumps(results, indent=2)
# Fallback to agent-based execution
# This would use hanzo-agents or the existing swarm implementation
if not results["success"] or mode in ["distributed", "hybrid"]:
# Import swarm tool as fallback
from hanzo_mcp.tools.agent.swarm_tool import SwarmTool
# Create temporary swarm tool
swarm = SwarmTool(permission_manager=self.permission_manager, model=model_pref)
# Convert network params to swarm params
swarm_params = {
"prompts": [task] if not agents_list else agents_list,
"consensus": routing == "consensus",
"parallel": routing == "parallel",
}
# Execute via swarm
swarm_result = await swarm.call(ctx, **swarm_params)
swarm_data = json.loads(swarm_result)
# Merge results
if swarm_data.get("success"):
results["agents_used"].extend([r["agent"] for r in swarm_data.get("results", [])])
results["results"].extend(swarm_data.get("results", []))
# Agent-based execution with concurrency
if not results["success"] or mode in ["distributed", "hybrid"]:
from hanzo_mcp.tools.agent.agent_tool import AgentTool
agent = AgentTool(permission_manager=self.permission_manager, model=model_pref)
concurrency = max(1, len(agents_list)) if agents_list else 5 if routing == "parallel" else 1
agent_params = {"prompts": task, "concurrency": concurrency}
agent_result = await agent.call(ctx, **agent_params)
# Wrap agent_result as a simple result list
results["agents_used"].append("agent")
results["results"].append({"agent": "agent", "response": agent_result})
results["success"] = True
else:
results["error"] = swarm_data.get("error", "Unknown error")
except Exception as e:
results["error"] = str(e)
@@ -218,17 +206,25 @@ class NetworkTool(BaseTool):
async def network_handler(
ctx: MCPContext,
task: Annotated[str, Field(description="Task to execute on the network")],
agents: Annotated[Optional[List[str]], Field(description="Specific agents to use")] = None,
agents: Annotated[
Optional[List[str]], Field(description="Specific agents to use")
] = None,
mode: Annotated[
Optional[str],
Field(description="Execution mode: local, distributed, or hybrid"),
] = None,
model: Annotated[Optional[str], Field(description="Model preference")] = None,
model: Annotated[
Optional[str], Field(description="Model preference")
] = None,
routing: Annotated[
Optional[str],
Field(description="Routing strategy: sequential, parallel, or consensus"),
Field(
description="Routing strategy: sequential, parallel, or consensus"
),
] = None,
require_local: Annotated[
Optional[bool], Field(description="Require local-only execution")
] = None,
require_local: Annotated[Optional[bool], Field(description="Require local-only execution")] = None,
) -> str:
"""Dispatch work to agent networks."""
params = NetworkToolParams(
@@ -244,26 +240,4 @@ class NetworkTool(BaseTool):
return tool
# Alias swarm to use network tool with local-only mode
@final
class LocalSwarmTool(NetworkTool):
"""Local-only version of the network tool (swarm compatibility).
This provides backward compatibility with the swarm tool
while using local compute resources only.
"""
name = "swarm"
description = "Run agent swarms locally using hanzo-miner compute"
def __init__(self, permission_manager: PermissionManager, **kwargs):
"""Initialize as local-only network."""
super().__init__(permission_manager=permission_manager, default_mode="local", **kwargs)
@override
async def call(self, ctx: MCPContext, **params: Unpack[NetworkToolParams]) -> str:
"""Execute with local-only mode."""
# Force local mode
params["mode"] = "local"
params["require_local"] = True
return await super().call(ctx, **params)
# Remove swarm compatibility tool; swarm is an alias of agent with concurrency
@@ -139,7 +139,11 @@ def get_default_model(model_override: str | None = None) -> str:
model = os.environ.get("AGENT_MODEL", "claude-3-5-sonnet-20241022")
# Special cases for tests
if model.startswith("test-model") or "TEST_MODE" in os.environ and model == "claude-3-5-sonnet-20241022":
if (
model.startswith("test-model")
or "TEST_MODE" in os.environ
and model == "claude-3-5-sonnet-20241022"
):
return model
provider = os.environ.get("AGENT_PROVIDER", "anthropic")
@@ -83,7 +83,9 @@ review(
file_paths: Optional[List[str]] = None,
context: Optional[str] = None,
) -> str:
return await tool_self.call(ctx, focus, work_description, code_snippets, file_paths, context)
return await tool_self.call(
ctx, focus, work_description, code_snippets, file_paths, context
)
class BalancedReviewer:
@@ -208,18 +210,23 @@ class BalancedReviewer:
if code_snippets:
total_lines = sum(snippet.count("\n") + 1 for snippet in code_snippets)
avg_line_length = sum(len(line) for snippet in code_snippets for line in snippet.split("\n")) / max(
total_lines, 1
)
avg_line_length = sum(
len(line) for snippet in code_snippets for line in snippet.split("\n")
) / max(total_lines, 1)
if avg_line_length < 80:
response += "✓ Line lengths are reasonable\n"
else:
response += "• Some lines might be too long, consider breaking them up\n"
response += (
"• Some lines might be too long, consider breaking them up\n"
)
# Check naming
has_good_names = any(
any(word in snippet for word in ["Add", "Get", "Set", "Create", "Update", "Delete"])
any(
word in snippet
for word in ["Add", "Get", "Set", "Create", "Update", "Delete"]
)
for snippet in code_snippets
)
if has_good_names:
@@ -257,7 +264,10 @@ class BalancedReviewer:
# Check for modularity in code
if code_snippets:
function_count = sum(
snippet.count("func ") + snippet.count("def ") + snippet.count("function ") for snippet in code_snippets
snippet.count("func ")
+ snippet.count("def ")
+ snippet.count("function ")
for snippet in code_snippets
)
if function_count > 0:
response += "✓ Code is broken into functions/methods\n"
@@ -324,7 +334,8 @@ class BalancedReviewer:
has_comments = False
if code_snippets:
has_comments = any(
"//" in snippet or "/*" in snippet or "#" in snippet or '"""' in snippet for snippet in code_snippets
"//" in snippet or "/*" in snippet or "#" in snippet or '"""' in snippet
for snippet in code_snippets
)
if has_comments:
@@ -361,7 +372,9 @@ class BalancedReviewer:
# Analyze file structure
if file_paths:
# Check for separation of concerns
has_separation = len(set(str(p).split("/")[-2] for p in file_paths if "/" in str(p))) > 1
has_separation = (
len(set(str(p).split("/")[-2] for p in file_paths if "/" in str(p))) > 1
)
if has_separation:
response += "✓ Changes span multiple modules (good separation)\n"
else:
@@ -413,7 +426,9 @@ class ReviewProtocol:
except KeyError:
focus_enum = ReviewFocus.GENERAL
review = self.reviewer.review(focus_enum, work_description, code_snippets, file_paths, context)
review = self.reviewer.review(
focus_enum, work_description, code_snippets, file_paths, context
)
header = f"Review {self.review_count}/{self.max_reviews} (Focus: {focus_enum.value}):\n\n"
footer = "\n\n💡 This is a balanced review - consider both strengths and suggestions."
@@ -61,7 +61,9 @@ For new code, prefer using 'network' directly."""
**kwargs: Additional arguments passed to NetworkTool
"""
# Just pass through to NetworkTool
super().__init__(permission_manager=permission_manager, default_mode=default_mode, **kwargs)
super().__init__(
permission_manager=permission_manager, default_mode=default_mode, **kwargs
)
async def call(self, **kwargs) -> str:
"""Execute swarm via network tool.
@@ -191,7 +191,9 @@ class SwarmToolParams(TypedDict):
class SwarmState(State):
"""State for swarm execution."""
def __init__(self, config: SwarmConfig, initial_query: str, context: Optional[str] = None):
def __init__(
self, config: SwarmConfig, initial_query: str, context: Optional[str] = None
):
"""Initialize swarm state."""
super().__init__()
self.config = config
@@ -292,7 +294,9 @@ class SwarmAgent(MCPAgent):
# Default to anthropic
return f"model://anthropic/{model}"
async def run(self, state: SwarmState, history: History, network: Network) -> InferenceResult:
async def run(
self, state: SwarmState, history: History, network: Network
) -> InferenceResult:
"""Execute the swarm agent."""
# Build prompt with context
prompt_parts = []
@@ -414,7 +418,9 @@ class SwarmRouter(DeterministicRouter):
# No more agents to execute
return None
def _get_agent_class(self, agent_id: str, agent_stack: List[type[Agent]]) -> type[Agent]:
def _get_agent_class(
self, agent_id: str, agent_stack: List[type[Agent]]
) -> type[Agent]:
"""Get agent class for given agent ID."""
# Find matching agent by name
for agent_class in agent_stack:
@@ -489,7 +495,11 @@ Models can be specified as:
from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
self.model = model or f"anthropic/{get_latest_claude_model()}"
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
self.api_key = (
api_key
or os.environ.get("ANTHROPIC_API_KEY")
or os.environ.get("CLAUDE_API_KEY")
)
self.base_url = base_url
self.max_tokens = max_tokens
self.agent_max_iterations = agent_max_iterations
@@ -497,15 +507,21 @@ Models can be specified as:
# Set up available tools for agents
self.available_tools: list[BaseTool] = []
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
self.available_tools.extend(
get_read_only_filesystem_tools(self.permission_manager)
)
self.available_tools.extend(
get_read_only_jupyter_tools(self.permission_manager)
)
# Add edit tools
self.available_tools.append(Edit(self.permission_manager))
self.available_tools.append(MultiEdit(self.permission_manager))
# Add batch tool
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
self.available_tools.append(
BatchTool({t.name: t for t in self.available_tools})
)
@override
async def call(
@@ -533,7 +549,9 @@ Models can be specified as:
# hanzo-agents SDK is required (already imported above)
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents using hanzo-agents SDK")
await tool_ctx.info(
f"Starting swarm execution with {len(agents_config)} agents using hanzo-agents SDK"
)
# Create state
state = SwarmState(config=config, initial_query=initial_query, context=context)
@@ -630,7 +648,9 @@ Models can be specified as:
output.append("=" * 80)
output.append(f"Total agents: {len(agents_config)}")
output.append(f"Completed: {len(results)}")
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
output.append(
f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}"
)
if entry_point:
output.append(f"Entry point: {entry_point}")
@@ -196,7 +196,11 @@ Models can be specified as:
from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
self.model = model or f"anthropic/{get_latest_claude_model()}"
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
self.api_key = (
api_key
or os.environ.get("ANTHROPIC_API_KEY")
or os.environ.get("CLAUDE_API_KEY")
)
self.base_url = base_url
self.max_tokens = max_tokens
self.agent_max_iterations = agent_max_iterations
@@ -239,7 +243,9 @@ Models can be specified as:
agents_config = config.get("agents", {})
entry_point = config.get("entry_point")
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents")
await tool_ctx.info(
f"Starting swarm execution with {len(agents_config)} agents"
)
# Build agent network
agent_instances = {}
@@ -286,7 +292,10 @@ Models can be specified as:
# Check if any other agent connects to this one
has_incoming = False
for other_config in agents_config.values():
if other_config.get("connections") and agent_id in other_config["connections"]:
if (
other_config.get("connections")
and agent_id in other_config["connections"]
):
has_incoming = True
break
if not has_incoming:
@@ -300,14 +309,18 @@ Models can be specified as:
await execution_queue.put((root, initial_query, {}))
# Execute agents in network order
async def execute_agent(agent_id: str, query: str, inputs: Dict[str, str]) -> str:
async def execute_agent(
agent_id: str, query: str, inputs: Dict[str, str]
) -> str:
"""Execute a single agent in the network."""
async with semaphore:
try:
agent_config = agents_config[agent_id]
agent = agent_instances[agent_id]
await tool_ctx.info(f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})")
await tool_ctx.info(
f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})"
)
# Build prompt with context and inputs
prompt_parts = []
@@ -324,11 +337,15 @@ Models can be specified as:
if inputs:
prompt_parts.append("Input from previous agents:")
for input_agent, input_result in inputs.items():
prompt_parts.append(f"\n--- From {input_agent} ---\n{input_result}")
prompt_parts.append(
f"\n--- From {input_agent} ---\n{input_result}"
)
# Add file context if specified
if agent_config.get("file_path"):
prompt_parts.append(f"\nFile to work on: {agent_config['file_path']}")
prompt_parts.append(
f"\nFile to work on: {agent_config['file_path']}"
)
# Add the main query
prompt_parts.append(f"\nTask: {agent_config['query']}")
@@ -376,7 +393,9 @@ Models can be specified as:
if ready:
# Execute agent
task = asyncio.create_task(execute_agent(agent_id, query, inputs))
task = asyncio.create_task(
execute_agent(agent_id, query, inputs)
)
running_tasks.add(task)
async def handle_completion(task, agent_id=agent_id):
@@ -390,7 +409,9 @@ Models can be specified as:
connections = agent_config.get("connections", [])
for next_agent in connections:
if next_agent in agents_config:
await execution_queue.put((next_agent, "", {agent_id: result}))
await execution_queue.put(
(next_agent, "", {agent_id: result})
)
asyncio.create_task(handle_completion(task))
@@ -449,7 +470,9 @@ Models can be specified as:
output.append("=" * 80)
output.append(f"Total agents: {len(agents_config)}")
output.append(f"Completed: {len(results)}")
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
output.append(
f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}"
)
if entry_point:
output.append(f"Entry point: {entry_point}")
@@ -467,7 +490,12 @@ Models can be specified as:
role = config.get("role", "Agent")
model = config.get("model", "default")
status = "✅" if agent_id in results and not results[agent_id].startswith("Error:") else "❌"
status = (
"✅"
if agent_id in results
and not results[agent_id].startswith("Error:")
else "❌"
)
lines.append(f"{indent}{status} {agent_id} ({role}) [{model}]")
# Show connections
@@ -0,0 +1,259 @@
"""Unified CLI Tools - DRY implementation using base agent classes.
This module provides the single, clean implementation of all CLI tools
following Python best practices and eliminating all duplication.
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional
from pathlib import Path
from mcp.server import FastMCP
from mcp.server.fastmcp import Context
from ..common.base import BaseTool
from ...core.base_agent import CLIAgent, AgentConfig
from ..common.permissions import PermissionManager
from ...core.model_registry import registry
class UnifiedCLITool(BaseTool, CLIAgent):
"""Unified CLI tool that combines BaseTool and CLIAgent functionality.
MRO: BaseTool first for proper method resolution order.
"""
def __init__(
self,
name: str,
description: str,
cli_command: str,
default_model: str,
permission_manager: Optional[PermissionManager] = None,
):
"""Initialize unified CLI tool.
Args:
name: Tool name
description: Tool description
cli_command: CLI command to execute
default_model: Default model to use
permission_manager: Permission manager for access control
"""
# Initialize CLIAgent with config
config = AgentConfig(model=default_model)
CLIAgent.__init__(self, config)
# Store tool metadata
self._name = name
self._description = description
self._cli_command = cli_command
self.permission_manager = permission_manager
@property
def name(self) -> str:
return self._name
@property
def description(self) -> str:
return self._description
@property
def cli_command(self) -> str:
return self._cli_command
def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
"""Build the CLI command with model-specific formatting.
Args:
prompt: The prompt
**kwargs: Additional parameters
Returns:
Command arguments list
"""
command = [self.cli_command]
# Get model config from registry
model_config = registry.get(self.config.model)
# Handle different CLI tool formats
if self.cli_command == "claude":
if model_config:
command.extend(["--model", model_config.full_name])
# Claude takes prompt via stdin
return command
elif self.cli_command == "openai":
# OpenAI CLI format
command.extend(["api", "chat.completions.create"])
if model_config:
command.extend(["-m", model_config.full_name])
command.extend(["-g", "user", prompt])
return command
elif self.cli_command in ["gemini", "grok"]:
# Simple format: command --model MODEL prompt
if model_config:
command.extend(["--model", model_config.full_name])
command.append(prompt)
return command
elif self.cli_command == "openhands":
# OpenHands format
command.extend(["run", prompt])
if model_config:
command.extend(["--model", model_config.full_name])
if self.config.working_dir:
command.extend(["--workspace", str(self.config.working_dir)])
return command
elif self.cli_command == "hanzo":
# Hanzo dev format
command.append("dev")
if model_config:
command.extend(["--model", model_config.full_name])
command.extend(["--prompt", prompt])
return command
elif self.cli_command == "cline":
# Cline format
command.append(prompt)
command.append("--no-interactive")
return command
elif self.cli_command == "aider":
# Aider format
if model_config:
command.extend(["--model", model_config.full_name])
command.extend(["--message", prompt])
command.extend(["--yes", "--no-stream"])
return command
elif self.cli_command == "ollama":
# Ollama format for local models
command.extend(["run", self.config.model.replace("ollama/", "")])
command.append(prompt)
return command
# Default format
command.append(prompt)
return command
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
"""Execute the CLI tool via MCP interface.
Args:
ctx: MCP context
**params: Tool parameters
Returns:
Execution result
"""
# Update config from params
if params.get("model"):
self.config.model = registry.resolve(params["model"])
if params.get("working_dir"):
self.config.working_dir = Path(params["working_dir"])
if params.get("timeout"):
self.config.timeout = params["timeout"]
# Execute using base agent
result = await self.execute(
params.get("prompt", ""),
context=ctx,
)
return result.content
def register(self, mcp_server: FastMCP) -> None:
"""Register this tool with the MCP server.
Args:
mcp_server: The FastMCP server instance
"""
tool_self = self
@mcp_server.tool(name=self.name, description=self.description)
async def tool_wrapper(
prompt: str,
ctx: Context[Any, Any, Any],
model: Optional[str] = None,
working_dir: Optional[str] = None,
timeout: int = 300,
) -> str:
return await tool_self.call(
ctx,
prompt=prompt,
model=model,
working_dir=working_dir,
timeout=timeout,
)
def create_cli_tools(permission_manager: Optional[PermissionManager] = None) -> Dict[str, UnifiedCLITool]:
"""Create all CLI tools with unified implementation.
Args:
permission_manager: Permission manager for access control
Returns:
Dictionary of tool name to tool instance
"""
tools = {}
# Define all tools with their configurations
tool_configs = [
("claude", "Execute Claude CLI for AI assistance", "claude", "claude"),
("cc", "Claude Code CLI (alias for claude)", "claude", "claude"),
("codex", "Execute OpenAI Codex/GPT-4 CLI", "openai", "gpt-4-turbo"),
("gemini", "Execute Google Gemini CLI", "gemini", "gemini"),
("grok", "Execute xAI Grok CLI", "grok", "grok"),
("openhands", "Execute OpenHands for autonomous coding", "openhands", "claude"),
("oh", "OpenHands CLI (alias)", "openhands", "claude"),
("hanzo_dev", "Execute Hanzo Dev AI assistant", "hanzo", "claude"),
("cline", "Execute Cline for autonomous coding", "cline", "claude"),
("aider", "Execute Aider for AI pair programming", "aider", "gpt-4-turbo"),
]
for name, description, cli_command, default_model in tool_configs:
tools[name] = UnifiedCLITool(
name=name,
description=description,
cli_command=cli_command,
default_model=default_model,
permission_manager=permission_manager,
)
return tools
def register_cli_tools(
mcp_server: FastMCP,
permission_manager: Optional[PermissionManager] = None,
) -> List[BaseTool]:
"""Register all CLI tools with the MCP server.
Args:
mcp_server: The FastMCP server instance
permission_manager: Permission manager for access control
Returns:
List of registered CLI tools
"""
tools = create_cli_tools(permission_manager)
# Register each tool
for tool in tools.values():
tool.register(mcp_server)
return list(tools.values())
__all__ = [
"UnifiedCLITool",
"create_cli_tools",
"register_cli_tools",
]
@@ -140,6 +140,8 @@ To make a batch call, provide the following:
Available tools in batch call:
Tool: dispatch_agent,read,directory_tree,grep,grep_ast,run_command,notebook_read
CLI Tools: claude,cc,codex,gemini,grok,openhands,oh,hanzo_dev,cline,aider
AST/Symbols: ast,symbols (tree-sitter based code analysis)
Not available: think,write,edit,multi_edit,notebook_edit
"""
@@ -175,22 +177,30 @@ Not available: think,write,edit,multi_edit,notebook_edit
# Validate required parameters
if not description:
await tool_ctx.error("Parameter 'description' is required but was None or empty")
await tool_ctx.error(
"Parameter 'description' is required but was None or empty"
)
return "Error: Parameter 'description' is required but was None or empty"
if not invocations:
await tool_ctx.error("Parameter 'invocations' is required but was None or empty")
await tool_ctx.error(
"Parameter 'invocations' is required but was None or empty"
)
return "Error: Parameter 'invocations' is required but was None or empty"
if not isinstance(invocations, list) or len(invocations) == 0:
await tool_ctx.error("Parameter 'invocations' must be a non-empty list")
return "Error: Parameter 'invocations' must be a non-empty list"
await tool_ctx.info(f"Executing batch operation: {description} ({len(invocations)} invocations)")
await tool_ctx.info(
f"Executing batch operation: {description} ({len(invocations)} invocations)"
)
# Execute all tool invocations in parallel
tasks: list[asyncio.Future[dict[str, Any]]] = []
invocation_map: dict[asyncio.Future[dict[str, Any]], dict[str, Any]] = {} # Map task Future to invocation
invocation_map: dict[asyncio.Future[dict[str, Any]], dict[str, Any]] = (
{}
) # Map task Future to invocation
for i, invocation in enumerate(invocations):
# Extract tool name and input from invocation
@@ -203,7 +213,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
await tool_ctx.error(error_message)
# Add direct result for this invocation
tasks.append(asyncio.Future())
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
tasks[-1].set_result(
{"invocation": invocation, "result": f"Error: {error_message}"}
)
invocation_map[tasks[-1]] = invocation
continue
@@ -213,7 +225,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
await tool_ctx.error(error_message)
# Add direct result for this invocation
tasks.append(asyncio.Future())
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
tasks[-1].set_result(
{"invocation": invocation, "result": f"Error: {error_message}"}
)
invocation_map[tasks[-1]] = invocation
continue
@@ -223,7 +237,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
await tool_ctx.info(f"Creating task for tool: {tool_name}")
# Create coroutine for this tool execution
async def execute_tool(tool_obj: BaseTool, tool_name: str, tool_input: dict[str, Any]):
async def execute_tool(
tool_obj: BaseTool, tool_name: str, tool_input: dict[str, Any]
):
try:
await tool_ctx.info(f"Executing tool: {tool_name}")
result = await tool_obj.call(ctx, **tool_input)
@@ -249,7 +265,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
await tool_ctx.error(error_message)
# Add direct result for this invocation
tasks.append(asyncio.Future())
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
tasks[-1].set_result(
{"invocation": invocation, "result": f"Error: {error_message}"}
)
invocation_map[tasks[-1]] = invocation
# Wait for all tasks to complete
@@ -266,7 +284,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
tool_name: str = invocation.get("tool_name", "unknown")
error_message = f"Unexpected error in tool '{tool_name}': {str(e)}"
await tool_ctx.error(error_message)
results.append({"invocation": invocation, "result": f"Error: {error_message}"})
results.append(
{"invocation": invocation, "result": f"Error: {error_message}"}
)
# Extract cursor if provided
cursor = params.get("cursor")
@@ -294,7 +314,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
)
# Create paginated response with token awareness
paginated_response = create_paginated_response(formatted_results, cursor=cursor, use_token_limit=True)
paginated_response = create_paginated_response(
formatted_results, cursor=cursor, use_token_limit=True
)
# Convert paginated response to string format for MCP
if isinstance(paginated_response, dict) and "items" in paginated_response:
@@ -304,9 +326,13 @@ Not available: think,write,edit,multi_edit,notebook_edit
# Add header
result_parts.append(f"=== Batch operation: {description} ===")
result_parts.append(f"Total invocations: {len(invocations)}")
result_parts.append(f"Showing results: {len(paginated_response['items'])} of {len(results)}")
result_parts.append(
f"Showing results: {len(paginated_response['items'])} of {len(results)}"
)
if paginated_response.get("hasMore"):
result_parts.append(f"More results available - use cursor: {paginated_response.get('nextCursor')}")
result_parts.append(
f"More results available - use cursor: {paginated_response.get('nextCursor')}"
)
result_parts.append("")
# Format each result
@@ -326,8 +352,12 @@ Not available: think,write,edit,multi_edit,notebook_edit
# If there's a next cursor, we need to preserve it in the response
# For now, append it as a note at the end
if paginated_response.get("hasMore") and paginated_response.get("nextCursor"):
formatted_output += f"\n\n[To continue, use cursor: {paginated_response['nextCursor']}]"
if paginated_response.get("hasMore") and paginated_response.get(
"nextCursor"
):
formatted_output += (
f"\n\n[To continue, use cursor: {paginated_response['nextCursor']}]"
)
await tool_ctx.info(
f"Batch operation '{description}' completed with {len(paginated_response['items'])} results"
@@ -394,4 +424,6 @@ Not available: think,write,edit,multi_edit,notebook_edit
cursor: Cursor,
ctx: MCPContext,
) -> str:
return await tool_self.call(ctx, description=description, invocations=invocations, cursor=cursor)
return await tool_self.call(
ctx, description=description, invocations=invocations, cursor=cursor
)
@@ -87,11 +87,17 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
try:
if action == "get":
return await self._get_config(scope, setting_path, project_name, project_path)
return await self._get_config(
scope, setting_path, project_name, project_path
)
elif action == "set":
return await self._set_config(scope, setting_path, value, project_name, project_path)
return await self._set_config(
scope, setting_path, value, project_name, project_path
)
elif action == "add_server":
return await self._add_mcp_server(server_name, server_config, scope, project_name)
return await self._add_mcp_server(
server_name, server_config, scope, project_name
)
elif action == "remove_server":
return await self._remove_mcp_server(server_name, scope, project_name)
elif action == "enable_server":
@@ -152,7 +158,10 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
"agent": settings.agent.__dict__,
"vector_store": settings.vector_store.__dict__,
"hub_enabled": settings.hub_enabled,
"mcp_servers": {name: server.__dict__ for name, server in settings.mcp_servers.items()},
"mcp_servers": {
name: server.__dict__
for name, server in settings.mcp_servers.items()
},
"current_project": settings.current_project,
"projects": list(settings.projects.keys()),
}
@@ -230,7 +239,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
else:
return f"Error: MCP server '{server_name}' already exists"
async def _remove_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
async def _remove_mcp_server(
self, server_name: Optional[str], scope: str, project_name: Optional[str]
) -> str:
"""Remove an MCP server."""
if not server_name:
return "Error: server_name is required"
@@ -243,7 +254,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
else:
return f"Error: MCP server '{server_name}' not found"
async def _enable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
async def _enable_mcp_server(
self, server_name: Optional[str], scope: str, project_name: Optional[str]
) -> str:
"""Enable an MCP server."""
if not server_name:
return "Error: server_name is required"
@@ -256,7 +269,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
else:
return f"Error: MCP server '{server_name}' not found"
async def _disable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
async def _disable_mcp_server(
self, server_name: Optional[str], scope: str, project_name: Optional[str]
) -> str:
"""Disable an MCP server."""
if not server_name:
return "Error: server_name is required"
@@ -282,7 +297,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
else:
return f"Error: MCP server '{server_name}' not found"
async def _add_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
async def _add_project(
self, project_name: Optional[str], project_path: Optional[str]
) -> str:
"""Add a project configuration."""
if not project_path:
return "Error: project_path is required"
@@ -308,7 +325,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
else:
return f"Error: Project '{project_name}' already exists"
async def _set_current_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
async def _set_current_project(
self, project_name: Optional[str], project_path: Optional[str]
) -> str:
"""Set the current active project."""
settings = load_settings()
@@ -67,7 +67,9 @@ class ToolContext:
"""
return self._mcp_context.client_id
def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
async def set_tool_info(
self, tool_name: str, execution_id: str | None = None
) -> None:
"""Set information about the currently executing tool.
Args:
@@ -153,7 +153,9 @@ Recommendations:
# Validate required analysis parameter
if not analysis:
await tool_ctx.error("Parameter 'analysis' is required but was None or empty")
await tool_ctx.error(
"Parameter 'analysis' is required but was None or empty"
)
return "Error: Parameter 'analysis' is required but was None or empty"
if analysis.strip() == "":
@@ -120,7 +120,9 @@ def _is_valid_context(ctx: Any) -> bool:
)
def mcp_tool(server: Any, name: str | None = None, description: str | None = None) -> Callable[[F], F]:
def mcp_tool(
server: Any, name: str | None = None, description: str | None = None
) -> Callable[[F], F]:
"""Enhanced MCP tool decorator that includes context normalization.
This decorator combines the standard MCP tool registration with
@@ -190,7 +192,9 @@ def create_tool_handler(server: Any, tool: Any) -> Callable[[], None]:
# Apply context normalization
normalized = with_context_normalization(func)
# Apply original decorator
return original_tool_decorator(name=name, description=description)(normalized)
return original_tool_decorator(name=name, description=description)(
normalized
)
return decorator
@@ -85,7 +85,9 @@ class AutoRegisterTool(BaseTool, ABC):
params = list(sig.parameters.items())
# Skip 'self' and 'ctx' parameters
tool_params = [(name, param) for name, param in params if name not in ("self", "ctx")]
tool_params = [
(name, param) for name, param in params if name not in ("self", "ctx")
]
# Create the handler function dynamically
async def handler(ctx: MCPContext, **kwargs: Any) -> Any:
@@ -96,4 +98,6 @@ class AutoRegisterTool(BaseTool, ABC):
normalized_handler = with_context_normalization(handler)
# Register with the server
mcp_server.tool(name=self.name, description=self.description)(normalized_handler)
mcp_server.tool(name=self.name, description=self.description)(
normalized_handler
)
@@ -143,7 +143,9 @@ class FastMCPPaginator(Generic[T]):
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
# Use provided page size or default
actual_page_size = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
actual_page_size = min(
page_size or cursor_data.page_size or self.page_size, self.max_page_size
)
# Get starting position
start_idx = cursor_data.offset
@@ -205,7 +207,9 @@ class FastMCPPaginator(Generic[T]):
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
# Determine page size
limit = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
limit = min(
page_size or cursor_data.page_size or self.page_size, self.max_page_size
)
# Execute query with cursor position
results = query_func(
@@ -254,7 +258,9 @@ class TokenAwarePaginator:
self.max_tokens = max_tokens
self.paginator = FastMCPPaginator()
def paginate_by_tokens(self, items: List[Any], cursor: Optional[str] = None, estimate_func=None) -> Dict[str, Any]:
def paginate_by_tokens(
self, items: List[Any], cursor: Optional[str] = None, estimate_func=None
) -> Dict[str, Any]:
"""Paginate items based on token count.
Args:
@@ -269,7 +275,9 @@ class TokenAwarePaginator:
# Default token estimation
if not estimate_func:
estimate_func = lambda x: estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
estimate_func = lambda x: estimate_tokens(
json.dumps(x) if not isinstance(x, str) else x
)
# Parse cursor
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
@@ -42,7 +42,9 @@ class ForgivingEditHelper:
return "\n".join(lines)
@staticmethod
def find_fuzzy_match(haystack: str, needle: str, threshold: float = 0.85) -> Optional[Tuple[int, int, str]]:
def find_fuzzy_match(
haystack: str, needle: str, threshold: float = 0.85
) -> Optional[Tuple[int, int, str]]:
"""Find a fuzzy match for the needle in the haystack.
Args:
@@ -79,9 +81,13 @@ class ForgivingEditHelper:
# Find end position by counting lines in needle
needle_lines = norm_needle.count("\n") + 1
end_pos = sum(len(line) + 1 for line in original_lines[: lines_before + needle_lines])
end_pos = sum(
len(line) + 1 for line in original_lines[: lines_before + needle_lines]
)
matched = "\n".join(original_lines[lines_before : lines_before + needle_lines])
matched = "\n".join(
original_lines[lines_before : lines_before + needle_lines]
)
return (start_pos, end_pos - 1, matched)
# Try fuzzy matching on lines
@@ -113,7 +119,9 @@ class ForgivingEditHelper:
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, candidate_norm, needle_norm
).ratio()
if ratio >= threshold:
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
@@ -122,7 +130,9 @@ class ForgivingEditHelper:
return None
@staticmethod
def suggest_matches(haystack: str, needle: str, max_suggestions: int = 3) -> List[Tuple[float, str]]:
def suggest_matches(
haystack: str, needle: str, max_suggestions: int = 3
) -> List[Tuple[float, str]]:
"""Suggest possible matches when exact match fails.
Args:
@@ -144,7 +154,9 @@ class ForgivingEditHelper:
for line in haystack.split("\n"):
if line.strip(): # Skip empty lines
line_norm = ForgivingEditHelper.normalize_whitespace(line)
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, line_norm, needle_norm
).ratio()
if ratio > 0.5: # Only reasonably similar lines
suggestions.append((ratio, line))
@@ -158,7 +170,9 @@ class ForgivingEditHelper:
candidate = "\n".join(candidate_lines)
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
ratio = difflib.SequenceMatcher(
None, candidate_norm, needle_norm
).ratio()
if ratio > 0.5:
suggestions.append((ratio, candidate))
@@ -167,7 +181,9 @@ class ForgivingEditHelper:
return suggestions[:max_suggestions]
@staticmethod
def create_edit_suggestion(file_content: str, old_string: str, new_string: str) -> dict:
def create_edit_suggestion(
file_content: str, old_string: str, new_string: str
) -> dict:
"""Create a helpful edit suggestion when match fails.
Args:
@@ -196,7 +212,10 @@ class ForgivingEditHelper:
if suggestions:
return {
"error": "Could not find exact or fuzzy match",
"suggestions": [{"similarity": f"{score:.0%}", "text": text} for score, text in suggestions],
"suggestions": [
{"similarity": f"{score:.0%}", "text": text}
for score, text in suggestions
],
"hint": "Try using one of these suggestions as old_string",
}
@@ -94,7 +94,11 @@ def register_default_modes():
def get_mode_from_env() -> Optional[str]:
"""Get mode name from environment variables."""
# Check for HANZO_MODE, PERSONALITY, or MODE env vars
return os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE")
return (
os.environ.get("HANZO_MODE")
or os.environ.get("PERSONALITY")
or os.environ.get("MODE")
)
def activate_mode_from_env():
@@ -68,7 +68,11 @@ class PaginatedBaseTool(BaseTool):
if cursor:
# For continuation, check if we have cached results
cursor_data = CursorManager.parse_cursor(cursor)
if cursor_data and "tool" in cursor_data and cursor_data["tool"] != self.name:
if (
cursor_data
and "tool" in cursor_data
and cursor_data["tool"] != self.name
):
return {"error": "Cursor is for a different tool", "code": -32602}
# Execute the tool
@@ -86,11 +90,15 @@ class PaginatedBaseTool(BaseTool):
if isinstance(paginated_result, dict) and "nextCursor" in paginated_result:
# Enhance the cursor with tool information
if "nextCursor" in paginated_result:
cursor_data = CursorManager.parse_cursor(paginated_result["nextCursor"])
cursor_data = CursorManager.parse_cursor(
paginated_result["nextCursor"]
)
if cursor_data:
cursor_data["tool"] = self.name
cursor_data["params"] = params # Store params for continuation
paginated_result["nextCursor"] = CursorManager.create_cursor(cursor_data)
paginated_result["nextCursor"] = CursorManager.create_cursor(
cursor_data
)
return paginated_result
else:
@@ -35,21 +35,31 @@ class AutoPaginatedResponse:
"""
# Handle different content types
if isinstance(content, str):
return AutoPaginatedResponse._handle_string_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_string_response(
content, cursor, max_tokens
)
elif isinstance(content, list):
return AutoPaginatedResponse._handle_list_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_list_response(
content, cursor, max_tokens
)
elif isinstance(content, dict):
# If dict already has pagination info, return as-is
if "nextCursor" in content or "cursor" in content:
return content
# Otherwise treat as single item
return AutoPaginatedResponse._handle_dict_response(content, cursor, max_tokens)
return AutoPaginatedResponse._handle_dict_response(
content, cursor, max_tokens
)
else:
# Convert to string for other types
return AutoPaginatedResponse._handle_string_response(str(content), cursor, max_tokens)
return AutoPaginatedResponse._handle_string_response(
str(content), cursor, max_tokens
)
@staticmethod
def _handle_string_response(content: str, cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_string_response(
content: str, cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for string responses."""
# Parse cursor to get offset
offset = 0
@@ -114,7 +124,9 @@ class AutoPaginatedResponse:
return response
@staticmethod
def _handle_list_response(items: List[Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_list_response(
items: List[Any], cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for list responses."""
# Parse cursor to get offset
offset = 0
@@ -152,7 +164,9 @@ class AutoPaginatedResponse:
truncated = item[:5000] + "... [truncated]"
result_items.append(truncated)
else:
result_items.append({"error": "Item too large", "index": item_index})
result_items.append(
{"error": "Item too large", "index": item_index}
)
item_index += 1
break
@@ -185,7 +199,9 @@ class AutoPaginatedResponse:
return response
@staticmethod
def _handle_dict_response(content: Dict[str, Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
def _handle_dict_response(
content: Dict[str, Any], cursor: Optional[str], max_tokens: int
) -> Dict[str, Any]:
"""Handle pagination for dict responses."""
# For dicts, check if it's too large as-is
content_str = json.dumps(content, indent=2)
@@ -264,13 +280,17 @@ def paginate_if_needed(
Original response if small enough, otherwise paginated dict
"""
# Quick check - if response is already paginated, return as-is
if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
if isinstance(response, dict) and (
"nextCursor" in response or "pagination_info" in response
):
return response
# For small responses, don't paginate unless forced
if not force_pagination:
try:
response_str = json.dumps(response) if not isinstance(response, str) else response
response_str = (
json.dumps(response) if not isinstance(response, str) else response
)
if len(response_str) < 10000: # Quick heuristic
return response
except Exception:
@@ -154,7 +154,9 @@ class StreamPaginator(Generic[T]):
"""
self.page_size = page_size
def paginate_stream(self, stream_generator, cursor: Optional[str] = None) -> PaginatedResponse[T]:
def paginate_stream(
self, stream_generator, cursor: Optional[str] = None
) -> PaginatedResponse[T]:
"""Paginate results from a stream/generator.
Args:
@@ -183,14 +185,18 @@ class StreamPaginator(Generic[T]):
items.append(item)
if len(items) >= self.page_size:
# We have a full page, create cursor for next page
next_cursor = CursorManager.create_cursor({"skip": skip_count + len(items)})
next_cursor = CursorManager.create_cursor(
{"skip": skip_count + len(items)}
)
return PaginatedResponse(items=items, next_cursor=next_cursor)
# No more items
return PaginatedResponse(items=items, next_cursor=None)
def paginate_list(items: List[T], cursor: Optional[str] = None, page_size: int = 100) -> PaginatedResponse[T]:
def paginate_list(
items: List[T], cursor: Optional[str] = None, page_size: int = 100
) -> PaginatedResponse[T]:
"""Convenience function to paginate a list.
Args:
@@ -225,9 +225,13 @@ class PermissibleOperation:
"""
self.permission_manager: PermissionManager = permission_manager
self.operation: str = operation
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = get_path_fn
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = (
get_path_fn
)
def __call__(self, func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
def __call__(
self, func: Callable[..., Awaitable[T]]
) -> Callable[..., Awaitable[T]]:
"""Decorate the function.
Args:
@@ -251,7 +255,9 @@ class PermissibleOperation:
# Check permission
if not self.permission_manager.is_path_allowed(path):
raise PermissionError(f"Operation '{self.operation}' not allowed for path: {path}")
raise PermissionError(
f"Operation '{self.operation}' not allowed for path: {path}"
)
# Call the function
return await func(*args, **kwargs)
@@ -88,7 +88,10 @@ personalities = [
programmer="Guido van Rossum",
description="Python's BDFL - readability counts",
philosophy="There should be one-- and preferably only one --obvious way to do it.",
tools=ESSENTIAL_TOOLS + ["uvx", "jupyter", "multi_edit", "symbols", "rules"] + AI_TOOLS + SEARCH_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["uvx", "jupyter", "multi_edit", "symbols", "rules"]
+ AI_TOOLS
+ SEARCH_TOOLS,
environment={"PYTHONPATH": ".", "PYTEST_ARGS": "-xvs"},
),
ToolPersonality(
@@ -104,7 +107,10 @@ personalities = [
programmer="Brendan Eich",
description="JavaScript creator - dynamic and flexible",
philosophy="Always bet on JS.",
tools=ESSENTIAL_TOOLS + ["npx", "watch", "symbols", "todo", "rules"] + BUILD_TOOLS + SEARCH_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["npx", "watch", "symbols", "todo", "rules"]
+ BUILD_TOOLS
+ SEARCH_TOOLS,
environment={"NODE_ENV": "development", "NPM_CONFIG_LOGLEVEL": "warn"},
),
ToolPersonality(
@@ -120,7 +126,10 @@ personalities = [
programmer="Bjarne Stroustrup",
description="C++ creator - zero-overhead abstractions",
philosophy="C++ is designed to allow you to express ideas.",
tools=ESSENTIAL_TOOLS + ["symbols", "multi_edit", "content_replace"] + UNIX_TOOLS + BUILD_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["symbols", "multi_edit", "content_replace"]
+ UNIX_TOOLS
+ BUILD_TOOLS,
environment={"CXX": "g++", "CXXFLAGS": "-std=c++20 -Wall"},
),
ToolPersonality(
@@ -136,7 +145,10 @@ personalities = [
programmer="Anders Hejlsberg",
description="TypeScript/C# creator - type safety matters",
philosophy="TypeScript is JavaScript that scales.",
tools=ESSENTIAL_TOOLS + ["npx", "symbols", "watch", "rules"] + BUILD_TOOLS + SEARCH_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["npx", "symbols", "watch", "rules"]
+ BUILD_TOOLS
+ SEARCH_TOOLS,
environment={"TYPESCRIPT_VERSION": "5.0"},
),
ToolPersonality(
@@ -169,7 +181,9 @@ personalities = [
programmer="Linus Torvalds",
description="Linux & Git creator - pragmatic excellence",
philosophy="Talk is cheap. Show me the code.",
tools=ESSENTIAL_TOOLS + ["git_search", "diff", "content_replace", "critic"] + UNIX_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["git_search", "diff", "content_replace", "critic"]
+ UNIX_TOOLS,
environment={"KERNEL_VERSION": "6.0", "GIT_AUTHOR_NAME": "Linus Torvalds"},
),
ToolPersonality(
@@ -177,7 +191,10 @@ personalities = [
programmer="Rob Pike",
description="Go creator - simplicity and concurrency",
philosophy="A little copying is better than a little dependency.",
tools=ESSENTIAL_TOOLS + ["symbols", "batch", "process"] + UNIX_TOOLS + BUILD_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["symbols", "batch", "process"]
+ UNIX_TOOLS
+ BUILD_TOOLS,
environment={"GOPATH": "~/go", "GO111MODULE": "on"},
),
ToolPersonality(
@@ -225,7 +242,9 @@ personalities = [
programmer="Graydon Hoare",
description="Rust creator - memory safety without GC",
philosophy="Memory safety without garbage collection, concurrency without data races.",
tools=ESSENTIAL_TOOLS + ["symbols", "multi_edit", "critic", "todo"] + BUILD_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["symbols", "multi_edit", "critic", "todo"]
+ BUILD_TOOLS,
environment={"RUST_BACKTRACE": "1", "CARGO_HOME": "~/.cargo"},
),
ToolPersonality(
@@ -852,7 +871,10 @@ personalities = [
programmer="Data Scientist",
description="Analyze all the things",
philosophy="In God we trust. All others must bring data.",
tools=ESSENTIAL_TOOLS + ["jupyter", "sql_query", "stats"] + VECTOR_TOOLS + AI_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["jupyter", "sql_query", "stats"]
+ VECTOR_TOOLS
+ AI_TOOLS,
environment={"JUPYTER_THEME": "dark"},
),
ToolPersonality(
@@ -884,7 +906,10 @@ personalities = [
programmer="Startup Founder",
description="Move fast and fix things",
philosophy="Done is better than perfect.",
tools=ESSENTIAL_TOOLS + ["todo", "agent", "consensus"] + BUILD_TOOLS + DATABASE_TOOLS,
tools=ESSENTIAL_TOOLS
+ ["todo", "agent", "consensus"]
+ BUILD_TOOLS
+ DATABASE_TOOLS,
environment={"STARTUP_MODE": "hustle"},
),
ToolPersonality(
@@ -88,7 +88,12 @@ class PluginLoader:
# Find tool classes
for _name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
if (
inspect.isclass(obj)
and issubclass(obj, BaseTool)
and obj != BaseTool
and hasattr(obj, "name")
):
# Load metadata if available
metadata = None
metadata_file = file_path.with_suffix(".json")
@@ -129,8 +134,15 @@ class PluginLoader:
else:
# Search for tool classes
for _name, obj in inspect.getmembers(module):
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
plugin = ToolPlugin(name=obj.name, tool_class=obj, source_path=package_dir)
if (
inspect.isclass(obj)
and issubclass(obj, BaseTool)
and obj != BaseTool
and hasattr(obj, "name")
):
plugin = ToolPlugin(
name=obj.name, tool_class=obj, source_path=package_dir
)
self.plugins[obj.name] = plugin
finally:
# Remove from path
@@ -97,7 +97,9 @@ Example:
memory_used_gb = memory.used / (1024**3)
memory_total_gb = memory.total / (1024**3)
memory_percent = memory.percent
output.append(f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)")
output.append(
f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)"
)
if memory_percent > 90:
warnings.append(f"⚠️ HIGH MEMORY USAGE: {memory_percent}%")
@@ -107,10 +109,14 @@ Example:
disk_total_gb = disk.total / (1024**3)
disk_percent = disk.percent
disk_free_gb = disk.free / (1024**3)
output.append(f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)")
output.append(
f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)"
)
output.append(f"Free Space: {disk_free_gb:.1f} GB")
if disk_percent > 90:
warnings.append(f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)")
warnings.append(
f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)"
)
output.append("")
@@ -167,7 +173,9 @@ Example:
size = db_file.stat().st_size
total_db_size += size
output.append(f"Total Database Size: {total_db_size / (1024**2):.1f} MB")
output.append(
f"Total Database Size: {total_db_size / (1024**2):.1f} MB"
)
output.append(f"Active Projects: {len(self.db_manager.projects)}")
# List largest databases
@@ -193,7 +201,9 @@ Example:
output.append("=== MCP Servers ===")
mcp_servers = McpAddTool.get_servers()
if mcp_servers:
running_mcp = sum(1 for s in mcp_servers.values() if s.get("status") == "running")
running_mcp = sum(
1 for s in mcp_servers.values() if s.get("status") == "running"
)
total_mcp_tools = sum(len(s.get("tools", [])) for s in mcp_servers.values())
output.append(f"Total Servers: {len(mcp_servers)}")
@@ -215,7 +225,9 @@ Example:
output.append(f"Log Files: {log_count} ({log_size / (1024**2):.1f} MB)")
if log_size > 100 * 1024**2: # > 100MB
warnings.append(f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB")
warnings.append(
f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB"
)
# Config directory
config_dir = Path.home() / ".hanzo" / "mcp"
@@ -115,7 +115,9 @@ Feature Implementation Planning
# Validate required thought parameter
if not thought:
await tool_ctx.error("Parameter 'thought' is required but was None or empty")
await tool_ctx.error(
"Parameter 'thought' is required but was None or empty"
)
return "Error: Parameter 'thought' is required but was None or empty"
if thought.strip() == "":
@@ -125,7 +125,9 @@ Use 'tool_enable' to re-enable disabled tools.
]
if not persist:
output.append("\nNote: This change is temporary and will be lost on restart.")
output.append(
"\nNote: This change is temporary and will be lost on restart."
)
# Warn about commonly used tools
common_tools = {"grep", "read", "write", "bash", "edit"}
@@ -135,7 +137,9 @@ Use 'tool_enable' to re-enable disabled tools.
)
# Count disabled tools
disabled_count = sum(1 for enabled in ToolEnableTool._tool_states.values() if not enabled)
disabled_count = sum(
1 for enabled in ToolEnableTool._tool_states.values() if not enabled
)
output.append(f"\nTotal disabled tools: {disabled_count}")
return "\n".join(output)
@@ -205,7 +205,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
# Iterate through categories
categories = (
[category_filter] if category_filter and category_filter in self.TOOL_INFO else self.TOOL_INFO.keys()
[category_filter]
if category_filter and category_filter in self.TOOL_INFO
else self.TOOL_INFO.keys()
)
for category in categories:
@@ -240,7 +242,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
max_name_len = max(len(name) for name, _, _ in category_shown)
for tool_name, description, status in category_shown:
output.append(f"{status} {tool_name.ljust(max_name_len)} - {description}")
output.append(
f"{status} {tool_name.ljust(max_name_len)} - {description}"
)
output.append("")
@@ -32,7 +32,9 @@ class ValidationResult:
return not self.is_valid
def validate_path_parameter(path: str | None, parameter_name: str = "path") -> ValidationResult:
def validate_path_parameter(
path: str | None, parameter_name: str = "path"
) -> ValidationResult:
"""Validate a path parameter.
Args:
@@ -4,10 +4,12 @@ Git-style config tool for managing settings.
"""
from typing import Unpack, Optional, Annotated, TypedDict, final, override
from pathlib import Path
from pydantic import Field
from mcp.server.fastmcp import Context as MCPContext
from hanzo_mcp.config import load_settings, save_settings
from hanzo_mcp.tools.common.base import BaseTool
from hanzo_mcp.tools.common.permissions import PermissionManager
from hanzo_mcp.tools.config.index_config import IndexScope, IndexConfig
@@ -24,7 +26,7 @@ Action = Annotated[
Key = Annotated[
Optional[str],
Field(
description="Configuration key (e.g., index.scope, vector.enabled)",
description="Configuration key (e.g., tools.write.enabled, enabled_tools.write, index.scope)",
default=None,
),
]
@@ -64,6 +66,15 @@ class ConfigParams(TypedDict, total=False):
path: Optional[str]
def _parse_bool(value: str) -> Optional[bool]:
v = value.strip().lower()
if v in {"true", "1", "yes", "on"}:
return True
if v in {"false", "0", "no", "off"}:
return False
return None
@final
class ConfigTool(BaseTool):
"""Git-style configuration management tool."""
@@ -88,6 +99,7 @@ class ConfigTool(BaseTool):
Usage:
config index.scope
config --action set index.scope project
config --action set tools.write.enabled false
config --action list
config --action toggle index.scope --path ./project"""
@@ -119,7 +131,9 @@ config --action toggle index.scope --path ./project"""
else:
return f"Error: Unknown action '{action}'. Valid actions: get, set, list, toggle"
async def _handle_get(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
async def _handle_get(
self, key: Optional[str], scope: str, path: Optional[str], tool_ctx
) -> str:
"""Get configuration value."""
if not key:
return "Error: key required for get action"
@@ -129,7 +143,22 @@ config --action toggle index.scope --path ./project"""
current_scope = self.index_config.get_scope(path)
return f"index.scope={current_scope.value}"
# Handle tool-specific settings
# tools.<name>.enabled → enabled_tools lookup
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
settings = load_settings(project_dir=path if scope == "local" else None)
return f"{key}={settings.is_tool_enabled(tool_name)}"
# enabled_tools.<name>
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
settings = load_settings(project_dir=path if scope == "local" else None)
val = settings.enabled_tools.get(tool_name)
return f"{key}={val if val is not None else 'unset'}"
# Indexing (legacy) per-tool setting: <tool>.enabled
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
@@ -138,6 +167,17 @@ config --action toggle index.scope --path ./project"""
return f"Unknown key: {key}"
def _save_project_settings(self, settings, project_dir: Optional[str]) -> Path:
"""Save to project config if path provided; else global."""
if project_dir:
project_path = Path(project_dir)
project_path.mkdir(parents=True, exist_ok=True)
cfg = project_path / ".hanzo-mcp.json"
cfg.write_text(__import__("json").dumps(settings.__dict__ if hasattr(settings, "__dict__") else {}, indent=2))
return cfg
# Fallback to global handler
return save_settings(settings, global_config=True)
async def _handle_set(
self,
key: Optional[str],
@@ -149,25 +189,68 @@ config --action toggle index.scope --path ./project"""
"""Set configuration value."""
if not key:
return "Error: key required for set action"
if not value:
if value is None:
return "Error: value required for set action"
# Handle index scope
if key == "index.scope":
try:
new_scope = IndexScope(value)
self.index_config.set_scope(new_scope, path if scope == "local" else None)
self.index_config.set_scope(
new_scope, path if scope == "local" else None
)
return f"Set {key}={value} ({'project' if path else 'global'})"
except ValueError:
return f"Error: Invalid scope value '{value}'. Valid: project, global, auto"
# Handle tool-specific settings
# tools.<name>.enabled → enabled_tools mapping
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
settings = load_settings(project_dir=path if scope == "local" else None)
et = dict(settings.enabled_tools)
et[tool_name] = parsed
settings.enabled_tools = et
# Save
if scope == "local" and path:
# Write a project .hanzo-mcp.json adjacent to the path
# Note: save_settings(local) saves to CWD; we target specific path here
out = self._save_project_settings(settings, path)
return f"Set {key}={parsed} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Set {key}={parsed} (global: {out})"
# enabled_tools.<name>
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
settings = load_settings(project_dir=path if scope == "local" else None)
et = dict(settings.enabled_tools)
et[tool_name] = parsed
settings.enabled_tools = et
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Set {key}={parsed} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Set {key}={parsed} (global: {out})"
# Indexing (legacy) per-tool setting: <tool>.enabled (search indexers)
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
enabled = value.lower() in ["true", "yes", "1", "on"]
self.index_config.set_indexing_enabled(tool, enabled)
return f"Set {key}={enabled}"
parsed = _parse_bool(value)
if parsed is None:
return "Error: value must be boolean (true/false)"
self.index_config.set_indexing_enabled(tool, parsed)
return f"Set {key}={parsed}"
return f"Unknown key: {key}"
@@ -184,25 +267,62 @@ config --action toggle index.scope --path ./project"""
output.append(f"\nProjects with custom config: {status['project_count']}")
output.append("\nTool settings:")
output.append("\nTool settings (indexing):")
for tool, settings in status["tools"].items():
output.append(f" {tool}:")
output.append(f" enabled: {settings['enabled']}")
output.append(f" per_project: {settings['per_project']}")
# Also show enabled_tools snapshot
settings_snapshot = load_settings(project_dir=path if scope == "local" else None)
output.append("\nEnabled tools (execution):")
for tool_name, enabled in sorted(settings_snapshot.enabled_tools.items()):
output.append(f" {tool_name}: {enabled}")
return "\n".join(output)
async def _handle_toggle(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
async def _handle_toggle(
self, key: Optional[str], scope: str, path: Optional[str], tool_ctx
) -> str:
"""Toggle configuration value."""
if not key:
return "Error: key required for toggle action"
# Handle index scope toggle
if key == "index.scope":
new_scope = self.index_config.toggle_scope(path if scope == "local" else None)
new_scope = self.index_config.toggle_scope(
path if scope == "local" else None
)
return f"Toggled index.scope to {new_scope.value}"
# Handle tool enable/disable toggle
# Handle execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
if key.startswith("tools.") and key.endswith(".enabled"):
parts = key.split(".")
if len(parts) == 3:
tool_name = parts[1]
settings = load_settings(project_dir=path if scope == "local" else None)
current = bool(settings.enabled_tools.get(tool_name, True))
settings.enabled_tools[tool_name] = not current
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Toggled {key} to {not current} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Toggled {key} to {not current} (global: {out})"
if key.startswith("enabled_tools."):
tool_name = key.split(".", 1)[1]
settings = load_settings(project_dir=path if scope == "local" else None)
current = bool(settings.enabled_tools.get(tool_name, True))
settings.enabled_tools[tool_name] = not current
if scope == "local" and path:
out = self._save_project_settings(settings, path)
return f"Toggled {key} to {not current} (project: {out})"
else:
out = save_settings(settings, global_config=True)
return f"Toggled {key} to {not current} (global: {out})"
# Handle indexing toggles (legacy)
if "." in key:
tool, setting = key.split(".", 1)
if setting == "enabled":
@@ -99,7 +99,9 @@ class IndexConfig:
if project_root:
if str(project_root) not in self._config["project_configs"]:
self._config["project_configs"][str(project_root)] = {}
self._config["project_configs"][str(project_root)]["scope"] = scope.value
self._config["project_configs"][str(project_root)][
"scope"
] = scope.value
else:
# Set global default
self._config["default_scope"] = scope.value
@@ -187,8 +187,12 @@ mode --action current"""
for mode_name in mode_names:
mode = next((m for m in modes if m.name == mode_name), None)
if mode:
marker = " (active)" if active and active.name == mode.name else ""
output.append(f" {mode.name}{marker}: {mode.programmer} - {mode.description}")
marker = (
" (active)" if active and active.name == mode.name else ""
)
output.append(
f" {mode.name}{marker}: {mode.programmer} - {mode.description}"
)
output.append("\nUse 'mode --action activate <name>' to activate a mode")
@@ -252,7 +256,9 @@ mode --action current"""
for key, value in mode.environment.items():
output.append(f" {key}={value}")
output.append("\nNote: Restart MCP session for changes to take full effect")
output.append(
"\nNote: Restart MCP session for changes to take full effect"
)
return "\n".join(output)
@@ -306,13 +312,17 @@ mode --action current"""
tool_self = self
@server.tool(name=self.name, description=self.description)
async def mode_handler(ctx: MCPContext, action: str = "list", name: Optional[str] = None) -> str:
async def mode_handler(
ctx: MCPContext, action: str = "list", name: Optional[str] = None
) -> str:
"""Handle mode tool calls."""
return await tool_self.run(ctx, action=action, name=name)
async def call(self, ctx: MCPContext, **params) -> str:
"""Call the tool with arguments."""
return await self.run(ctx, action=params.get("action", "list"), name=params.get("name"))
return await self.run(
ctx, action=params.get("action", "list"), name=params.get("name")
)
# Create tool instance
@@ -127,7 +127,9 @@ class ProjectDatabase:
# Indexes for graph traversal
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_relationship ON edges(relationship)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_edges_relationship ON edges(relationship)"
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)")
def _load_graph_from_disk(self):
@@ -136,11 +138,15 @@ class ProjectDatabase:
try:
# Copy nodes
nodes = disk_conn.execute("SELECT * FROM nodes").fetchall()
self.graph_conn.executemany("INSERT OR REPLACE INTO nodes VALUES (?, ?, ?, ?)", nodes)
self.graph_conn.executemany(
"INSERT OR REPLACE INTO nodes VALUES (?, ?, ?, ?)", nodes
)
# Copy edges
edges = disk_conn.execute("SELECT * FROM edges").fetchall()
self.graph_conn.executemany("INSERT OR REPLACE INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges)
self.graph_conn.executemany(
"INSERT OR REPLACE INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges
)
self.graph_conn.commit()
finally:
@@ -120,7 +120,9 @@ class GraphParams(TypedDict, total=False):
class GraphTool(BaseTool):
"""Unified graph database tool."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph tool."""
super().__init__(permission_manager)
self.db_manager = db_manager
@@ -93,7 +93,9 @@ class GraphAddParams(TypedDict, total=False):
class GraphAddTool(BaseTool):
"""Tool for adding nodes and edges to graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph add tool.
Args:
@@ -223,11 +225,15 @@ Examples:
if not relationship:
return "Error: relationship is required when adding an edge"
await tool_ctx.info(f"Adding edge: {source} --[{relationship}]--> {target}")
await tool_ctx.info(
f"Adding edge: {source} --[{relationship}]--> {target}"
)
# Check if nodes exist
cursor = graph_conn.cursor()
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (source, target))
cursor.execute(
"SELECT id FROM nodes WHERE id IN (?, ?)", (source, target)
)
existing = [row[0] for row in cursor.fetchall()]
if source not in existing:
@@ -102,7 +102,9 @@ class GraphQueryParams(TypedDict, total=False):
class GraphQueryTool(BaseTool):
"""Tool for querying the graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph query tool.
Args:
@@ -190,7 +192,10 @@ Examples:
return f"Error: Invalid query '{query}'. Must be one of: {', '.join(valid_queries)}"
# Validate required parameters
if query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"] and not node_id:
if (
query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"]
and not node_id
):
return f"Error: node_id is required for '{query}' query"
if query == "path" and (not node_id or not target_id):
@@ -220,17 +225,27 @@ Examples:
try:
if query == "neighbors":
return self._query_neighbors(graph_conn, node_id, relationship, node_type, direction)
return self._query_neighbors(
graph_conn, node_id, relationship, node_type, direction
)
elif query == "path":
return self._query_path(graph_conn, node_id, target_id, relationship)
elif query == "subgraph":
return self._query_subgraph(graph_conn, node_id, depth, relationship, node_type, direction)
return self._query_subgraph(
graph_conn, node_id, depth, relationship, node_type, direction
)
elif query == "connected":
return self._query_connected(graph_conn, node_id, relationship, node_type, direction)
return self._query_connected(
graph_conn, node_id, relationship, node_type, direction
)
elif query == "ancestors":
return self._query_ancestors(graph_conn, node_id, depth, relationship, node_type)
return self._query_ancestors(
graph_conn, node_id, depth, relationship, node_type
)
elif query == "descendants":
return self._query_descendants(graph_conn, node_id, depth, relationship, node_type)
return self._query_descendants(
graph_conn, node_id, depth, relationship, node_type
)
except Exception as e:
await tool_ctx.error(f"Failed to execute query: {str(e)}")
@@ -316,9 +331,13 @@ Examples:
output = [f"Neighbors of '{node_id}' ({node_info[0]}):\n"]
for n in neighbors:
arrow = "<--" if n["direction"] == "incoming" else "-->"
output.append(f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})")
output.append(
f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})"
)
if n["properties"]:
output.append(f" Properties: {json.dumps(n['properties'], indent=6)[:100]}")
output.append(
f" Properties: {json.dumps(n['properties'], indent=6)[:100]}"
)
output.append(f"\nTotal neighbors: {len(neighbors)}")
return "\n".join(output)
@@ -571,7 +590,9 @@ Examples:
node_type: Optional[str],
) -> str:
"""Find nodes that point TO this node (incoming edges only)."""
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "incoming")
return self._query_subgraph(
conn, node_id, depth, relationship, node_type, "incoming"
)
def _query_descendants(
self,
@@ -582,7 +603,9 @@ Examples:
node_type: Optional[str],
) -> str:
"""Find nodes that this node points TO (outgoing edges only)."""
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "outgoing")
return self._query_subgraph(
conn, node_id, depth, relationship, node_type, "outgoing"
)
def register(self, mcp_server) -> None:
"""Register this tool with the MCP server."""
@@ -74,7 +74,9 @@ class GraphRemoveParams(TypedDict, total=False):
class GraphRemoveTool(BaseTool):
"""Tool for removing nodes and edges from graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph remove tool.
Args:
@@ -218,7 +220,9 @@ Examples:
# Remove edge(s)
if relationship:
# Remove specific edge
await tool_ctx.info(f"Removing edge: {source} --[{relationship}]--> {target}")
await tool_ctx.info(
f"Removing edge: {source} --[{relationship}]--> {target}"
)
cursor = graph_conn.cursor()
cursor.execute(
@@ -238,7 +242,9 @@ Examples:
return f"Successfully removed edge: {source} --[{relationship}]--> {target}"
else:
# Remove all edges between nodes
await tool_ctx.info(f"Removing all edges between {source} and {target}")
await tool_ctx.info(
f"Removing all edges between {source} and {target}"
)
cursor = graph_conn.cursor()
cursor.execute(
@@ -76,7 +76,9 @@ class GraphSearchParams(TypedDict, total=False):
class GraphSearchTool(BaseTool):
"""Tool for searching nodes and edges in graph database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph search tool.
Args:
@@ -313,8 +315,13 @@ Examples:
output.append(f"Nodes ({len(nodes)}):")
for node in nodes[:20]: # Show first 20
output.append(f" {node['id']} ({node['node_type']})")
if node["match_field"] == "properties" and "matching_properties" in node:
output.append(f" Matched in: {list(node['matching_properties'].keys())}")
if (
node["match_field"] == "properties"
and "matching_properties" in node
):
output.append(
f" Matched in: {list(node['matching_properties'].keys())}"
)
if node["properties"] and node["match_field"] != "properties":
props_str = json.dumps(node["properties"], indent=6)[:100]
if len(props_str) == 100:
@@ -328,9 +335,16 @@ Examples:
if edges:
output.append(f"Edges ({len(edges)}):")
for edge in edges[:20]: # Show first 20
output.append(f" {edge['source']} --[{edge['relationship']}]--> {edge['target']}")
if edge["match_field"] == "properties" and "matching_properties" in edge:
output.append(f" Matched in: {list(edge['matching_properties'].keys())}")
output.append(
f" {edge['source']} --[{edge['relationship']}]--> {edge['target']}"
)
if (
edge["match_field"] == "properties"
and "matching_properties" in edge
):
output.append(
f" Matched in: {list(edge['matching_properties'].keys())}"
)
if edge["weight"] != 1.0:
output.append(f" Weight: {edge['weight']}")
if edge["properties"]:
@@ -58,7 +58,9 @@ class GraphStatsParams(TypedDict, total=False):
class GraphStatsTool(BaseTool):
"""Tool for getting graph database statistics."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the graph stats tool.
Args:
@@ -137,7 +139,9 @@ Examples:
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Getting graph statistics for project: {project_db.project_path}")
await tool_ctx.info(
f"Getting graph statistics for project: {project_db.project_path}"
)
# Get graph connection
graph_conn = project_db.get_graph_connection()
@@ -152,7 +156,9 @@ Examples:
# Basic counts
if node_type_filter:
cursor.execute("SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,))
cursor.execute(
"SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,)
)
node_count = cursor.fetchone()[0]
output.append(f"Nodes (type='{node_type_filter}'): {node_count:,}")
else:
@@ -166,7 +172,9 @@ Examples:
(relationship_filter,),
)
edge_count = cursor.fetchone()[0]
output.append(f"Edges (relationship='{relationship_filter}'): {edge_count:,}")
output.append(
f"Edges (relationship='{relationship_filter}'): {edge_count:,}"
)
else:
cursor.execute("SELECT COUNT(*) FROM edges")
edge_count = cursor.fetchone()[0]
@@ -180,7 +188,9 @@ Examples:
# Node type distribution
output.append("=== Node Types ===")
cursor.execute("SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC")
cursor.execute(
"SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC"
)
node_types = cursor.fetchall()
for n_type, count in node_types[:10]:
@@ -275,21 +285,31 @@ Examples:
output.append("\n=== Detailed Analysis ===")
# Node properties usage
cursor.execute("SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL")
cursor.execute(
"SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL"
)
nodes_with_props = cursor.fetchone()[0]
if nodes_with_props > 0:
props_pct = (nodes_with_props / node_count) * 100
output.append(f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)")
output.append(
f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)"
)
# Edge properties usage
cursor.execute("SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL")
cursor.execute(
"SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL"
)
edges_with_props = cursor.fetchone()[0]
if edges_with_props > 0 and edge_count > 0:
props_pct = (edges_with_props / edge_count) * 100
output.append(f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)")
output.append(
f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)"
)
# Weight distribution
cursor.execute("SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges")
cursor.execute(
"SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges"
)
weight_stats = cursor.fetchone()
if weight_stats[0] is not None:
output.append(f"\nEdge weights:")
@@ -318,7 +338,9 @@ Examples:
if patterns:
output.append("Most common connections:")
for src_type, rel, tgt_type, count in patterns:
output.append(f" {src_type} --[{rel}]--> {tgt_type}: {count} times")
output.append(
f" {src_type} --[{rel}]--> {tgt_type}: {count} times"
)
# Component analysis (simplified)
output.append("\n=== Graph Structure ===")
@@ -75,7 +75,9 @@ class SQLParams(TypedDict, total=False):
class SQLTool(BaseTool):
"""Unified SQL database tool."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the SQL tool."""
super().__init__(permission_manager)
self.db_manager = db_manager
@@ -294,7 +296,9 @@ sql --action stats --table users
output.append("-" * 60)
for col in columns:
output.append(f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}")
output.append(
f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}"
)
# Get indexes
cursor = conn.execute(f"PRAGMA index_list({table})")
@@ -332,7 +336,9 @@ sql --action stats --table users
# Get columns
cursor = conn.execute(f"PRAGMA table_info({table_name})")
columns = cursor.fetchall()
output.append(f"Columns: {', '.join([col[1] for col in columns])}")
output.append(
f"Columns: {', '.join([col[1] for col in columns])}"
)
return "\n".join(output)
@@ -396,7 +402,9 @@ sql --action stats --table users
"""
)
stats = cursor.fetchone()
output.append(f" {col_name}: distinct={stats[0]}, nulls={stats[1]}")
output.append(
f" {col_name}: distinct={stats[0]}, nulls={stats[1]}"
)
else:
# Overall database stats
@@ -48,7 +48,9 @@ class SqlQueryParams(TypedDict, total=False):
class SqlQueryTool(BaseTool):
"""Tool for executing SQL queries on project databases."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the SQL query tool.
Args:
@@ -138,11 +140,11 @@ Note: Use sql_search for text search operations."""
query_upper = query.upper()
for keyword in write_keywords:
if keyword in query_upper:
return (
f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
)
return f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
await tool_ctx.info(f"Executing SQL query on project: {project_db.project_path}")
await tool_ctx.info(
f"Executing SQL query on project: {project_db.project_path}"
)
# Execute query
conn = None
@@ -207,7 +209,9 @@ Note: Use sql_search for text search operations."""
output_rows = []
for row in rows[:1000]: # Limit to 1000 rows
row_str = " | ".join(
self._truncate(str(val) if val is not None else "NULL", width).ljust(width)
self._truncate(str(val) if val is not None else "NULL", width).ljust(
width
)
for val, width in zip(row, col_widths)
)
output_rows.append(row_str)
@@ -66,7 +66,9 @@ class SqlSearchParams(TypedDict, total=False):
class SqlSearchTool(BaseTool):
"""Tool for searching text in SQLite database."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the SQL search tool.
Args:
@@ -251,7 +253,9 @@ Use sql_query for complex queries with joins, conditions, etc."""
return text_columns
def _format_results(self, table: str, results: list, pattern: str, search_columns: list[str]) -> str:
def _format_results(
self, table: str, results: list, pattern: str, search_columns: list[str]
) -> str:
"""Format search results based on table type."""
output = []
@@ -39,7 +39,9 @@ class SqlStatsParams(TypedDict, total=False):
class SqlStatsTool(BaseTool):
"""Tool for getting SQLite database statistics."""
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
def __init__(
self, permission_manager: PermissionManager, db_manager: DatabaseManager
):
"""Initialize the SQL stats tool.
Args:
@@ -113,7 +115,9 @@ Examples:
except Exception as e:
return f"Error accessing project database: {str(e)}"
await tool_ctx.info(f"Getting statistics for project: {project_db.project_path}")
await tool_ctx.info(
f"Getting statistics for project: {project_db.project_path}"
)
# Collect statistics
conn = None
@@ -132,7 +136,9 @@ Examples:
output.append("")
# Get table statistics
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
)
tables = cursor.fetchall()
output.append("=== Tables ===")
@@ -176,7 +182,9 @@ Examples:
# Show sample data for specific tables
if table_name == "files" and row_count > 0:
cursor.execute(f"SELECT COUNT(DISTINCT SUBSTR(path, -3)) as ext_count FROM {table_name}")
cursor.execute(
f"SELECT COUNT(DISTINCT SUBSTR(path, -3)) as ext_count FROM {table_name}"
)
ext_count = cursor.fetchone()[0]
output.append(f" File types: ~{ext_count}")
@@ -198,7 +206,9 @@ Examples:
output.append(f"\nTotal Rows: {total_rows:,}")
# Get index statistics
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name")
cursor.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name"
)
indexes = cursor.fetchall()
if indexes:
output.append(f"\n=== Indexes ===")
@@ -158,7 +158,9 @@ Note: Requires Neovim to be installed.
return "Error: Must provide either 'command', 'commands', or 'macro'"
if sum(bool(x) for x in [command, commands, macro]) > 1:
return "Error: Can only use one of 'command', 'commands', or 'macro' at a time"
return (
"Error: Can only use one of 'command', 'commands', or 'macro' at a time"
)
# Check if Neovim is available
nvim_cmd = shutil.which("nvim")
@@ -168,7 +168,9 @@ Note: Requires Neovim to be installed.
project_path,
)
elif action == "restore":
return await self._restore_session(tool_ctx, session_name, project_session_dir)
return await self._restore_session(
tool_ctx, session_name, project_session_dir
)
elif action == "list":
return self._list_sessions(project_session_dir, project_path)
elif action == "delete":
@@ -212,7 +214,9 @@ Note: Requires Neovim to be installed.
# Run Neovim to save session
# First, check if Neovim is already running
# For now, we'll create a new instance
result = subprocess.run(["nvim", "-c", vim_script.strip()], capture_output=True, text=True)
result = subprocess.run(
["nvim", "-c", vim_script.strip()], capture_output=True, text=True
)
if result.returncode != 0 and result.stderr:
return f"Error saving session: {result.stderr}"
@@ -242,15 +246,15 @@ Or manually in Neovim:
except Exception as e:
return f"Error saving session: {str(e)}"
async def _restore_session(self, tool_ctx, session_name: Optional[str], project_dir: Path) -> str:
async def _restore_session(
self, tool_ctx, session_name: Optional[str], project_dir: Path
) -> str:
"""Restore Neovim session."""
if not session_name:
# List available sessions
sessions = list(project_dir.glob("*.vim"))
if not sessions:
return (
"Error: No sessions found for this project. Use 'neovim_session --action list' to see all sessions."
)
return "Error: No sessions found for this project. Use 'neovim_session --action list' to see all sessions."
# Use most recent
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
@@ -326,7 +330,9 @@ Or manually in Neovim:
if len(other_sessions) > 10:
output.append(f" ... and {len(other_sessions) - 10} more")
output.append("\nUse 'neovim_session --action restore --session-name <name>' to restore a session.")
output.append(
"\nUse 'neovim_session --action restore --session-name <name>' to restore a session."
)
return "\n".join(output)
@@ -93,7 +93,9 @@ def get_read_only_filesystem_tools(
return tools
def get_filesystem_tools(permission_manager: PermissionManager, project_manager=None) -> list[BaseTool]:
def get_filesystem_tools(
permission_manager: PermissionManager, project_manager=None
) -> list[BaseTool]:
"""Create instances of all filesystem tools.
Args:
@@ -219,4 +221,22 @@ def register_filesystem_tools(
tools = get_filesystem_tools(permission_manager, project_manager)
ToolRegistry.register_tools(mcp_server, tools)
# Register 'symbols' as an alias for 'ast' to match common terminology
try:
ast_tool = next((t for t in tools if getattr(t, "name", "") == "ast"), None)
if ast_tool is not None:
class _SymbolsAlias(ASTTool): # type: ignore[misc]
@property
def name(self) -> str: # type: ignore[override]
return "symbols"
@property
def description(self) -> str: # type: ignore[override]
return f"Alias of 'ast' tool.\n\n{ast_tool.description}"
ToolRegistry.register_tool(mcp_server, _SymbolsAlias(permission_manager))
except Exception:
# Alias is best-effort; don't fail tool registration if aliasing fails
pass
return tools
@@ -129,7 +129,9 @@ class ASTMultiEdit(BaseTool):
return parser.parse(bytes(content, "utf-8"))
def _find_references(self, symbol: str, file_path: str, project_root: Optional[str] = None) -> List[ASTMatch]:
def _find_references(
self, symbol: str, file_path: str, project_root: Optional[str] = None
) -> List[ASTMatch]:
"""Find all references to a symbol across the project."""
matches = []
@@ -147,7 +149,9 @@ class ASTMultiEdit(BaseTool):
return matches
def _get_reference_patterns(self, symbol: str, file_path: str) -> List[Dict[str, Any]]:
def _get_reference_patterns(
self, symbol: str, file_path: str
) -> List[Dict[str, Any]]:
"""Get language-specific patterns for finding references."""
ext = Path(file_path).suffix.lower()
lang = self.languages.get(ext, "generic")
@@ -258,7 +262,9 @@ class ASTMultiEdit(BaseTool):
matches.extend(self._query_ast(tree, pattern, file_path, content))
else:
# Fallback to text search
matches.extend(self._text_search(content, pattern["query"], file_path))
matches.extend(
self._text_search(content, pattern["query"], file_path)
)
except Exception:
continue
@@ -307,7 +313,9 @@ class ASTMultiEdit(BaseTool):
return matches
def _get_parent_context(self, node: tree_sitter.Node, content: str) -> Optional[str]:
def _get_parent_context(
self, node: tree_sitter.Node, content: str
) -> Optional[str]:
"""Get parent context for better understanding."""
parent = node.parent
if parent:
@@ -327,7 +335,9 @@ class ASTMultiEdit(BaseTool):
return None
def _text_search(self, content: str, pattern: str, file_path: str) -> List[ASTMatch]:
def _text_search(
self, content: str, pattern: str, file_path: str
) -> List[ASTMatch]:
"""Fallback text search."""
matches = []
lines = content.split("\n")
@@ -402,14 +412,18 @@ class ASTMultiEdit(BaseTool):
return str(path.parent)
def _group_matches_by_file(self, matches: List[ASTMatch]) -> Dict[str, List[ASTMatch]]:
def _group_matches_by_file(
self, matches: List[ASTMatch]
) -> Dict[str, List[ASTMatch]]:
"""Group matches by file for efficient editing."""
grouped = defaultdict(list)
for match in matches:
grouped[match.file_path].append(match)
return grouped
def _create_unique_context(self, content: str, match: ASTMatch, context_lines: int) -> str:
def _create_unique_context(
self, content: str, match: ASTMatch, context_lines: int
) -> str:
"""Create unique context for edit identification."""
lines = content.split("\n")
@@ -485,20 +499,27 @@ class ASTMultiEdit(BaseTool):
pattern = {"query": edit_op.old_string, "type": "text"}
matches = self._query_ast(tree, pattern, file_path, content)
else:
matches = self._text_search(content, edit_op.old_string, file_path)
matches = self._text_search(
content, edit_op.old_string, file_path
)
# Filter by node types if specified
if edit_op.node_types:
matches = [m for m in matches if m.node_type in edit_op.node_types]
# Check expected count
if edit_op.expect_count is not None and len(matches) != edit_op.expect_count:
if (
edit_op.expect_count is not None
and len(matches) != edit_op.expect_count
):
results["errors"].append(
{
"edit": edit_op.old_string,
"expected": edit_op.expect_count,
"found": len(matches),
"locations": [f"{m.file_path}:{m.line_start}" for m in matches[:5]],
"locations": [
f"{m.file_path}:{m.line_start}" for m in matches[:5]
],
}
)
continue
@@ -526,7 +547,9 @@ class ASTMultiEdit(BaseTool):
success = await self._apply_file_changes(file_path, changes)
if success:
results["edits_applied"] += len(changes)
results["changes"].append({"file": file_path, "edits": len(changes)})
results["changes"].append(
{"file": file_path, "edits": len(changes)}
)
except Exception as e:
results["errors"].append({"file": file_path, "error": str(e)})
@@ -541,7 +564,9 @@ class ASTMultiEdit(BaseTool):
grouped[match.file_path].append((edit_op, match))
return grouped
async def _apply_file_changes(self, file_path: str, changes: List[Tuple[EditOperation, ASTMatch]]) -> bool:
async def _apply_file_changes(
self, file_path: str, changes: List[Tuple[EditOperation, ASTMatch]]
) -> bool:
"""Apply changes to a single file."""
with open(file_path, "r", encoding="utf-8") as f:
content = f.read()
@@ -575,7 +600,9 @@ class ASTMultiEdit(BaseTool):
return True
def _generate_preview(self, matches: List[Tuple[EditOperation, ASTMatch]], page_size: int) -> List[Dict[str, Any]]:
def _generate_preview(
self, matches: List[Tuple[EditOperation, ASTMatch]], page_size: int
) -> List[Dict[str, Any]]:
"""Generate preview of changes."""
preview = []
@@ -598,7 +625,9 @@ class ASTMultiEdit(BaseTool):
return preview
def _fallback_to_basic_edit(self, file_path: str, edits: List[Dict[str, Any]]) -> MCPResourceDocument:
def _fallback_to_basic_edit(
self, file_path: str, edits: List[Dict[str, Any]]
) -> MCPResourceDocument:
"""Fallback to basic multi-edit when treesitter not available."""
# Delegate to existing multi_edit tool
from hanzo_mcp.tools.filesystem.multi_edit import MultiEdit
@@ -21,7 +21,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
the base functionality in FileSystemTool.
"""
async def check_path_allowed(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
async def check_path_allowed(
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
) -> tuple[bool, str]:
"""Check if a path is allowed and log an error if not.
Args:
@@ -38,7 +40,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
return False, f"{error_prefix}: {message}"
return True, ""
async def check_path_exists(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
async def check_path_exists(
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
) -> tuple[bool, str]:
"""Check if a path exists and log an error if not.
Args:
@@ -56,7 +60,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
return False, f"{error_prefix}: {message}"
return True, ""
async def check_is_file(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
async def check_is_file(
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
) -> tuple[bool, str]:
"""Check if a path is a file and log an error if not.
Args:
@@ -74,7 +80,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
return False, f"{error_prefix}: {message}"
return True, ""
async def check_is_directory(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
async def check_is_directory(
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
) -> tuple[bool, str]:
"""Check if a path is a directory and log an error if not.
Args:
@@ -80,7 +80,10 @@ class BatchSearchResults:
return {
"query": self.query,
"total_results": self.total_results,
"results_by_type": {k.value: [r.to_dict() for r in v] for k, v in self.results_by_type.items()},
"results_by_type": {
k.value: [r.to_dict() for r in v]
for k, v in self.results_by_type.items()
},
"combined_results": [r.to_dict() for r in self.combined_results],
"search_time_ms": self.search_time_ms,
}
@@ -93,8 +96,12 @@ Queries = Annotated[
SearchPath = Annotated[str, Field(description="Path to search in", default=".")]
Include = Annotated[str, Field(description="File pattern to include", default="*")]
MaxResults = Annotated[int, Field(description="Maximum results per query", default=20)]
IncludeContext = Annotated[bool, Field(description="Include function/method context", default=True)]
CombineResults = Annotated[bool, Field(description="Combine and deduplicate results", default=True)]
IncludeContext = Annotated[
bool, Field(description="Include function/method context", default=True)
]
CombineResults = Annotated[
bool, Field(description="Combine and deduplicate results", default=True)
]
class BatchSearchParams(TypedDict):
@@ -205,7 +212,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
# If pattern contains natural language, prioritize vector search
words = pattern.split()
if len(words) > 2 and not any(c in pattern for c in ["(", ")", "{", "}", "[", "]"]):
if len(words) > 2 and not any(
c in pattern for c in ["(", ")", "{", "}", "[", "]"]
):
use_vector = True
return use_vector, use_ast, use_symbol
@@ -218,7 +227,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
try:
# Use the existing grep tool
grep_result = await self.grep_tool.call(tool_ctx.mcp_context, pattern=pattern, path=path, include=include)
grep_result = await self.grep_tool.call(
tool_ctx.mcp_context, pattern=pattern, path=path, include=include
)
results = []
if "Found" in grep_result and "matches" in grep_result:
@@ -254,7 +265,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
await tool_ctx.error(f"Grep search failed: {str(e)}")
return []
async def _run_vector_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_vector_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Run vector search and convert results."""
if not self.vector_tool:
return []
@@ -360,7 +373,11 @@ Perfect for comprehensive code analysis and refactoring tasks."""
content=content,
search_type=SearchType.AST,
score=0.9, # High score for AST matches
context=("\n".join(context_lines) if context_lines else None),
context=(
"\n".join(context_lines)
if context_lines
else None
),
)
results.append(result)
@@ -379,7 +396,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
await tool_ctx.error(f"AST search failed: {str(e)}")
return []
async def _run_symbol_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_symbol_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Run symbol search using AST analysis."""
await tool_ctx.info(f"Running symbol search for: {pattern}")
@@ -395,7 +414,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
# Look for source files
for ext in [".py", ".js", ".ts", ".java", ".cpp", ".c"]:
files_to_check.extend(path_obj.rglob(f"*{ext}"))
files_to_check = [str(f) for f in files_to_check[:50]] # Limit for performance
files_to_check = [
str(f) for f in files_to_check[:50]
] # Limit for performance
# Analyze files for symbols
for file_path in files_to_check:
@@ -418,7 +439,11 @@ Perfect for comprehensive code analysis and refactoring tasks."""
file_path=symbol.file_path,
line_number=symbol.line_start,
content=f"{symbol.type} {symbol.name}"
+ (f" - {symbol.docstring[:100]}..." if symbol.docstring else ""),
+ (
f" - {symbol.docstring[:100]}..."
if symbol.docstring
else ""
),
search_type=SearchType.SYMBOL,
score=0.95, # Very high score for symbol matches
symbol_info=symbol,
@@ -439,7 +464,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
await tool_ctx.error(f"Symbol search failed: {str(e)}")
return []
async def _add_function_context(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
async def _add_function_context(
self, results: List[SearchResult], tool_ctx
) -> List[SearchResult]:
"""Add function/method context to results where relevant."""
enhanced_results = []
@@ -461,10 +488,16 @@ Perfect for comprehensive code analysis and refactoring tasks."""
if file_ast:
# Find symbol containing this line
for symbol in file_ast.symbols:
if symbol.line_start <= result.line_number <= symbol.line_end and symbol.type in [
"function",
"method",
]:
if (
symbol.line_start
<= result.line_number
<= symbol.line_end
and symbol.type
in [
"function",
"method",
]
):
enhanced_result = SearchResult(
file_path=result.file_path,
line_number=result.line_number,
@@ -477,13 +510,17 @@ Perfect for comprehensive code analysis and refactoring tasks."""
)
break
except Exception as e:
await tool_ctx.warning(f"Could not add context for {result.file_path}: {str(e)}")
await tool_ctx.warning(
f"Could not add context for {result.file_path}: {str(e)}"
)
enhanced_results.append(enhanced_result)
return enhanced_results
def _combine_and_rank_results(self, results_by_type: Dict[SearchType, List[SearchResult]]) -> List[SearchResult]:
def _combine_and_rank_results(
self, results_by_type: Dict[SearchType, List[SearchResult]]
) -> List[SearchResult]:
"""Combine results from different search types and rank by relevance."""
all_results = []
seen_combinations = set()
@@ -515,7 +552,8 @@ Perfect for comprehensive code analysis and refactoring tasks."""
# Replace existing if: higher priority type, or same priority but higher score
if result_priority > existing_priority or (
result_priority == existing_priority and result.score > existing.score
result_priority == existing_priority
and result.score > existing.score
):
# Replace the entire result to preserve type
idx = all_results.index(existing)
@@ -523,7 +561,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
else:
# Still merge useful information
existing.context = existing.context or result.context
existing.symbol_info = existing.symbol_info or result.symbol_info
existing.symbol_info = (
existing.symbol_info or result.symbol_info
)
break
# Sort by score (descending) then by search type priority
@@ -534,7 +574,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
SearchType.VECTOR: 1,
}
all_results.sort(key=lambda r: (r.score, type_priority[r.search_type]), reverse=True)
all_results.sort(
key=lambda r: (r.score, type_priority[r.search_type]), reverse=True
)
return all_results
@@ -570,7 +612,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
if not exists:
return error_msg
await tool_ctx.info(f"Starting batch search with {len(queries)} queries in {path}")
await tool_ctx.info(
f"Starting batch search with {len(queries)} queries in {path}"
)
# Run all queries in parallel
search_tasks = []
@@ -583,26 +627,44 @@ Perfect for comprehensive code analysis and refactoring tasks."""
if query_type == "grep":
pattern = query.get("pattern")
if pattern:
search_tasks.append(self._run_grep_search(pattern, path, include, tool_ctx, max_results))
search_tasks.append(
self._run_grep_search(
pattern, path, include, tool_ctx, max_results
)
)
elif query_type == "grep_ast":
pattern = query.get("pattern")
if pattern:
search_tasks.append(self._run_ast_search(pattern, path, include, tool_ctx, max_results))
search_tasks.append(
self._run_ast_search(
pattern, path, include, tool_ctx, max_results
)
)
elif query_type == "vector_search" and self.vector_tool:
search_query = query.get("query") or query.get("pattern")
if search_query:
search_tasks.append(self._run_vector_search(search_query, path, tool_ctx, max_results))
search_tasks.append(
self._run_vector_search(
search_query, path, tool_ctx, max_results
)
)
elif query_type == "git_search":
pattern = query.get("pattern")
search_type = query.get("search_type", "content")
if pattern:
search_tasks.append(self._run_git_search(pattern, path, search_type, tool_ctx, max_results))
search_tasks.append(
self._run_git_search(
pattern, path, search_type, tool_ctx, max_results
)
)
else:
await tool_ctx.warning(f"Unknown or unavailable search type: {query_type}")
await tool_ctx.warning(
f"Unknown or unavailable search type: {query_type}"
)
# Execute all searches in parallel
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
@@ -627,7 +689,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
# Add context if requested
if include_context:
combined_results = await self._add_context_to_results(combined_results, tool_ctx)
combined_results = await self._add_context_to_results(
combined_results, tool_ctx
)
end_time = time.time()
search_time_ms = (end_time - start_time) * 1000
@@ -636,17 +700,27 @@ Perfect for comprehensive code analysis and refactoring tasks."""
combined_results.sort(key=lambda r: r.score, reverse=True)
# Limit total results
combined_results = combined_results[: max_results * 2] # Allow more when combining
combined_results = combined_results[
: max_results * 2
] # Allow more when combining
# Create batch results object
batch_results = BatchSearchResults(
query=f"Batch search with {len(queries)} queries",
total_results=len(combined_results),
results_by_type={
SearchType.GREP: [r for r in combined_results if r.search_type == SearchType.GREP],
SearchType.VECTOR: [r for r in combined_results if r.search_type == SearchType.VECTOR],
SearchType.AST: [r for r in combined_results if r.search_type == SearchType.AST],
SearchType.GIT: [r for r in combined_results if r.search_type == SearchType.GIT],
SearchType.GREP: [
r for r in combined_results if r.search_type == SearchType.GREP
],
SearchType.VECTOR: [
r for r in combined_results if r.search_type == SearchType.VECTOR
],
SearchType.AST: [
r for r in combined_results if r.search_type == SearchType.AST
],
SearchType.GIT: [
r for r in combined_results if r.search_type == SearchType.GIT
],
},
combined_results=combined_results,
search_time_ms=search_time_ms,
@@ -728,12 +802,16 @@ Perfect for comprehensive code analysis and refactoring tasks."""
return combined
async def _add_context_to_results(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
async def _add_context_to_results(
self, results: List[SearchResult], tool_ctx
) -> List[SearchResult]:
"""Add function/method context to results."""
# This is a simplified version - you could enhance with full AST context
return await self._add_function_context(results, tool_ctx)
def _format_batch_results(self, results: BatchSearchResults, query_info: List[Dict]) -> str:
def _format_batch_results(
self, results: BatchSearchResults, query_info: List[Dict]
) -> str:
"""Format batch search results for display."""
output = []
@@ -781,7 +859,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
score_str = f"[{result.search_type.value} {result.score:.2f}]"
if result.line_number:
output.append(f" {result.line_number:>4}: {score_str} {result.content}")
output.append(
f" {result.line_number:>4}: {score_str} {result.content}"
)
else:
output.append(f" {score_str} {result.content}")
@@ -159,11 +159,15 @@ Only works within allowed directories."""
# Process based on whether path is a file or directory
if input_path.is_file():
# Single file search
if file_pattern == "*" or fnmatch.fnmatch(input_path.name, file_pattern):
if file_pattern == "*" or fnmatch.fnmatch(
input_path.name, file_pattern
):
matching_files.append(input_path)
await tool_ctx.info(f"Searching single file: {path}")
else:
await tool_ctx.info(f"File does not match pattern '{file_pattern}': {path}")
await tool_ctx.info(
f"File does not match pattern '{file_pattern}': {path}"
)
return f"File does not match pattern '{file_pattern}': {path}"
elif input_path.is_dir():
# Directory search - optimized file finding
@@ -182,7 +186,9 @@ Only works within allowed directories."""
for entry in input_path.rglob("*"):
entry_path = str(entry)
if entry_path in allowed_paths and entry.is_file():
if file_pattern == "*" or fnmatch.fnmatch(entry.name, file_pattern):
if file_pattern == "*" or fnmatch.fnmatch(
entry.name, file_pattern
):
matching_files.append(entry)
await tool_ctx.info(f"Found {len(matching_files)} matching files")
@@ -245,7 +251,9 @@ Only works within allowed directories."""
)
message = f"Dry run: {replacements_made} replacements of '{pattern}' with '{replacement}' would be made in {files_modified} files:"
else:
await tool_ctx.info(f"Made {replacements_made} replacements in {files_modified} files")
await tool_ctx.info(
f"Made {replacements_made} replacements in {files_modified} files"
)
message = f"Made {replacements_made} replacements of '{pattern}' with '{replacement}' in {files_modified} files:"
return message + "\n\n" + "\n".join(results)
@@ -166,8 +166,16 @@ diff a.json b.json --ignore-whitespace"""
output.extend(diff_lines)
# Add summary
additions = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
deletions = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
additions = sum(
1
for line in diff_lines
if line.startswith("+") and not line.startswith("+++")
)
deletions = sum(
1
for line in diff_lines
if line.startswith("-") and not line.startswith("---")
)
output.append("")
output.append("=" * 60)
@@ -112,7 +112,9 @@ requested. Only works within allowed directories."""
await tool_ctx.error(path_validation.error_message)
return f"Error: {path_validation.error_message}"
await tool_ctx.info(f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})")
await tool_ctx.info(
f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})"
)
# Check if path is allowed
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
@@ -151,7 +153,9 @@ requested. Only works within allowed directories."""
}
# Log filtering settings
await tool_ctx.info(f"Directory tree filtering: include_filtered={include_filtered}")
await tool_ctx.info(
f"Directory tree filtering: include_filtered={include_filtered}"
)
# Check if a directory should be filtered
def should_filter(current_path: Path) -> bool:
@@ -161,7 +165,9 @@ requested. Only works within allowed directories."""
return False
# Filter based on directory name if filtering is enabled
return current_path.name in FILTERED_DIRECTORIES and not include_filtered
return (
current_path.name in FILTERED_DIRECTORIES and not include_filtered
)
# Track stats for summary
stats = {
@@ -172,7 +178,9 @@ requested. Only works within allowed directories."""
}
# Build the tree recursively
async def build_tree(current_path: Path, current_depth: int = 0) -> list[dict[str, Any]]:
async def build_tree(
current_path: Path, current_depth: int = 0
) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
# Skip processing if path isn't allowed
@@ -181,7 +189,9 @@ requested. Only works within allowed directories."""
try:
# Sort entries: directories first, then files alphabetically
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
entries = sorted(
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
)
for entry in entries:
# Skip entries that aren't allowed
@@ -210,7 +220,9 @@ requested. Only works within allowed directories."""
continue
# Process children recursively with depth increment
entry_data["children"] = await build_tree(entry, current_depth + 1)
entry_data["children"] = await build_tree(
entry, current_depth + 1
)
result.append(entry_data)
else:
# Files should be at the same level check as directories
@@ -225,7 +237,9 @@ requested. Only works within allowed directories."""
return result
# Format the tree as a simple indented structure
def format_tree(tree_data: list[dict[str, Any]], level: int = 0) -> list[str]:
def format_tree(
tree_data: list[dict[str, Any]], level: int = 0
) -> list[str]:
lines = []
for item in tree_data:
@@ -235,7 +249,9 @@ requested. Only works within allowed directories."""
# Format based on type
if item["type"] == "directory":
if "skipped" in item:
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
lines.append(
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
)
else:
lines.append(f"{indent}{item['name']}/")
# Add children with increased indentation if present
@@ -294,4 +310,6 @@ requested. Only works within allowed directories."""
depth: Depth = 3,
include_filtered: IncludeFiltered = False,
) -> str:
return await tool_self.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
return await tool_self.call(
ctx, path=path, depth=depth, include_filtered=include_filtered
)
@@ -152,7 +152,9 @@ Returns nextCursor if more entries are available."""
await tool_ctx.error(path_validation.error_message)
return {"error": path_validation.error_message}
await tool_ctx.info(f"Getting paginated directory tree: {path} (depth: {depth}, page_size: {page_size})")
await tool_ctx.info(
f"Getting paginated directory tree: {path} (depth: {depth}, page_size: {page_size})"
)
# Check if path is allowed
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
@@ -194,27 +196,35 @@ Returns nextCursor if more entries are available."""
def should_filter(current_path: Path) -> bool:
if str(current_path.absolute()) == str(dir_path.absolute()):
return False
return current_path.name in FILTERED_DIRECTORIES and not include_filtered
return (
current_path.name in FILTERED_DIRECTORIES and not include_filtered
)
# Collect all entries in a flat list for pagination
all_entries: List[Dict[str, Any]] = []
# Build the tree and collect entries
def collect_entries(current_path: Path, current_depth: int = 0, parent_path: str = "") -> None:
def collect_entries(
current_path: Path, current_depth: int = 0, parent_path: str = ""
) -> None:
"""Collect entries in a flat list for pagination."""
if not self.is_path_allowed(str(current_path)):
return
try:
# Sort entries: directories first, then files alphabetically
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
entries = sorted(
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
)
for entry in entries:
if not self.is_path_allowed(str(entry)):
continue
# Calculate relative path for display
relative_path = f"{parent_path}/{entry.name}" if parent_path else entry.name
relative_path = (
f"{parent_path}/{entry.name}" if parent_path else entry.name
)
if entry.is_dir():
entry_data: Dict[str, Any] = {
@@ -121,7 +121,9 @@ Usage:
# Empty old_string is valid when creating a new file
file_exists = Path(file_path).exists()
if file_exists and old_string.strip() == "":
await tool_ctx.error("Parameter 'old_string' cannot be empty for existing files")
await tool_ctx.error(
"Parameter 'old_string' cannot be empty for existing files"
)
return "Error: Parameter 'old_string' cannot be empty for existing files"
if (
@@ -129,8 +131,12 @@ Usage:
or not isinstance(expected_replacements, (int, float))
or expected_replacements < 0
):
await tool_ctx.error("Parameter 'expected_replacements' must be a non-negative number")
return "Error: Parameter 'expected_replacements' must be a non-negative number"
await tool_ctx.error(
"Parameter 'expected_replacements' must be a non-negative number"
)
return (
"Error: Parameter 'expected_replacements' must be a non-negative number"
)
await tool_ctx.info(f"Editing file: {file_path}")
@@ -158,7 +164,9 @@ Usage:
f.write(new_string)
await tool_ctx.info(f"Successfully created file: {file_path}")
return f"Successfully created file: {file_path} ({len(new_string)} bytes)"
return (
f"Successfully created file: {file_path} ({len(new_string)} bytes)"
)
# Check file exists for non-creation operations
exists, error_msg = await self.check_path_exists(file_path, tool_ctx)
@@ -191,7 +199,9 @@ Usage:
modified_content = original_content.replace(old_string, new_string)
else:
# If we can't find the exact string, report an error
await tool_ctx.error("The specified old_string was not found in the file content")
await tool_ctx.error(
"The specified old_string was not found in the file content"
)
return "Error: The specified old_string was not found in the file content. Please check that it matches exactly, including all whitespace and indentation."
# Generate diff
@@ -216,7 +226,9 @@ Usage:
num_backticks += 1
# Format diff with appropriate number of backticks
formatted_diff = f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
formatted_diff = (
f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
)
# Write the file if there are changes
if diff_text:
@@ -199,7 +199,9 @@ Fast, intuitive file content search."""
# Fallback
selected_backend = "grep"
await tool_ctx.info(f"Using {selected_backend} to search for '{pattern}' in {path}")
await tool_ctx.info(
f"Using {selected_backend} to search for '{pattern}' in {path}"
)
# Execute search
if selected_backend == "rg":
@@ -457,7 +459,9 @@ Fast, intuitive file content search."""
context_lines = []
for j in range(start, end):
prefix = ":" if j + 1 == i else "-"
context_lines.append(f"{file_path}:{j + 1}{prefix}{lines[j].rstrip()}")
context_lines.append(
f"{file_path}:{j + 1}{prefix}{lines[j].rstrip()}"
)
results.extend(context_lines)
results.append("") # Separator
else:
@@ -478,7 +482,9 @@ Fast, intuitive file content search."""
await tool_ctx.error(f"Error in fallback grep: {str(e)}")
return f"Error in fallback grep: {str(e)}"
def _match_file_pattern(self, filename: str, include: Optional[str], exclude: Optional[str]) -> bool:
def _match_file_pattern(
self, filename: str, include: Optional[str], exclude: Optional[str]
) -> bool:
"""Check if filename matches include/exclude patterns."""
if include and not fnmatch.fnmatch(filename, include):
return False
@@ -335,7 +335,9 @@ For database search, use 'sql_search' or 'vector_search'.
return f"No files found matching '{pattern}' (using fallback search)"
# Format output
output = [f"Found {len(results)} file(s) matching '{pattern}' (using fallback search):"]
output = [
f"Found {len(results)} file(s) matching '{pattern}' (using fallback search):"
]
output.append("")
for filepath in sorted(results):
@@ -344,7 +346,9 @@ For database search, use 'sql_search' or 'vector_search'.
if count >= max_results:
output.append(f"\n... (showing first {max_results} results)")
output.append("\nNote: Install 'ffind' for faster searching: pip install ffind")
output.append(
"\nNote: Install 'ffind' for faster searching: pip install ffind"
)
return "\n".join(output)
@@ -256,7 +256,9 @@ Examples:
tool_ctx,
)
elif search_type == "blame":
return await self._search_blame(abs_path, pattern, case_sensitive, file_pattern, tool_ctx)
return await self._search_blame(
abs_path, pattern, case_sensitive, file_pattern, tool_ctx
)
else:
return f"Unknown search type: {search_type}"
@@ -305,7 +307,9 @@ Examples:
elif result.returncode == 1:
return f"No matches found for pattern: {pattern}"
else:
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
raise subprocess.CalledProcessError(
result.returncode, cmd, result.stdout, result.stderr
)
async def _search_commits(
self,
@@ -349,11 +353,16 @@ Examples:
lines = result.stdout.strip().split("\n")
if lines and lines[0]:
await tool_ctx.info(f"Found {len(lines)} commits")
return f"Found {len(lines)} commits matching '{pattern}':\n\n" + result.stdout
return (
f"Found {len(lines)} commits matching '{pattern}':\n\n"
+ result.stdout
)
else:
return f"No commits found matching: {pattern}"
else:
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
raise subprocess.CalledProcessError(
result.returncode, cmd, result.stdout, result.stderr
)
async def _search_diff(
self,
@@ -378,7 +387,8 @@ Examples:
import re
case_insensitive_pattern = "".join(
f"[{c.upper()}{c.lower()}]" if c.isalpha() else re.escape(c) for c in pattern
f"[{c.upper()}{c.lower()}]" if c.isalpha() else re.escape(c)
for c in pattern
)
search_flag = f"-G{case_insensitive_pattern}"
@@ -405,7 +415,9 @@ Examples:
if result.returncode == 0 and result.stdout.strip():
# Parse and highlight matching lines
output = self._highlight_diff_matches(result.stdout, pattern, case_sensitive)
output = self._highlight_diff_matches(
result.stdout, pattern, case_sensitive
)
matches = output.count("commit ")
await tool_ctx.info(f"Found {matches} commits with changes")
return f"Found {matches} commits with changes matching '{pattern}':\n\n{output}"
@@ -493,8 +505,9 @@ Examples:
if all_matches:
await tool_ctx.info(f"Found {len(all_matches)} matching lines")
return f"Found {len(all_matches)} lines matching '{pattern}':\n\n" + "\n".join(
all_matches[:50]
return (
f"Found {len(all_matches)} lines matching '{pattern}':\n\n"
+ "\n".join(all_matches[:50])
) # Limit output
else:
return f"No lines found matching: {pattern}"
@@ -521,7 +534,9 @@ Examples:
return f"Found matches for '{pattern}':\n" + "\n".join(output)
def _highlight_diff_matches(self, diff_output: str, pattern: str, case_sensitive: bool) -> str:
def _highlight_diff_matches(
self, diff_output: str, pattern: str, case_sensitive: bool
) -> str:
"""Highlight matching lines in diff output."""
lines = diff_output.split("\n")
output = []
@@ -118,7 +118,9 @@ When you are doing an open ended search that may require multiple rounds of glob
# Special case for tests: direct file path with include pattern that doesn't match
if Path(path).is_file() and include_pattern and include_pattern != "*":
if not fnmatch.fnmatch(Path(path).name, include_pattern):
await tool_ctx.info(f"File does not match pattern '{include_pattern}': {path}")
await tool_ctx.info(
f"File does not match pattern '{include_pattern}': {path}"
)
return f"File does not match pattern '{include_pattern}': {path}"
cmd = ["rg", "--json", pattern]
@@ -142,7 +144,9 @@ When you are doing an open ended search that may require multiple rounds of glob
if process.returncode != 0 and process.returncode != 1:
# rg returns 1 when no matches are found, which is not an error
await tool_ctx.error(f"ripgrep failed with exit code {process.returncode}: {stderr.decode()}")
await tool_ctx.error(
f"ripgrep failed with exit code {process.returncode}: {stderr.decode()}"
)
return f"Error executing ripgrep: {stderr.decode()}"
# Parse the JSON output
@@ -178,7 +182,9 @@ When you are doing an open ended search that may require multiple rounds of glob
if data.get("type") == "match":
path = data.get("data", {}).get("path", {}).get("text", "")
line_number = data.get("data", {}).get("line_number", 0)
line_text = data.get("data", {}).get("lines", {}).get("text", "").rstrip()
line_text = (
data.get("data", {}).get("lines", {}).get("text", "").rstrip()
)
if path not in file_results:
file_results[path] = []
@@ -245,7 +251,9 @@ When you are doing an open ended search that may require multiple rounds of glob
await tool_ctx.info(f"Searching single file: {path}")
else:
# File doesn't match the pattern, return immediately
await tool_ctx.info(f"File does not match pattern '{include_pattern}': {path}")
await tool_ctx.info(
f"File does not match pattern '{include_pattern}': {path}"
)
return f"File does not match pattern '{include_pattern}': {path}"
elif input_path.is_dir():
# Directory search - find all files
@@ -282,7 +290,9 @@ When you are doing an open ended search that may require multiple rounds of glob
if input_path.is_file():
await tool_ctx.info(f"Searching file: {path}")
else:
await tool_ctx.info(f"Searching through {total_files} files in directory")
await tool_ctx.info(
f"Searching through {total_files} files in directory"
)
# Set up for parallel processing
results: list[str] = []
@@ -304,14 +314,18 @@ When you are doing an open ended search that may require multiple rounds of glob
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
if re.search(pattern, line):
file_results.append(f"{file_path}:{line_num}: {line.rstrip()}")
file_results.append(
f"{file_path}:{line_num}: {line.rstrip()}"
)
matches_found += 1
files_processed += 1
except UnicodeDecodeError:
# Skip binary files
files_processed += 1
except Exception as e:
await tool_ctx.warning(f"Error reading {file_path}: {str(e)}")
await tool_ctx.warning(
f"Error reading {file_path}: {str(e)}"
)
except Exception as e:
await tool_ctx.warning(f"Error processing {file_path}: {str(e)}")
@@ -415,7 +429,9 @@ When you are doing an open ended search that may require multiple rounds of glob
truncation_message="\n\n[Grep results truncated due to token limit. Use more specific patterns or paths to reduce output.]",
)
else:
await tool_ctx.info("ripgrep is not installed, using fallback implementation")
await tool_ctx.info(
"ripgrep is not installed, using fallback implementation"
)
result = await self.fallback_grep(pattern, path, tool_ctx, include)
return truncate_response(
result,
@@ -446,4 +462,6 @@ When you are doing an open ended search that may require multiple rounds of glob
include: Include,
) -> str:
# Use 'include' parameter if provided, otherwise fall back to 'file_pattern'
return await tool_self.call(ctx, pattern=pattern, path=path, include=include)
return await tool_self.call(
ctx, pattern=pattern, path=path, include=include
)
@@ -170,11 +170,15 @@ If you want to create a new file, use:
expected_replacements = edit.get("expected_replacements", 1)
if old_string is None:
await tool_ctx.error(f"Parameter 'old_string' in edit at index {i} is required but was None")
await tool_ctx.error(
f"Parameter 'old_string' in edit at index {i} is required but was None"
)
return f"Error: Parameter 'old_string' in edit at index {i} is required but was None"
if new_string is None:
await tool_ctx.error(f"Parameter 'new_string' in edit at index {i} is required but was None")
await tool_ctx.error(
f"Parameter 'new_string' in edit at index {i} is required but was None"
)
return f"Error: Parameter 'new_string' in edit at index {i} is required but was None"
if (
@@ -188,8 +192,12 @@ If you want to create a new file, use:
return f"Error: Parameter 'expected_replacements' in edit at index {i} must be a non-negative number"
if old_string == new_string:
await tool_ctx.error(f"Edit at index {i}: old_string and new_string are identical")
return f"Error: Edit at index {i}: old_string and new_string are identical"
await tool_ctx.error(
f"Edit at index {i}: old_string and new_string are identical"
)
return (
f"Error: Edit at index {i}: old_string and new_string are identical"
)
await tool_ctx.info(f"Applying {len(edits)} edits to file: {file_path}")
@@ -253,7 +261,9 @@ If you want to create a new file, use:
# Check if old_string exists in current content
if old_string not in current_content:
edit_index = i + 1 if not creation_mode else i + 2 # Adjust for display
edit_index = (
i + 1 if not creation_mode else i + 2
) # Adjust for display
await tool_ctx.error(
f"Edit {edit_index}: The specified old_string was not found in the file content"
)
@@ -264,7 +274,9 @@ If you want to create a new file, use:
# Check if the number of occurrences matches expected_replacements
if occurrences != expected_replacements:
edit_index = i + 1 if not creation_mode else i + 2 # Adjust for display
edit_index = (
i + 1 if not creation_mode else i + 2
) # Adjust for display
await tool_ctx.error(
f"Edit {edit_index}: Found {occurrences} occurrences of the specified old_string, but expected {expected_replacements}"
)
@@ -118,12 +118,18 @@ Usage:
await tool_ctx.error("Parameter 'file_path' is required but was None")
return "Error: Parameter 'file_path' is required but was None"
await tool_ctx.info(f"Reading file: {file_path} (offset: {offset}, limit: {limit})")
await tool_ctx.info(
f"Reading file: {file_path} (offset: {offset}, limit: {limit})"
)
# Check if path is allowed
if not self.is_path_allowed(file_path):
await tool_ctx.error(f"Access denied - path outside allowed directories: {file_path}")
return f"Error: Access denied - path outside allowed directories: {file_path}"
await tool_ctx.error(
f"Access denied - path outside allowed directories: {file_path}"
)
return (
f"Error: Access denied - path outside allowed directories: {file_path}"
)
try:
file_path_obj = Path(file_path)
@@ -160,7 +166,10 @@ Usage:
# Truncate long lines
if len(line) > self.MAX_LINE_LENGTH:
line = line[: self.MAX_LINE_LENGTH] + self.LINE_TRUNCATION_INDICATOR
line = (
line[: self.MAX_LINE_LENGTH]
+ self.LINE_TRUNCATION_INDICATOR
)
# Add line with line number (1-based)
lines.append(f"{i + 1:6d} {line.rstrip()}")
@@ -187,12 +196,17 @@ Usage:
# Truncate long lines
if len(line) > self.MAX_LINE_LENGTH:
line = line[: self.MAX_LINE_LENGTH] + self.LINE_TRUNCATION_INDICATOR
line = (
line[: self.MAX_LINE_LENGTH]
+ self.LINE_TRUNCATION_INDICATOR
)
# Add line with line number (1-based)
lines.append(f"{i + 1:6d} {line.rstrip()}")
await tool_ctx.warning(f"File read with latin-1 encoding: {file_path}")
await tool_ctx.warning(
f"File read with latin-1 encoding: {file_path}"
)
except Exception:
await tool_ctx.error(f"Cannot read binary file: {file_path}")
@@ -222,7 +236,9 @@ Usage:
await tool_ctx.error(f"Error reading file: {str(e)}")
return f"Error: {str(e)}"
async def run(self, ctx: MCPContext, file_path: str, offset: int = 0, limit: int = 2000) -> str:
async def run(
self, ctx: MCPContext, file_path: str, offset: int = 0, limit: int = 2000
) -> str:
"""Run method for backwards compatibility with test scripts.
Args:
@@ -255,4 +271,6 @@ Usage:
offset: Offset = 0,
limit: Limit = 2000,
) -> str:
return await tool_self.call(ctx, file_path=file_path, offset=offset, limit=limit)
return await tool_self.call(
ctx, file_path=file_path, offset=offset, limit=limit
)
@@ -142,7 +142,9 @@ understand project-specific requirements and preferences."""
found_configs.append(
{
"path": str(config_path),
"relative_path": str(config_path.relative_to(start_path)),
"relative_path": str(
config_path.relative_to(start_path)
),
"content": content,
"size": len(content),
}
@@ -150,7 +152,9 @@ understand project-specific requirements and preferences."""
await tool_ctx.info(f"Found configuration: {config_path}")
except Exception as e:
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
await tool_ctx.warning(
f"Could not read {config_path}: {str(e)}"
)
# Check if we've reached the root or a git repository root
if current_path.parent == current_path:
@@ -165,7 +169,8 @@ understand project-specific requirements and preferences."""
if (
config_path.exists()
and config_path.is_file()
and str(config_path) not in [c["path"] for c in found_configs]
and str(config_path)
not in [c["path"] for c in found_configs]
):
try:
if self.is_path_allowed(str(config_path)):
@@ -175,15 +180,21 @@ understand project-specific requirements and preferences."""
found_configs.append(
{
"path": str(config_path),
"relative_path": str(config_path.relative_to(start_path)),
"relative_path": str(
config_path.relative_to(start_path)
),
"content": content,
"size": len(content),
}
)
await tool_ctx.info(f"Found configuration: {config_path}")
await tool_ctx.info(
f"Found configuration: {config_path}"
)
except Exception as e:
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
await tool_ctx.warning(
f"Could not read {config_path}: {str(e)}"
)
break
# Move to parent directory
@@ -248,7 +248,9 @@ This is the recommended search tool for comprehensive results."""
) -> List[SearchResult]:
"""Run grep search and parse results."""
try:
result = await self.grep_tool.call(tool_ctx.mcp_context, pattern=pattern, path=path, include=include)
result = await self.grep_tool.call(
tool_ctx.mcp_context, pattern=pattern, path=path, include=include
)
results = []
if "Found" in result and "matches" in result:
@@ -279,7 +281,9 @@ This is the recommended search tool for comprehensive results."""
await tool_ctx.error(f"Grep search failed: {e}")
return []
async def _run_grep_ast_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_grep_ast_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Run AST-aware search and parse results."""
try:
result = await self.grep_ast_tool.call(
@@ -313,7 +317,11 @@ This is the recommended search tool for comprehensive results."""
content=content,
search_type=SearchType.GREP_AST,
score=0.95, # High score for AST matches
context=(" > ".join(current_context) if current_context else None),
context=(
" > ".join(current_context)
if current_context
else None
),
)
)
@@ -331,7 +339,9 @@ This is the recommended search tool for comprehensive results."""
await tool_ctx.error(f"AST search failed: {e}")
return []
async def _run_vector_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_vector_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Run semantic vector search."""
if not self.vector_tool:
return []
@@ -389,7 +399,9 @@ This is the recommended search tool for comprehensive results."""
await tool_ctx.error(f"Vector search failed: {e}")
return []
async def _run_git_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_git_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Run git history search."""
try:
# Search in both content and commits
@@ -428,7 +440,11 @@ This is the recommended search tool for comprehensive results."""
SearchResult(
file_path=parts[0].strip(),
line_number=None,
content=(parts[-1].strip() if len(parts) > 2 else line),
content=(
parts[-1].strip()
if len(parts) > 2
else line
),
search_type=SearchType.GIT,
score=0.8, # Good score for git matches
)
@@ -444,7 +460,9 @@ This is the recommended search tool for comprehensive results."""
await tool_ctx.error(f"Git search failed: {e}")
return []
async def _run_symbol_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
async def _run_symbol_search(
self, pattern: str, path: str, tool_ctx, max_results: int
) -> List[SearchResult]:
"""Search for symbol definitions using grep with specific patterns."""
try:
# Create patterns for common symbol definitions
@@ -459,7 +477,11 @@ This is the recommended search tool for comprehensive results."""
# Run grep searches in parallel for each pattern
tasks = []
for sp in symbol_patterns:
tasks.append(self.grep_tool.call(tool_ctx.mcp_context, pattern=sp, path=path, include="*"))
tasks.append(
self.grep_tool.call(
tool_ctx.mcp_context, pattern=sp, path=path, include="*"
)
)
grep_results = await asyncio.gather(*tasks, return_exceptions=True)
@@ -496,7 +518,9 @@ This is the recommended search tool for comprehensive results."""
await tool_ctx.error(f"Symbol search failed: {e}")
return []
def _deduplicate_results(self, all_results: List[SearchResult]) -> List[SearchResult]:
def _deduplicate_results(
self, all_results: List[SearchResult]
) -> List[SearchResult]:
"""Deduplicate results, keeping the highest scoring version."""
seen = {}
@@ -523,7 +547,9 @@ This is the recommended search tool for comprehensive results."""
}
# Sort by score (descending) and then by type priority
results.sort(key=lambda r: (r.score, type_priority.get(r.search_type, 0)), reverse=True)
results.sort(
key=lambda r: (r.score, type_priority.get(r.search_type, 0)), reverse=True
)
return results
@@ -573,26 +599,42 @@ This is the recommended search tool for comprehensive results."""
search_names = []
if params.get("enable_grep", True) and pattern_analysis["use_grep"]:
search_tasks.append(self._run_grep_search(pattern, path, include, tool_ctx, max_results))
search_tasks.append(
self._run_grep_search(pattern, path, include, tool_ctx, max_results)
)
search_names.append("grep")
if params.get("enable_grep_ast", True) and pattern_analysis["use_grep_ast"]:
search_tasks.append(self._run_grep_ast_search(pattern, path, tool_ctx, max_results))
search_tasks.append(
self._run_grep_ast_search(pattern, path, tool_ctx, max_results)
)
search_names.append("grep_ast")
if params.get("enable_vector", True) and self.vector_tool and pattern_analysis["use_vector"]:
search_tasks.append(self._run_vector_search(pattern, path, tool_ctx, max_results))
if (
params.get("enable_vector", True)
and self.vector_tool
and pattern_analysis["use_vector"]
):
search_tasks.append(
self._run_vector_search(pattern, path, tool_ctx, max_results)
)
search_names.append("vector")
if params.get("enable_git", True) and pattern_analysis["use_git"]:
search_tasks.append(self._run_git_search(pattern, path, tool_ctx, max_results))
search_tasks.append(
self._run_git_search(pattern, path, tool_ctx, max_results)
)
search_names.append("git")
if params.get("enable_symbol", True) and pattern_analysis["use_symbol"]:
search_tasks.append(self._run_symbol_search(pattern, path, tool_ctx, max_results))
search_tasks.append(
self._run_symbol_search(pattern, path, tool_ctx, max_results)
)
search_names.append("symbol")
await tool_ctx.info(f"Running {len(search_tasks)} search types in parallel: {', '.join(search_names)}")
await tool_ctx.info(
f"Running {len(search_tasks)} search types in parallel: {', '.join(search_names)}"
)
# Run all searches in parallel
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
@@ -677,7 +719,9 @@ This is the recommended search tool for comprehensive results."""
score_str = f"[{result.search_type.value} {result.score:.2f}]"
if result.line_number:
output.append(f" {result.line_number:>4}: {score_str} {result.content}")
output.append(
f" {result.line_number:>4}: {score_str} {result.content}"
)
else:
output.append(f" {score_str} {result.content}")
@@ -141,7 +141,9 @@ Finds code structures (functions, classes, methods) with full context."""
# Route to appropriate handler
if action == "search":
return await self._handle_search(params, tool_ctx)
elif action == "ast" or action == "grep_ast": # Support both for backward compatibility
elif (
action == "ast" or action == "grep_ast"
): # Support both for backward compatibility
return await self._handle_ast(params, tool_ctx)
elif action == "index":
return await self._handle_index(params, tool_ctx)
@@ -216,7 +218,9 @@ Finds code structures (functions, classes, methods) with full context."""
output = tc.format()
else:
# Just show matching lines
output = "\n".join([f"{line}: {code.splitlines()[line - 1]}" for line in loi])
output = "\n".join(
[f"{line}: {code.splitlines()[line - 1]}" for line in loi]
)
results.append(f"\n{file_path}:\n{output}\n")
match_count += len(loi)
@@ -321,7 +325,9 @@ Finds code structures (functions, classes, methods) with full context."""
return f"No matches found for '{pattern}' in {path}"
output = [f"=== AST-aware Grep Results for '{pattern}' ==="]
output.append(f"Total matches: {match_count} in {len([r for r in results if '===' in str(r)]) // 4} files\n")
output.append(
f"Total matches: {match_count} in {len([r for r in results if '===' in str(r)]) // 4} files\n"
)
output.extend(results)
if match_count >= limit:
@@ -484,12 +490,16 @@ Finds code structures (functions, classes, methods) with full context."""
for root, _, files in os.walk(path_obj):
for file in files:
file_path = Path(root) / file
if file_path.suffix in extensions and self.is_path_allowed(str(file_path)):
if file_path.suffix in extensions and self.is_path_allowed(
str(file_path)
):
files_to_process.append(str(file_path))
return files_to_process
def _extract_symbols(self, tc: TreeContext, file_path: str) -> Dict[str, List[Dict[str, Any]]]:
def _extract_symbols(
self, tc: TreeContext, file_path: str
) -> Dict[str, List[Dict[str, Any]]]:
"""Extract symbols from a TreeContext (placeholder implementation)."""
# This would need proper tree-sitter queries to extract symbols
# For now, return empty structure
@@ -188,7 +188,9 @@ tree --pattern "*.py" --show-size"""
if pattern:
import fnmatch
entries = [e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()]
entries = [
e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()
]
# Filter dirs only
if dirs_only:
@@ -191,7 +191,9 @@ watch . --recursive --exclude "__pycache__"
output.append("\nWatch cancelled")
# Summary
output.append(f"\nWatch completed after {int(time.time() - start_time)} seconds")
output.append(
f"\nWatch completed after {int(time.time() - start_time)} seconds"
)
output.append(f"Total changes detected: {len(changes)}")
return "\n".join(output)
@@ -125,7 +125,9 @@ Usage:
with open(path_obj, "w", encoding="utf-8") as f:
f.write(content)
await tool_ctx.info(f"Successfully wrote file: {file_path} ({len(content)} bytes)")
await tool_ctx.info(
f"Successfully wrote file: {file_path} ({len(content)} bytes)"
)
return f"Successfully wrote file: {file_path} ({len(content)} bytes)"
except Exception as e:
await tool_ctx.error(f"Error writing file: {str(e)}")
@@ -127,7 +127,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
"""
tool_ctx.set_tool_info(self.name)
async def parse_notebook(self, file_path: Path) -> tuple[dict[str, Any], list[NotebookCellSource]]:
async def parse_notebook(
self, file_path: Path
) -> tuple[dict[str, Any], list[NotebookCellSource]]:
"""Parse a Jupyter notebook file.
Args:
@@ -141,7 +143,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
notebook = json.loads(content)
# Get notebook language
language = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
language = (
notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
)
cells = notebook.get("cells", [])
processed_cells = []
@@ -173,7 +177,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
text = output.get("text", "")
if isinstance(text, list):
text = "".join(text)
outputs.append(NotebookCellOutput(output_type="stream", text=text))
outputs.append(
NotebookCellOutput(output_type="stream", text=text)
)
elif output_type in ["execute_result", "display_data"]:
# Process text output
@@ -199,7 +205,11 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
media_type="image/jpeg",
)
outputs.append(NotebookCellOutput(output_type=output_type, text=text, image=image))
outputs.append(
NotebookCellOutput(
output_type=output_type, text=text, image=image
)
)
elif output_type == "error":
# Format error traceback
@@ -210,13 +220,17 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
# Handle raw text strings and lists of strings
if isinstance(traceback, list):
# Clean ANSI escape codes and join the list but preserve the formatting
clean_traceback = [clean_ansi_escapes(line) for line in traceback]
clean_traceback = [
clean_ansi_escapes(line) for line in traceback
]
traceback_text = "\n".join(clean_traceback)
else:
traceback_text = clean_ansi_escapes(str(traceback))
error_text = f"{ename}: {evalue}\n{traceback_text}"
outputs.append(NotebookCellOutput(output_type="error", text=error_text))
outputs.append(
NotebookCellOutput(output_type="error", text=error_text)
)
# Create cell object
processed_cell = NotebookCellSource(
@@ -151,7 +151,9 @@ jupyter --action create "new.ipynb"
else:
return f"Error: Unknown action '{action}'. Valid actions: read, edit, create, delete, execute"
async def _handle_read(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
async def _handle_read(
self, notebook_path: str, params: Dict[str, Any], tool_ctx
) -> str:
"""Read notebook or specific cell."""
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
if not exists:
@@ -186,7 +188,9 @@ jupyter --action create "new.ipynb"
await tool_ctx.error(f"Failed to read notebook: {str(e)}")
return f"Error reading notebook: {str(e)}"
async def _handle_edit(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
async def _handle_edit(
self, notebook_path: str, params: Dict[str, Any], tool_ctx
) -> str:
"""Edit notebook cell."""
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
if not exists:
@@ -291,7 +295,9 @@ jupyter --action create "new.ipynb"
await tool_ctx.error(f"Failed to create notebook: {str(e)}")
return f"Error creating notebook: {str(e)}"
async def _handle_delete(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
async def _handle_delete(
self, notebook_path: str, params: Dict[str, Any], tool_ctx
) -> str:
"""Delete notebook or cell."""
# If cell specified, delegate to edit with delete mode
if params.get("cell_id") or params.get("cell_index") is not None:
@@ -310,7 +316,9 @@ jupyter --action create "new.ipynb"
await tool_ctx.error(f"Failed to delete notebook: {str(e)}")
return f"Error deleting notebook: {str(e)}"
async def _handle_execute(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
async def _handle_execute(
self, notebook_path: str, params: Dict[str, Any], tool_ctx
) -> str:
"""Execute notebook cells (placeholder for future implementation)."""
return "Error: Cell execution not yet implemented. Use a Jupyter kernel or server for execution."
@@ -137,14 +137,20 @@ class NoteBookEditTool(JupyterBaseTool):
# Don't validate new_source for delete mode
if edit_mode != "delete" and not new_source:
await tool_ctx.error("New source is required for replace or insert operations")
await tool_ctx.error(
"New source is required for replace or insert operations"
)
return "Error: New source is required for replace or insert operations"
await tool_ctx.info(f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})")
await tool_ctx.info(
f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})"
)
# Check if path is allowed
if not self.is_path_allowed(notebook_path):
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
await tool_ctx.error(
f"Access denied - path outside allowed directories: {notebook_path}"
)
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
try:
@@ -180,15 +186,23 @@ class NoteBookEditTool(JupyterBaseTool):
if edit_mode == "insert":
if cell_number > len(cells):
await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
await tool_ctx.error(
f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
)
return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
else: # replace or delete
if cell_number >= len(cells):
await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
await tool_ctx.error(
f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
)
return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
# Get notebook language (needed for context but not directly used in this block)
_ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
_ = (
notebook.get("metadata", {})
.get("language_info", {})
.get("name", "python")
)
# Perform the requested operation
if edit_mode == "replace":
@@ -224,7 +238,9 @@ class NoteBookEditTool(JupyterBaseTool):
change_description = f"Replaced cell {cell_number}"
if cell_type is not None and cell_type != old_type:
change_description += f" (changed type from {old_type} to {cell_type})"
change_description += (
f" (changed type from {old_type} to {cell_type})"
)
elif edit_mode == "insert":
# Create new cell
@@ -241,7 +257,9 @@ class NoteBookEditTool(JupyterBaseTool):
# Insert the cell
cells.insert(cell_number, new_cell)
change_description = f"Inserted new {cell_type} cell at position {cell_number}"
change_description = (
f"Inserted new {cell_type} cell at position {cell_number}"
)
else: # delete
# Store deleted cell info for reporting
@@ -250,14 +268,20 @@ class NoteBookEditTool(JupyterBaseTool):
# Remove the cell
del cells[cell_number]
change_description = f"Deleted {deleted_type} cell at position {cell_number}"
change_description = (
f"Deleted {deleted_type} cell at position {cell_number}"
)
# Write the updated notebook back to file
with open(file_path, "w", encoding="utf-8") as f:
json.dump(notebook, f, indent=1)
await tool_ctx.info(f"Successfully edited notebook: {notebook_path} - {change_description}")
return f"Successfully edited notebook: {notebook_path} - {change_description}"
await tool_ctx.info(
f"Successfully edited notebook: {notebook_path} - {change_description}"
)
return (
f"Successfully edited notebook: {notebook_path} - {change_description}"
)
except Exception as e:
await tool_ctx.error(f"Error editing notebook: {str(e)}")
return f"Error editing notebook: {str(e)}"
@@ -86,7 +86,9 @@ class NotebookReadTool(JupyterBaseTool):
# Check if path is allowed
if not self.is_path_allowed(notebook_path):
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
await tool_ctx.error(
f"Access denied - path outside allowed directories: {notebook_path}"
)
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
try:
@@ -113,7 +115,9 @@ class NotebookReadTool(JupyterBaseTool):
# Format the notebook content as a readable string
result = self.format_notebook_cells(processed_cells)
await tool_ctx.info(f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)")
await tool_ctx.info(
f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)"
)
return result
except json.JSONDecodeError:
await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
@@ -1,22 +1,50 @@
"""LLM tools for Hanzo AI."""
"""LLM tools for Hanzo AI.
This package exposes LLM-related tools. Imports are guarded to keep the
package importable on environments lacking optional dependencies or newer
typing features.
"""
try: # pragma: no cover - guard heavy imports
from hanzo_mcp.tools.llm.llm_tool import LLMTool
except Exception: # pragma: no cover
LLMTool = None # type: ignore
try: # pragma: no cover
from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
except Exception:
LLMManageTool = None # type: ignore
try: # pragma: no cover
from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
except Exception:
ConsensusTool = None # type: ignore
try: # pragma: no cover
from hanzo_mcp.tools.llm.llm_unified import UnifiedLLMTool
except Exception:
UnifiedLLMTool = None # type: ignore
try: # pragma: no cover
from hanzo_mcp.tools.llm.provider_tools import (
GroqTool,
GeminiTool,
OpenAITool,
MistralTool,
AnthropicTool,
PerplexityTool,
create_provider_tools,
)
except Exception: # pragma: no cover
GroqTool = GeminiTool = OpenAITool = MistralTool = AnthropicTool = PerplexityTool = None # type: ignore
def create_provider_tools(*args, **kwargs): # type: ignore
return []
# Legacy imports for backwards compatibility
from hanzo_mcp.tools.llm.llm_tool import LLMTool
from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
from hanzo_mcp.tools.llm.provider_tools import (
GroqTool,
GeminiTool,
OpenAITool,
MistralTool,
AnthropicTool,
PerplexityTool,
create_provider_tools,
)
__all__ = [
"LLMTool",
"LLMTool",
"UnifiedLLMTool",
"ConsensusTool",
"LLMManageTool",
"create_provider_tools",
@@ -211,14 +211,22 @@ The tool will:
)
# Prepare summary of results
successful_responses = [(m, r) for m, r in results.items() if not r.startswith("Error:")]
failed_responses = [(m, r) for m, r in results.items() if r.startswith("Error:")]
successful_responses = [
(m, r) for m, r in results.items() if not r.startswith("Error:")
]
failed_responses = [
(m, r) for m, r in results.items() if r.startswith("Error:")
]
if not successful_responses:
return "Error: All model queries failed:\n\n" + "\n".join([f"{m}: {r}" for m, r in failed_responses])
return "Error: All model queries failed:\n\n" + "\n".join(
[f"{m}: {r}" for m, r in failed_responses]
)
# Use aggregation model to synthesize responses
consensus = await self._aggregate_responses(successful_responses, prompt, aggregation_model)
consensus = await self._aggregate_responses(
successful_responses, prompt, aggregation_model
)
# Format output
output = ["=== LLM Consensus Analysis ==="]
@@ -237,7 +245,9 @@ The tool will:
output.append("\n=== Individual Responses ===")
for model, response in successful_responses:
output.append(f"\n--- {model} ---")
output.append(response[:500] + "..." if len(response) > 500 else response)
output.append(
response[:500] + "..." if len(response) > 500 else response
)
if failed_responses:
output.append("\n=== Failed Queries ===")
@@ -272,7 +282,9 @@ The tool will:
# Create a mock context for the LLM tool
mock_ctx = type("MockContext", (), {"client": None})()
result = await asyncio.wait_for(self.llm_tool.call(mock_ctx, **params), timeout=timeout)
result = await asyncio.wait_for(
self.llm_tool.call(mock_ctx, **params), timeout=timeout
)
return (model, result)
except asyncio.TimeoutError:
return (model, f"Error: Timeout after {timeout} seconds")
@@ -293,7 +305,9 @@ The tool will:
) -> str:
"""Use an LLM to aggregate and analyze responses."""
# Prepare the aggregation prompt
response_summary = "\n\n".join([f"Model: {model}\nResponse: {response}" for model, response in responses])
response_summary = "\n\n".join(
[f"Model: {model}\nResponse: {response}" for model, response in responses]
)
aggregation_prompt = f"""You are analyzing responses from multiple AI models to the following prompt:
@@ -346,7 +360,9 @@ Be concise but thorough. Focus on providing actionable insights."""
for model, response in responses:
output.append(f"- {model}: {len(response)} characters")
output.append("\nNote: Advanced consensus analysis unavailable. Showing basic summary only.")
output.append(
"\nNote: Advanced consensus analysis unavailable. Showing basic summary only."
)
return "\n".join(output)
@@ -192,7 +192,9 @@ Providers are automatically detected based on environment variables:
for provider in available_but_disabled:
env_vars = available_providers.get(provider, [])
output.append(f" - {provider}: {', '.join(env_vars)}")
output.append(f" Use: llm_manage --action enable --provider {provider}")
output.append(
f" Use: llm_manage --action enable --provider {provider}"
)
output.append("")
# Show providers without API keys
@@ -354,7 +356,9 @@ Providers are automatically detected based on environment variables:
except Exception as e:
return f"Error listing models: {str(e)}"
async def _test_model(self, ctx: MCPContext, provider: Optional[str], model: Optional[str]) -> str:
async def _test_model(
self, ctx: MCPContext, provider: Optional[str], model: Optional[str]
) -> str:
"""Test a model to verify it works."""
if not model and not provider:
return "Error: Either model or provider is required for test action"

Some files were not shown because too many files have changed in this diff Show More