style: auto-format code with ruff formatter

This commit is contained in:
2025-09-17 17:27:38 -05:00
parent c4082a01c2
commit 46677a0d82
210 changed files with 1833 additions and 4249 deletions
+1 -3
View File
@@ -16,9 +16,7 @@ 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:
@@ -137,9 +137,7 @@ 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
@@ -159,9 +157,7 @@ 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
@@ -171,9 +167,7 @@ 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
+8 -24
View File
@@ -134,9 +134,7 @@ 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 {
@@ -178,9 +176,7 @@ 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:
@@ -263,9 +259,7 @@ 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:
@@ -293,9 +287,7 @@ 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:
@@ -320,9 +312,7 @@ 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:
@@ -351,9 +341,7 @@ 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.
@@ -365,9 +353,7 @@ 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:
@@ -440,9 +426,7 @@ async def run_bridge_server(config: BridgeConfig):
def main():
"""Main entry point for the bridge."""
parser = argparse.ArgumentParser(
description="MCP Bridge for Claude-to-Claude communication"
)
parser = argparse.ArgumentParser(description="MCP Bridge for Claude-to-Claude communication")
parser.add_argument(
"--target-port",
type=int,
+6 -15
View File
@@ -62,13 +62,12 @@ def main() -> None:
# 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 = argparse.ArgumentParser(description="MCP server implementing Hanzo AI capabilities")
parser.add_argument("--version", action="version", version=f"hanzo-mcp {_version}")
@@ -242,12 +241,8 @@ 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:
@@ -257,9 +252,7 @@ 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
@@ -404,9 +397,7 @@ 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():
+5 -14
View File
@@ -344,10 +344,7 @@ 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:
@@ -380,9 +377,7 @@ 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:
@@ -442,15 +437,11 @@ 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"]
),
disable_search_tools=not any(
settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]
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"]),
host=settings.server.host,
port=settings.server.port,
enabled_tools=settings.enabled_tools, # Pass individual tool configuration
+3 -9
View File
@@ -53,9 +53,7 @@ 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:
@@ -81,13 +79,9 @@ 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)
+6 -20
View File
@@ -53,9 +53,7 @@ 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
@@ -109,9 +107,7 @@ 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)
@@ -123,9 +119,7 @@ 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:
@@ -139,9 +133,7 @@ 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:
@@ -399,11 +391,7 @@ 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
@@ -496,9 +484,7 @@ 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)
@@ -204,6 +204,4 @@ 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
+88 -88
View File
@@ -28,11 +28,11 @@ 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
@@ -41,7 +41,7 @@ class AgentContext(Protocol):
@dataclass
class AgentConfig:
"""Configuration for agent execution."""
model: str = "claude-3-5-sonnet-20241022"
timeout: int = 300
max_retries: int = 3
@@ -49,7 +49,7 @@ class AgentConfig:
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)
@@ -60,13 +60,13 @@ class AgentConfig:
@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)."""
@@ -75,33 +75,33 @@ class AgentResult:
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,
@@ -109,76 +109,76 @@ class BaseAgent(ABC, Generic[TContext]):
**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,
@@ -187,47 +187,47 @@ class BaseAgent(ABC, Generic[TContext]):
**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)
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,
@@ -237,13 +237,13 @@ class BaseAgent(ABC, Generic[TContext]):
**kwargs: Any,
) -> str:
"""Implementation-specific execution.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
Execution output
"""
@@ -252,35 +252,35 @@ class BaseAgent(ABC, Generic[TContext]):
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,
@@ -289,21 +289,21 @@ class CLIAgent(BaseAgent[TContext]):
**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,
@@ -313,7 +313,7 @@ class CLIAgent(BaseAgent[TContext]):
cwd=str(self.config.working_dir) if self.config.working_dir else None,
env=env,
)
# Handle timeout
try:
stdout, stderr = await asyncio.wait_for(
@@ -323,18 +323,18 @@ class CLIAgent(BaseAgent[TContext]):
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,
@@ -343,13 +343,13 @@ class APIAgent(BaseAgent[TContext]):
**kwargs: Any,
) -> str:
"""Execute via API.
Args:
prompt: The prompt
context: Execution context
env: Environment variables
**kwargs: Additional parameters
Returns:
API response
"""
@@ -360,36 +360,36 @@ class APIAgent(BaseAgent[TContext]):
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,
@@ -398,13 +398,13 @@ class AgentOrchestrator:
**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
"""
@@ -414,25 +414,25 @@ class AgentOrchestrator:
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(
@@ -441,12 +441,12 @@ class AgentOrchestrator:
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,
@@ -454,58 +454,58 @@ class AgentOrchestrator:
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
@@ -517,4 +517,4 @@ __all__ = [
"CLIAgent",
"APIAgent",
"AgentOrchestrator",
]
]
+238 -210
View File
@@ -15,7 +15,7 @@ from dataclasses import field, dataclass
class ModelProvider(Enum):
"""Enumeration of AI model providers."""
ANTHROPIC = "anthropic"
OPENAI = "openai"
GOOGLE = "google"
@@ -30,7 +30,7 @@ class ModelProvider(Enum):
@dataclass(frozen=True)
class ModelConfig:
"""Configuration for a single AI model."""
full_name: str
provider: ModelProvider
aliases: Set[str] = field(default_factory=set)
@@ -46,16 +46,16 @@ class ModelConfig:
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:
@@ -63,7 +63,7 @@ class ModelRegistry:
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:
@@ -71,225 +71,253 @@ class ModelRegistry:
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",
))
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",
))
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",
))
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",
))
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",
))
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",
))
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
"""
@@ -300,7 +328,7 @@ class ModelRegistry:
seen_names.add(config.full_name)
results.append(config)
return results
def get_models_supporting(
self,
vision: Optional[bool] = None,
@@ -308,61 +336,61 @@ class ModelRegistry:
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
"""
@@ -370,10 +398,10 @@ class ModelRegistry:
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
"""
@@ -391,10 +419,10 @@ 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
"""
@@ -403,10 +431,10 @@ def resolve_model(model_name: str) -> str:
def get_model_config(model_name: str) -> Optional[ModelConfig]:
"""Get model configuration.
Args:
model_name: Model name or alias
Returns:
Model configuration or None
"""
@@ -415,10 +443,10 @@ def get_model_config(model_name: str) -> Optional[ModelConfig]:
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
"""
@@ -433,4 +461,4 @@ __all__ = [
"resolve_model",
"get_model_config",
"get_api_key_env",
]
]
+5 -15
View File
@@ -159,12 +159,8 @@ 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."""
@@ -218,12 +214,8 @@ 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",
@@ -239,9 +231,7 @@ 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()
+2 -6
View File
@@ -52,9 +52,7 @@ 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)
@@ -78,9 +76,7 @@ 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,13 +49,9 @@ 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)
@@ -105,6 +101,4 @@ 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,9 +596,7 @@ 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)
+7 -21
View File
@@ -51,9 +51,7 @@ 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:
@@ -102,9 +100,7 @@ def get_directory_structure(
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():
@@ -147,9 +143,7 @@ def get_directory_structure(
# 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
@@ -228,9 +222,7 @@ 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))
@@ -238,9 +230,7 @@ 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
@@ -248,13 +238,9 @@ 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"
+2 -6
View File
@@ -9,9 +9,7 @@ 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
@@ -176,9 +174,7 @@ 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
+9 -23
View File
@@ -68,14 +68,17 @@ except Exception:
# Define stub functions for required imports
def activate_mode_from_env():
pass
class ModeLoader:
@staticmethod
def get_enabled_tools_from_mode(base_enabled_tools=None, force_mode=None):
return base_enabled_tools or {}
@staticmethod
def apply_environment_from_mode():
pass
# Try to import LSP tool
try:
from hanzo_mcp.tools.lsp import LSPTool, create_lsp_tool
@@ -134,9 +137,7 @@ 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
@@ -149,9 +150,7 @@ 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:
@@ -196,9 +195,7 @@ 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
@@ -225,9 +222,7 @@ 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
@@ -238,11 +233,7 @@ 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:
@@ -258,12 +249,7 @@ 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
@@ -108,6 +108,7 @@ def register_agent_tools(
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
@@ -127,6 +128,6 @@ def register_agent_tools(
# 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, network_tool, code_auth_tool] + cli_tools
+10 -30
View File
@@ -118,23 +118,17 @@ 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
@@ -189,15 +183,9 @@ 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
@@ -272,14 +260,10 @@ 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
@@ -389,17 +373,13 @@ 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:
@@ -197,9 +197,7 @@ 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):
@@ -324,12 +322,8 @@ 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))
@@ -341,9 +335,7 @@ 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(
@@ -399,9 +391,7 @@ Usage notes:
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"
)
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,12 +151,8 @@ 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))
@@ -174,9 +170,7 @@ 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()
@@ -239,9 +233,7 @@ 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:
@@ -271,9 +263,7 @@ 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:
@@ -310,9 +300,7 @@ 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)}"
@@ -321,9 +309,7 @@ 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:
@@ -352,9 +338,7 @@ 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
@@ -413,9 +397,7 @@ 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:
@@ -480,9 +462,7 @@ 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
@@ -505,9 +485,7 @@ 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
@@ -552,9 +530,7 @@ 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)}"
@@ -572,9 +548,7 @@ 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)}")
@@ -602,8 +576,7 @@ 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,9 +96,7 @@ 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:
@@ -192,9 +190,7 @@ 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
@@ -357,9 +353,7 @@ 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,9 +97,7 @@ 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}"
@@ -115,15 +113,11 @@ 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
@@ -131,12 +125,7 @@ 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
@@ -151,13 +140,9 @@ 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:
@@ -175,9 +160,7 @@ 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)}")
@@ -55,6 +55,7 @@ Timeout = Annotated[
class CLIToolParams(TypedDict, total=False):
"""Common parameters for CLI tools."""
prompt: str
model: Optional[str]
working_dir: Optional[str]
@@ -63,7 +64,7 @@ class CLIToolParams(TypedDict, total=False):
class BaseCLITool(BaseTool):
"""Base class for CLI tool implementations."""
def __init__(
self,
permission_manager: Optional[PermissionManager] = None,
@@ -71,7 +72,7 @@ class BaseCLITool(BaseTool):
api_key_env: Optional[str] = None,
):
"""Initialize CLI tool.
Args:
permission_manager: Permission manager for access control
default_model: Default model to use
@@ -80,21 +81,21 @@ class BaseCLITool(BaseTool):
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],
@@ -103,20 +104,20 @@ class BaseCLITool(BaseTool):
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,
@@ -126,33 +127,33 @@ class BaseCLITool(BaseTool):
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,
@@ -169,33 +170,33 @@ class BaseCLITool(BaseTool):
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,
@@ -207,11 +208,11 @@ class ClaudeCLITool(BaseCLITool):
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)"
@@ -219,34 +220,34 @@ class ClaudeCodeCLITool(ClaudeCLITool):
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,
@@ -257,34 +258,34 @@ class CodexCLITool(BaseCLITool):
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,
@@ -295,34 +296,34 @@ class GeminiCLITool(BaseCLITool):
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,
@@ -333,34 +334,34 @@ class GrokCLITool(BaseCLITool):
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,
@@ -371,11 +372,11 @@ class OpenHandsCLITool(BaseCLITool):
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)"
@@ -383,34 +384,34 @@ class OpenHandsShortCLITool(OpenHandsCLITool):
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] = ["dev"]
if model:
command.extend(["--model", model])
command.extend(["--prompt", prompt])
# Execute
return await self.execute_cli(
command,
@@ -421,31 +422,31 @@ class HanzoDevCLITool(BaseCLITool):
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,
@@ -456,28 +457,28 @@ class ClineCLITool(BaseCLITool):
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:
@@ -485,7 +486,7 @@ class AiderCLITool(BaseCLITool):
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,
@@ -499,11 +500,11 @@ def register_cli_tools(
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
"""
@@ -519,11 +520,11 @@ def register_cli_tools(
ClineCLITool(permission_manager),
AiderCLITool(permission_manager),
]
# Register each tool
for tool in tools:
tool.register(mcp_server)
return tools
@@ -540,4 +541,4 @@ __all__ = [
"ClineCLITool",
"AiderCLITool",
"register_cli_tools",
]
]
@@ -322,9 +322,7 @@ 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,9 +117,7 @@ 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":
@@ -148,9 +146,7 @@ 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,9 +117,7 @@ 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,
@@ -137,15 +135,11 @@ 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:
@@ -160,9 +154,7 @@ 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"
@@ -173,9 +165,7 @@ 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"
@@ -183,9 +173,7 @@ 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
@@ -269,9 +257,7 @@ 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"
@@ -382,8 +368,6 @@ 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,17 +423,13 @@ 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
@@ -458,9 +454,7 @@ 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
@@ -473,9 +467,7 @@ 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"
@@ -498,21 +490,15 @@ 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 = {
@@ -534,9 +520,7 @@ 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"
@@ -547,9 +531,7 @@ 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 = ""
@@ -558,21 +540,15 @@ 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,9 +77,7 @@ 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):
@@ -160,9 +158,7 @@ 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,
}
)
@@ -180,6 +176,7 @@ class NetworkTool(BaseTool):
# 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}
@@ -206,25 +203,17 @@ 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"
),
] = None,
require_local: Annotated[
Optional[bool], Field(description="Require local-only execution")
Field(description="Routing strategy: sequential, parallel, or consensus"),
] = None,
require_local: Annotated[Optional[bool], Field(description="Require local-only execution")] = None,
) -> str:
"""Dispatch work to agent networks."""
params = NetworkToolParams(
@@ -139,11 +139,7 @@ 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,9 +83,7 @@ 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:
@@ -210,23 +208,18 @@ 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:
@@ -264,10 +257,7 @@ 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"
@@ -334,8 +324,7 @@ 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:
@@ -372,9 +361,7 @@ 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:
@@ -426,9 +413,7 @@ 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,9 +61,7 @@ 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,9 +191,7 @@ 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
@@ -294,9 +292,7 @@ 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 = []
@@ -418,9 +414,7 @@ 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:
@@ -495,11 +489,7 @@ 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
@@ -507,21 +497,15 @@ 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(
@@ -549,9 +533,7 @@ 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)
@@ -648,9 +630,7 @@ 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,11 +196,7 @@ 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
@@ -243,9 +239,7 @@ 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 = {}
@@ -292,10 +286,7 @@ 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:
@@ -309,18 +300,14 @@ 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 = []
@@ -337,15 +324,11 @@ 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']}")
@@ -393,9 +376,7 @@ 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):
@@ -409,9 +390,7 @@ 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))
@@ -470,9 +449,7 @@ 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}")
@@ -490,12 +467,7 @@ 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
@@ -21,10 +21,10 @@ 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,
@@ -34,7 +34,7 @@ class UnifiedCLITool(BaseTool, CLIAgent):
permission_manager: Optional[PermissionManager] = None,
):
"""Initialize unified CLI tool.
Args:
name: Tool name
description: Tool description
@@ -45,47 +45,47 @@ class UnifiedCLITool(BaseTool, CLIAgent):
# 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"])
@@ -93,14 +93,14 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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])
@@ -109,7 +109,7 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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")
@@ -117,13 +117,13 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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:
@@ -131,24 +131,24 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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
"""
@@ -159,23 +159,23 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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,
@@ -195,15 +195,15 @@ class UnifiedCLITool(BaseTool, CLIAgent):
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"),
@@ -217,7 +217,7 @@ def create_cli_tools(permission_manager: Optional[PermissionManager] = None) ->
("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,
@@ -226,7 +226,7 @@ def create_cli_tools(permission_manager: Optional[PermissionManager] = None) ->
default_model=default_model,
permission_manager=permission_manager,
)
return tools
@@ -235,20 +235,20 @@ def register_cli_tools(
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())
@@ -256,4 +256,4 @@ __all__ = [
"UnifiedCLITool",
"create_cli_tools",
"register_cli_tools",
]
]
@@ -177,30 +177,22 @@ 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
@@ -213,9 +205,7 @@ 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
@@ -225,9 +215,7 @@ 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
@@ -237,9 +225,7 @@ 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)
@@ -265,9 +251,7 @@ 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
@@ -284,9 +268,7 @@ 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")
@@ -314,9 +296,7 @@ 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:
@@ -326,13 +306,9 @@ 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
@@ -352,12 +328,8 @@ 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"
@@ -424,6 +396,4 @@ 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,17 +87,11 @@ 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":
@@ -158,10 +152,7 @@ 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()),
}
@@ -239,9 +230,7 @@ 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"
@@ -254,9 +243,7 @@ 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"
@@ -269,9 +256,7 @@ 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"
@@ -297,9 +282,7 @@ 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"
@@ -325,9 +308,7 @@ 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,9 +67,7 @@ class ToolContext:
"""
return self._mcp_context.client_id
async 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,9 +153,7 @@ 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,9 +120,7 @@ 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
@@ -192,9 +190,7 @@ 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,9 +85,7 @@ 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:
@@ -98,6 +96,4 @@ 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,9 +143,7 @@ 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
@@ -207,9 +205,7 @@ 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(
@@ -258,9 +254,7 @@ 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:
@@ -275,9 +269,7 @@ 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,9 +42,7 @@ 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:
@@ -81,13 +79,9 @@ 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
@@ -119,9 +113,7 @@ 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])
@@ -130,9 +122,7 @@ 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:
@@ -154,9 +144,7 @@ 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))
@@ -170,9 +158,7 @@ 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))
@@ -181,9 +167,7 @@ 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:
@@ -212,10 +196,7 @@ 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",
}
+1 -5
View File
@@ -94,11 +94,7 @@ 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,11 +68,7 @@ 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
@@ -90,15 +86,11 @@ 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,31 +35,21 @@ 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
@@ -124,9 +114,7 @@ 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
@@ -164,9 +152,7 @@ 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
@@ -199,9 +185,7 @@ 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)
@@ -280,17 +264,13 @@ 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,9 +154,7 @@ 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:
@@ -185,18 +183,14 @@ 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,13 +225,9 @@ 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:
@@ -255,9 +251,7 @@ 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,10 +88,7 @@ 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(
@@ -107,10 +104,7 @@ 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(
@@ -126,10 +120,7 @@ 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(
@@ -145,10 +136,7 @@ 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(
@@ -181,9 +169,7 @@ 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(
@@ -191,10 +177,7 @@ 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(
@@ -242,9 +225,7 @@ 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(
@@ -871,10 +852,7 @@ 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(
@@ -906,10 +884,7 @@ 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,12 +88,7 @@ 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")
@@ -134,15 +129,8 @@ 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
+6 -18
View File
@@ -97,9 +97,7 @@ 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}%")
@@ -109,14 +107,10 @@ 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("")
@@ -173,9 +167,7 @@ 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
@@ -201,9 +193,7 @@ 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)}")
@@ -225,9 +215,7 @@ 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,9 +115,7 @@ 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,9 +125,7 @@ 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"}
@@ -137,9 +135,7 @@ 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,9 +205,7 @@ 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:
@@ -242,9 +240,7 @@ 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,9 +32,7 @@ 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:
@@ -131,9 +131,7 @@ 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"
@@ -173,7 +171,9 @@ config --action toggle index.scope --path ./project"""
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))
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)
@@ -196,9 +196,7 @@ config --action toggle index.scope --path ./project"""
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"
@@ -281,18 +279,14 @@ config --action toggle index.scope --path ./project"""
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 execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
@@ -99,9 +99,7 @@ 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,12 +187,8 @@ 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")
@@ -256,9 +252,7 @@ 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)
@@ -312,17 +306,13 @@ 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,9 +127,7 @@ 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):
@@ -138,15 +136,11 @@ 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,9 +120,7 @@ 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,9 +93,7 @@ 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:
@@ -225,15 +223,11 @@ 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,9 +102,7 @@ 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:
@@ -192,10 +190,7 @@ 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):
@@ -225,27 +220,17 @@ 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)}")
@@ -331,13 +316,9 @@ 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)
@@ -590,9 +571,7 @@ 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,
@@ -603,9 +582,7 @@ 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,9 +74,7 @@ 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:
@@ -220,9 +218,7 @@ 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(
@@ -242,9 +238,7 @@ 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,9 +76,7 @@ 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:
@@ -315,13 +313,8 @@ 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:
@@ -335,16 +328,9 @@ 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,9 +58,7 @@ 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:
@@ -139,9 +137,7 @@ 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()
@@ -156,9 +152,7 @@ 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:
@@ -172,9 +166,7 @@ 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]
@@ -188,9 +180,7 @@ 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]:
@@ -285,31 +275,21 @@ 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:")
@@ -338,9 +318,7 @@ 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 ===")
+4 -12
View File
@@ -75,9 +75,7 @@ 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
@@ -296,9 +294,7 @@ 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})")
@@ -336,9 +332,7 @@ 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)
@@ -402,9 +396,7 @@ 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,9 +48,7 @@ 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:
@@ -140,11 +138,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
@@ -209,9 +207,7 @@ 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,9 +66,7 @@ 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:
@@ -253,9 +251,7 @@ 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,9 +39,7 @@ 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:
@@ -115,9 +113,7 @@ 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
@@ -136,9 +132,7 @@ 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 ===")
@@ -182,9 +176,7 @@ 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}")
@@ -206,9 +198,7 @@ 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,9 +158,7 @@ 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,9 +168,7 @@ 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":
@@ -214,9 +212,7 @@ 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}"
@@ -246,15 +242,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)
@@ -330,9 +326,7 @@ 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,9 +93,7 @@ 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:
@@ -226,6 +224,7 @@ def register_filesystem_tools(
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]
@@ -129,9 +129,7 @@ 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 = []
@@ -149,9 +147,7 @@ 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")
@@ -262,9 +258,7 @@ 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
@@ -313,9 +307,7 @@ 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:
@@ -335,9 +327,7 @@ 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")
@@ -412,18 +402,14 @@ 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")
@@ -499,27 +485,20 @@ 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
@@ -547,9 +526,7 @@ 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)})
@@ -564,9 +541,7 @@ 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()
@@ -600,9 +575,7 @@ 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 = []
@@ -625,9 +598,7 @@ 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,9 +21,7 @@ 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:
@@ -40,9 +38,7 @@ 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:
@@ -60,9 +56,7 @@ 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:
@@ -80,9 +74,7 @@ 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,10 +80,7 @@ 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,
}
@@ -96,12 +93,8 @@ 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):
@@ -212,9 +205,7 @@ 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
@@ -227,9 +218,7 @@ 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:
@@ -265,9 +254,7 @@ 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 []
@@ -373,11 +360,7 @@ 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)
@@ -396,9 +379,7 @@ 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}")
@@ -414,9 +395,7 @@ 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:
@@ -439,11 +418,7 @@ 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,
@@ -464,9 +439,7 @@ 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 = []
@@ -488,16 +461,10 @@ 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,
@@ -510,17 +477,13 @@ 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()
@@ -552,8 +515,7 @@ 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)
@@ -561,9 +523,7 @@ 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
@@ -574,9 +534,7 @@ 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
@@ -612,9 +570,7 @@ 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 = []
@@ -627,44 +583,26 @@ 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)
@@ -689,9 +627,7 @@ 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
@@ -700,27 +636,17 @@ 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,
@@ -802,16 +728,12 @@ 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 = []
@@ -859,9 +781,7 @@ 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,15 +159,11 @@ 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
@@ -186,9 +182,7 @@ 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")
@@ -251,9 +245,7 @@ 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,16 +166,8 @@ 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,9 +112,7 @@ 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)
@@ -153,9 +151,7 @@ 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:
@@ -165,9 +161,7 @@ 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 = {
@@ -178,9 +172,7 @@ 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
@@ -189,9 +181,7 @@ 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
@@ -220,9 +210,7 @@ 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
@@ -237,9 +225,7 @@ 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:
@@ -249,9 +235,7 @@ 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
@@ -310,6 +294,4 @@ 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,9 +152,7 @@ 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)
@@ -196,35 +194,27 @@ 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,9 +121,7 @@ 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 (
@@ -131,12 +129,8 @@ 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}")
@@ -164,9 +158,7 @@ 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)
@@ -199,9 +191,7 @@ 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
@@ -226,9 +216,7 @@ 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,9 +199,7 @@ 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":
@@ -459,9 +457,7 @@ 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:
@@ -482,9 +478,7 @@ 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,9 +335,7 @@ 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):
@@ -346,9 +344,7 @@ 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,9 +256,7 @@ 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}"
@@ -307,9 +305,7 @@ 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,
@@ -353,16 +349,11 @@ 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,
@@ -387,8 +378,7 @@ 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}"
@@ -415,9 +405,7 @@ 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}"
@@ -505,9 +493,8 @@ 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}"
@@ -534,9 +521,7 @@ 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,9 +118,7 @@ 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]
@@ -144,9 +142,7 @@ 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
@@ -182,9 +178,7 @@ 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] = []
@@ -251,9 +245,7 @@ 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
@@ -290,9 +282,7 @@ 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] = []
@@ -314,18 +304,14 @@ 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)}")
@@ -429,9 +415,7 @@ 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,
@@ -462,6 +446,4 @@ 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,15 +170,11 @@ 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 (
@@ -192,12 +188,8 @@ 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}")
@@ -261,9 +253,7 @@ 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"
)
@@ -274,9 +264,7 @@ 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,18 +118,12 @@ 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)
@@ -166,10 +160,7 @@ 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()}")
@@ -196,17 +187,12 @@ 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}")
@@ -236,9 +222,7 @@ 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:
@@ -271,6 +255,4 @@ 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,9 +142,7 @@ 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),
}
@@ -152,9 +150,7 @@ 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:
@@ -169,8 +165,7 @@ 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)):
@@ -180,21 +175,15 @@ 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,9 +248,7 @@ 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:
@@ -281,9 +279,7 @@ 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(
@@ -317,11 +313,7 @@ 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),
)
)
@@ -339,9 +331,7 @@ 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 []
@@ -399,9 +389,7 @@ 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
@@ -440,11 +428,7 @@ 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
)
@@ -460,9 +444,7 @@ 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
@@ -477,11 +459,7 @@ 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)
@@ -518,9 +496,7 @@ 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 = {}
@@ -547,9 +523,7 @@ 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
@@ -599,42 +573,26 @@ 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)
@@ -719,9 +677,7 @@ 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,9 +141,7 @@ 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)
@@ -218,9 +216,7 @@ 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)
@@ -325,9 +321,7 @@ 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:
@@ -490,16 +484,12 @@ 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,9 +188,7 @@ 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,9 +191,7 @@ 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,9 +125,7 @@ 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)}")
+6 -20
View File
@@ -127,9 +127,7 @@ 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:
@@ -143,9 +141,7 @@ 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 = []
@@ -177,9 +173,7 @@ 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
@@ -205,11 +199,7 @@ 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
@@ -220,17 +210,13 @@ 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,9 +151,7 @@ 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:
@@ -188,9 +186,7 @@ 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:
@@ -295,9 +291,7 @@ 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:
@@ -316,9 +310,7 @@ 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,20 +137,14 @@ 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:
@@ -186,23 +180,15 @@ 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":
@@ -238,9 +224,7 @@ 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
@@ -257,9 +241,7 @@ 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
@@ -268,20 +250,14 @@ 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,9 +86,7 @@ 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:
@@ -115,9 +113,7 @@ 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}")
@@ -211,22 +211,14 @@ 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 ==="]
@@ -245,9 +237,7 @@ 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 ===")
@@ -282,9 +272,7 @@ 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")
@@ -305,9 +293,7 @@ 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:
@@ -360,9 +346,7 @@ 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,9 +192,7 @@ 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
@@ -356,9 +354,7 @@ 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"
+17 -58
View File
@@ -294,9 +294,7 @@ Available: {", ".join(available) if available else "None"}"""
# Other actions can fall through to regular path if available
if not LITELLM_AVAILABLE:
return (
"Error: LiteLLM is not installed. Install it with: pip install litellm"
)
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
# Extract action
action = params.get("action", "query")
@@ -315,9 +313,7 @@ Available: {", ".join(available) if available else "None"}"""
elif action == "disable":
return self._handle_disable(params.get("provider"))
elif action == "test":
return await self._handle_test(
tool_ctx, params.get("model"), params.get("provider")
)
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
else:
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
@@ -407,9 +403,7 @@ Available: {", ".join(available) if available else "None"}"""
models = params.get("models")
if not models:
# Use configured or default models
consensus_size = params.get("consensus_size") or self.config.get(
"consensus_size", 3
)
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
models = self._get_consensus_models(consensus_size)
if not models:
@@ -449,11 +443,7 @@ Available: {", ".join(available) if available else "None"}"""
if devil_model:
# Create devil's advocate prompt
responses_text = "\n\n".join(
[
f"Model {i + 1}: {resp['response']}"
for i, resp in enumerate(responses)
if resp["response"]
]
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
)
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
@@ -477,14 +467,10 @@ Provide your critical analysis:"""
}
# Aggregate responses
judge_model = params.get("judge_model") or self.config.get(
"default_judge_model", "gpt-4o"
)
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
include_raw = params.get("include_raw", False)
return await self._aggregate_consensus(
responses, prompt, judge_model, include_raw, devil_response, tool_ctx
)
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
def _handle_list(self) -> str:
"""List available providers."""
@@ -518,9 +504,7 @@ Provide your critical analysis:"""
output.append(f"{provider}: {status}")
output.append(f" Environment variables: {', '.join(env_vars)}")
output.append(
"\nUse 'llm --action enable/disable --provider <name>' to manage providers"
)
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
return "\n".join(output)
@@ -560,16 +544,10 @@ Provide your critical analysis:"""
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
available = (
"✅" if provider_name in self.available_providers else "❌"
)
output.append(
f"{available} {provider_name}: {len(models)} models"
)
available = "✅" if provider_name in self.available_providers else "❌"
output.append(f"{available} {provider_name}: {len(models)} models")
output.append(
"\nUse 'llm --action models --provider <name>' to see specific models"
)
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
return "\n".join(output)
@@ -608,9 +586,7 @@ Provide your critical analysis:"""
else:
return f"{provider} is already disabled"
async def _handle_test(
self, tool_ctx, model: Optional[str], provider: Optional[str]
) -> str:
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
"""Test a model or provider."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
@@ -666,11 +642,7 @@ Provide your critical analysis:"""
break
provider = self._get_provider_for_model(model)
if (
provider
and provider in self.available_providers
and provider not in disabled
):
if provider and provider in self.available_providers and provider not in disabled:
models.append(model)
# If still need more, add from available providers
@@ -703,9 +675,7 @@ Provide your critical analysis:"""
"""Query multiple models in parallel."""
async def query_with_info(model: str) -> Dict[str, Any]:
result = await self._query_single_model(
model, prompt, system_prompt, temperature, max_tokens
)
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
return {
"model": model,
"response": result.get("response"),
@@ -784,12 +754,7 @@ Provide your critical analysis:"""
return "Error: All models failed to respond"
# Format responses for aggregation
responses_text = "\n\n".join(
[
f"Model: {r['model']}\nResponse: {r['response']}"
for r in successful_responses
]
)
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
if devil_response:
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
@@ -818,23 +783,17 @@ Be concise and highlight the most important findings."""
if tool_ctx:
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
judge_result = await self._query_single_model(
judge_model, aggregation_prompt, None, 0.3, None
)
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
if not judge_result["success"]:
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
# Format output
output = [
f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"
]
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
output.append(judge_result["response"])
# Add model list
output.append(
f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}"
)
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
if devil_response:
output.append(f"Devil's Advocate: {devil_response['model']}")
@@ -281,9 +281,7 @@ Available: {', '.join(available) if available else 'None'}"""
pass
if not LITELLM_AVAILABLE:
return (
"Error: LiteLLM is not installed. Install it with: pip install litellm"
)
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
# Extract action
action = params.get("action", "query")
@@ -302,9 +300,7 @@ Available: {', '.join(available) if available else 'None'}"""
elif action == "disable":
return self._handle_disable(params.get("provider"))
elif action == "test":
return await self._handle_test(
tool_ctx, params.get("model"), params.get("provider")
)
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
else:
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
@@ -394,9 +390,7 @@ Available: {', '.join(available) if available else 'None'}"""
models = params.get("models")
if not models:
# Use configured or default models
consensus_size = params.get("consensus_size") or self.config.get(
"consensus_size", 3
)
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
models = self._get_consensus_models(consensus_size)
if not models:
@@ -436,11 +430,7 @@ Available: {', '.join(available) if available else 'None'}"""
if devil_model:
# Create devil's advocate prompt
responses_text = "\n\n".join(
[
f"Model {i+1}: {resp['response']}"
for i, resp in enumerate(responses)
if resp["response"]
]
[f"Model {i+1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
)
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
@@ -464,14 +454,10 @@ Provide your critical analysis:"""
}
# Aggregate responses
judge_model = params.get("judge_model") or self.config.get(
"default_judge_model", "gpt-4o"
)
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
include_raw = params.get("include_raw", False)
return await self._aggregate_consensus(
responses, prompt, judge_model, include_raw, devil_response, tool_ctx
)
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
def _handle_list(self) -> str:
"""List available providers."""
@@ -505,9 +491,7 @@ Provide your critical analysis:"""
output.append(f"{provider}: {status}")
output.append(f" Environment variables: {', '.join(env_vars)}")
output.append(
"\nUse 'llm --action enable/disable --provider <name>' to manage providers"
)
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
return "\n".join(output)
@@ -547,16 +531,10 @@ Provide your critical analysis:"""
# Show providers with counts
for provider_name, models in sorted(all_models.items()):
if models:
available = (
"✅" if provider_name in self.available_providers else "❌"
)
output.append(
f"{available} {provider_name}: {len(models)} models"
)
available = "✅" if provider_name in self.available_providers else "❌"
output.append(f"{available} {provider_name}: {len(models)} models")
output.append(
"\nUse 'llm --action models --provider <name>' to see specific models"
)
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
return "\n".join(output)
@@ -595,9 +573,7 @@ Provide your critical analysis:"""
else:
return f"{provider} is already disabled"
async def _handle_test(
self, tool_ctx, model: Optional[str], provider: Optional[str]
) -> str:
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
"""Test a model or provider."""
if not model and not provider:
return "Error: Either model or provider is required for test action"
@@ -653,11 +629,7 @@ Provide your critical analysis:"""
break
provider = self._get_provider_for_model(model)
if (
provider
and provider in self.available_providers
and provider not in disabled
):
if provider and provider in self.available_providers and provider not in disabled:
models.append(model)
# If still need more, add from available providers
@@ -690,9 +662,7 @@ Provide your critical analysis:"""
"""Query multiple models in parallel."""
async def query_with_info(model: str) -> Dict[str, Any]:
result = await self._query_single_model(
model, prompt, system_prompt, temperature, max_tokens
)
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
return {
"model": model,
"response": result.get("response"),
@@ -771,12 +741,7 @@ Provide your critical analysis:"""
return "Error: All models failed to respond"
# Format responses for aggregation
responses_text = "\n\n".join(
[
f"Model: {r['model']}\nResponse: {r['response']}"
for r in successful_responses
]
)
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
if devil_response:
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
@@ -805,23 +770,17 @@ Be concise and highlight the most important findings."""
if tool_ctx:
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
judge_result = await self._query_single_model(
judge_model, aggregation_prompt, None, 0.3, None
)
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
if not judge_result["success"]:
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
# Format output
output = [
f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"
]
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
output.append(judge_result["response"])
# Add model list
output.append(
f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}"
)
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
if devil_response:
output.append(f"Devil's Advocate: {devil_response['model']}")

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