mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
fix: improve AI tool detection and fallback mechanism (v0.3.31)
- Fix Hanzo Node detection to test actual chat endpoint instead of health check - Check both ports 3690 (default) and 8000 for Hanzo Node - Add session-based failed tool tracking to avoid repeated attempts - Cache working tool selection for better performance - Fix hanzo-mcp integration with proper dependencies - Add comprehensive test suite for tool detection (14 tests) - Test and verify OpenAI Codex integration
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
"""Hanzo AI - Implementation of Hanzo capabilities using MCP."""
|
||||
|
||||
# Polyfill typing.override for Python < 3.12
|
||||
try: # pragma: no cover
|
||||
from typing import override as _override # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
import typing as _typing
|
||||
|
||||
def override(obj): # type: ignore
|
||||
return obj
|
||||
|
||||
_typing.override = override # type: ignore[attr-defined]
|
||||
|
||||
# Configure FastMCP logging globally for stdio transport
|
||||
import os
|
||||
import warnings
|
||||
|
||||
# Suppress litellm deprecation warnings about event loop
|
||||
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
|
||||
warnings.filterwarnings(
|
||||
"ignore", message="There is no current event loop", category=DeprecationWarning
|
||||
)
|
||||
|
||||
if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
|
||||
try:
|
||||
@@ -15,4 +28,4 @@ if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__version__ = "0.7.7"
|
||||
__version__ = "0.8.4"
|
||||
|
||||
@@ -137,7 +137,9 @@ class Analytics:
|
||||
|
||||
self.capture("tool_used", properties)
|
||||
|
||||
def track_error(self, error: Exception, context: Optional[Dict[str, Any]] = None) -> None:
|
||||
def track_error(
|
||||
self, error: Exception, context: Optional[Dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Track an error event."""
|
||||
if not self.config.capture_errors:
|
||||
return
|
||||
@@ -157,7 +159,9 @@ class Analytics:
|
||||
return default
|
||||
|
||||
try:
|
||||
return self._client.feature_enabled(flag_key, self.config.distinct_id, default=default)
|
||||
return self._client.feature_enabled(
|
||||
flag_key, self.config.distinct_id, default=default
|
||||
)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
@@ -167,7 +171,9 @@ class Analytics:
|
||||
return default
|
||||
|
||||
try:
|
||||
return self._client.get_feature_flag(flag_key, self.config.distinct_id, default=default)
|
||||
return self._client.get_feature_flag(
|
||||
flag_key, self.config.distinct_id, default=default
|
||||
)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
@@ -83,7 +83,11 @@ class ClaudeBridge(FastMCP):
|
||||
response = await self._forward_to_claude(message, context)
|
||||
|
||||
self.conversation_history.append(
|
||||
{"from": self.config.target_instance, "to": self.config.source_instance, "response": response}
|
||||
{
|
||||
"from": self.config.target_instance,
|
||||
"to": self.config.source_instance,
|
||||
"response": response,
|
||||
}
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -130,7 +134,9 @@ class ClaudeBridge(FastMCP):
|
||||
"""
|
||||
logger.info(f"Bridge {self.config.instance_id}: Task delegation")
|
||||
|
||||
delegation_prompt = self._build_delegation_prompt(task, requirements, constraints)
|
||||
delegation_prompt = self._build_delegation_prompt(
|
||||
task, requirements, constraints
|
||||
)
|
||||
result = await self._forward_to_claude(delegation_prompt)
|
||||
|
||||
return {
|
||||
@@ -143,7 +149,9 @@ class ClaudeBridge(FastMCP):
|
||||
|
||||
@self.tool()
|
||||
async def get_claude_opinion(
|
||||
question: str, options: Optional[List[str]] = None, criteria: Optional[List[str]] = None
|
||||
question: str,
|
||||
options: Optional[List[str]] = None,
|
||||
criteria: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Get another Claude's opinion on a decision.
|
||||
|
||||
@@ -170,7 +178,9 @@ class ClaudeBridge(FastMCP):
|
||||
}
|
||||
|
||||
@self.tool()
|
||||
async def share_context_with_claude(key: str, value: Any, description: Optional[str] = None) -> bool:
|
||||
async def share_context_with_claude(
|
||||
key: str, value: Any, description: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Share context with another Claude instance.
|
||||
|
||||
Args:
|
||||
@@ -253,7 +263,9 @@ class ClaudeBridge(FastMCP):
|
||||
"shared_context_keys": list(self.shared_context.keys()),
|
||||
}
|
||||
|
||||
def _build_review_prompt(self, code: str, description: str, focus_areas: Optional[List[str]]) -> str:
|
||||
def _build_review_prompt(
|
||||
self, code: str, description: str, focus_areas: Optional[List[str]]
|
||||
) -> str:
|
||||
"""Build a code review prompt."""
|
||||
prompt = f"""
|
||||
Please review the following code:
|
||||
@@ -281,7 +293,9 @@ class ClaudeBridge(FastMCP):
|
||||
|
||||
return prompt
|
||||
|
||||
def _build_delegation_prompt(self, task: str, requirements: List[str], constraints: Optional[List[str]]) -> str:
|
||||
def _build_delegation_prompt(
|
||||
self, task: str, requirements: List[str], constraints: Optional[List[str]]
|
||||
) -> str:
|
||||
"""Build a task delegation prompt."""
|
||||
prompt = f"""
|
||||
Please complete the following task:
|
||||
@@ -306,7 +320,9 @@ class ClaudeBridge(FastMCP):
|
||||
|
||||
return prompt
|
||||
|
||||
def _build_opinion_prompt(self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]) -> str:
|
||||
def _build_opinion_prompt(
|
||||
self, question: str, options: Optional[List[str]], criteria: Optional[List[str]]
|
||||
) -> str:
|
||||
"""Build an opinion request prompt."""
|
||||
prompt = f"""
|
||||
I need your opinion on the following:
|
||||
@@ -335,7 +351,9 @@ class ClaudeBridge(FastMCP):
|
||||
|
||||
return prompt
|
||||
|
||||
async def _forward_to_claude(self, prompt: str, context: Optional[str] = None) -> str:
|
||||
async def _forward_to_claude(
|
||||
self, prompt: str, context: Optional[str] = None
|
||||
) -> str:
|
||||
"""Forward a request to the target Claude instance.
|
||||
|
||||
In production, this would make an actual API call to the Claude instance.
|
||||
@@ -347,7 +365,9 @@ class ClaudeBridge(FastMCP):
|
||||
full_prompt = f"Context: {context}\n\n{prompt}"
|
||||
|
||||
# Log the forwarding
|
||||
logger.info(f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}")
|
||||
logger.info(
|
||||
f"Forwarding from instance {self.config.source_instance} to {self.config.target_instance}"
|
||||
)
|
||||
logger.debug(f"Prompt: {full_prompt[:200]}...")
|
||||
|
||||
# In production, this would:
|
||||
@@ -393,7 +413,10 @@ async def run_bridge_server(config: BridgeConfig):
|
||||
config: Bridge configuration
|
||||
"""
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s")
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
)
|
||||
|
||||
logger.info(f"Starting MCP Bridge for Claude instance {config.instance_id}")
|
||||
logger.info(f"Role: {config.role}")
|
||||
@@ -408,17 +431,36 @@ async def run_bridge_server(config: BridgeConfig):
|
||||
read_stream=read_stream,
|
||||
write_stream=write_stream,
|
||||
initialization_options=InitializationOptions(
|
||||
server_name=bridge.name, server_version="1.0.0", capabilities=bridge.get_capabilities()
|
||||
server_name=bridge.name,
|
||||
server_version="1.0.0",
|
||||
capabilities=bridge.get_capabilities(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the bridge."""
|
||||
parser = argparse.ArgumentParser(description="MCP Bridge for Claude-to-Claude communication")
|
||||
parser.add_argument("--target-port", type=int, required=True, help="Port of the target Claude instance")
|
||||
parser.add_argument("--instance-id", type=int, required=True, help="ID of the target Claude instance")
|
||||
parser.add_argument("--role", type=str, required=True, help="Role of the target instance (primary, critic_1, etc.)")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP Bridge for Claude-to-Claude communication"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-port",
|
||||
type=int,
|
||||
required=True,
|
||||
help="Port of the target Claude instance",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--instance-id",
|
||||
type=int,
|
||||
required=True,
|
||||
help="ID of the target Claude instance",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--role",
|
||||
type=str,
|
||||
required=True,
|
||||
help="Role of the target instance (primary, critic_1, etc.)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
"""Command-line interface for the Hanzo AI server."""
|
||||
"""Command-line interface for the Hanzo AI server.
|
||||
|
||||
This module intentionally defers heavy imports (like the server and its
|
||||
dependencies) until after we determine the transport and configure logging.
|
||||
This prevents any stdout/stderr noise from imports that would corrupt the
|
||||
MCP stdio transport used by Claude Desktop and other MCP clients.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
@@ -9,48 +15,62 @@ import argparse
|
||||
from typing import Any, cast
|
||||
from pathlib import Path
|
||||
|
||||
from hanzo_mcp.server import HanzoMCPServer
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Run the CLI for the Hanzo AI server."""
|
||||
|
||||
# Pre-parse arguments to check transport type early
|
||||
import sys
|
||||
|
||||
# Pre-parse arguments to check transport type early, BEFORE importing server
|
||||
early_parser = argparse.ArgumentParser(add_help=False)
|
||||
early_parser.add_argument("--transport", choices=["stdio", "sse"], default="stdio")
|
||||
early_args, _ = early_parser.parse_known_args()
|
||||
|
||||
# Configure logging VERY early based on transport
|
||||
suppress_stdout = False
|
||||
original_stdout = sys.stdout
|
||||
if early_args.transport == "stdio":
|
||||
# Set environment variable for server to detect stdio mode
|
||||
import os
|
||||
|
||||
# Set environment variable for server to detect stdio mode as early as possible
|
||||
os.environ["HANZO_MCP_TRANSPORT"] = "stdio"
|
||||
# Aggressively quiet common dependency loggers/warnings in stdio mode
|
||||
os.environ.setdefault("PYTHONWARNINGS", "ignore")
|
||||
os.environ.setdefault("LITELLM_LOG", "ERROR")
|
||||
os.environ.setdefault("LITELLM_LOGGING_LEVEL", "ERROR")
|
||||
os.environ.setdefault("FASTMCP_LOG_LEVEL", "ERROR")
|
||||
|
||||
# For stdio transport, disable ALL logging immediately
|
||||
from fastmcp.utilities.logging import configure_logging
|
||||
# Suppress FastMCP logging (if available) and all standard logging
|
||||
try:
|
||||
from fastmcp.utilities.logging import configure_logging # type: ignore
|
||||
|
||||
# Set to ERROR to suppress INFO/WARNING messages from FastMCP
|
||||
configure_logging(level="ERROR")
|
||||
configure_logging(level="ERROR")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Also configure standard logging to ERROR level
|
||||
logging.basicConfig(
|
||||
level=logging.ERROR, # Only show errors
|
||||
handlers=[], # No handlers for stdio to prevent protocol corruption
|
||||
)
|
||||
|
||||
# Redirect stderr to devnull for stdio transport to prevent any output
|
||||
import sys
|
||||
|
||||
sys.stderr = open(os.devnull, "w")
|
||||
|
||||
from hanzo_mcp import __version__
|
||||
# Suppress stdout during potentially noisy imports unless user requested help/version
|
||||
if not any(flag in sys.argv for flag in ("--version", "-h", "--help")):
|
||||
sys.stdout = open(os.devnull, "w")
|
||||
suppress_stdout = True
|
||||
|
||||
parser = argparse.ArgumentParser(description="MCP server implementing Hanzo AI capabilities")
|
||||
# Import the server only AFTER transport/logging have been configured to avoid import-time noise
|
||||
from hanzo_mcp.server import HanzoMCPServer
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"hanzo-mcp {__version__}")
|
||||
# Avoid importing hanzo_mcp package just to get version (it can have side-effects).
|
||||
try:
|
||||
from importlib.metadata import version as _pkg_version # py3.8+
|
||||
_version = _pkg_version("hanzo-mcp")
|
||||
except Exception:
|
||||
_version = "unknown"
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP server implementing Hanzo AI capabilities"
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=f"hanzo-mcp {_version}")
|
||||
|
||||
_ = parser.add_argument(
|
||||
"--transport",
|
||||
@@ -195,6 +215,14 @@ def main() -> None:
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Restore stdout after parsing, before any explicit output or server start
|
||||
if suppress_stdout:
|
||||
try:
|
||||
sys.stdout.close() # Close devnull handle
|
||||
except Exception:
|
||||
pass
|
||||
sys.stdout = original_stdout
|
||||
|
||||
# Cast args attributes to appropriate types to avoid 'Any' warnings
|
||||
name: str = cast(str, args.name)
|
||||
install: bool = cast(bool, args.install)
|
||||
@@ -214,8 +242,12 @@ def main() -> None:
|
||||
port: int = cast(int, args.port)
|
||||
log_level: str = cast(str, args.log_level)
|
||||
project_dir: str | None = cast(str | None, args.project_dir)
|
||||
allowed_paths: list[str] = cast(list[str], args.allowed_paths) if args.allowed_paths else []
|
||||
project_paths: list[str] = cast(list[str], args.project_paths) if args.project_paths else []
|
||||
allowed_paths: list[str] = (
|
||||
cast(list[str], args.allowed_paths) if args.allowed_paths else []
|
||||
)
|
||||
project_paths: list[str] = (
|
||||
cast(list[str], args.project_paths) if args.project_paths else []
|
||||
)
|
||||
|
||||
# Handle project_dir parameter (add to both allowed_paths and project_paths)
|
||||
if project_dir:
|
||||
@@ -225,7 +257,9 @@ def main() -> None:
|
||||
project_paths.append(project_dir)
|
||||
|
||||
if install:
|
||||
install_claude_desktop_config(name, allowed_paths, disable_write_tools, disable_search_tools, host, port)
|
||||
install_claude_desktop_config(
|
||||
name, allowed_paths, disable_write_tools, disable_search_tools, host, port
|
||||
)
|
||||
return
|
||||
|
||||
# Get logger
|
||||
@@ -370,7 +404,9 @@ def install_claude_desktop_config(
|
||||
args.append("--disable-search-tools")
|
||||
|
||||
# Create config object
|
||||
config: dict[str, Any] = {"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}}
|
||||
config: dict[str, Any] = {
|
||||
"mcpServers": {name: {"command": script_path.as_posix(), "args": args}}
|
||||
}
|
||||
|
||||
# Check if the file already exists
|
||||
if config_file.exists():
|
||||
|
||||
@@ -344,7 +344,10 @@ def apply_cli_overrides(args: argparse.Namespace) -> Dict[str, Any]:
|
||||
vector_config["enabled"] = True
|
||||
if hasattr(args, "vector_store_path") and args.vector_store_path:
|
||||
vector_config["data_path"] = args.vector_store_path
|
||||
if hasattr(args, "embedding_model") and args.embedding_model != "text-embedding-3-small":
|
||||
if (
|
||||
hasattr(args, "embedding_model")
|
||||
and args.embedding_model != "text-embedding-3-small"
|
||||
):
|
||||
vector_config["embedding_model"] = args.embedding_model
|
||||
|
||||
if vector_config:
|
||||
@@ -377,7 +380,9 @@ def list_tools(settings: HanzoMCPSettings) -> None:
|
||||
|
||||
logger.info(f"\nTotal: {len(TOOL_REGISTRY)} tools")
|
||||
enabled_count = len(settings.get_enabled_tools())
|
||||
logger.info(f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}")
|
||||
logger.info(
|
||||
f"Enabled: {enabled_count}, Disabled: {len(TOOL_REGISTRY) - enabled_count}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
@@ -437,11 +442,15 @@ def main() -> None:
|
||||
agent_base_url=settings.agent.base_url,
|
||||
agent_max_iterations=settings.agent.max_iterations,
|
||||
agent_max_tool_uses=settings.agent.max_tool_uses,
|
||||
enable_agent_tool=settings.agent.enabled or settings.is_tool_enabled("dispatch_agent"),
|
||||
enable_agent_tool=settings.agent.enabled
|
||||
or settings.is_tool_enabled("dispatch_agent"),
|
||||
disable_write_tools=not any(
|
||||
settings.is_tool_enabled(t) for t in ["write", "edit", "multi_edit", "content_replace"]
|
||||
settings.is_tool_enabled(t)
|
||||
for t in ["write", "edit", "multi_edit", "content_replace"]
|
||||
),
|
||||
disable_search_tools=not any(
|
||||
settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]
|
||||
),
|
||||
disable_search_tools=not any(settings.is_tool_enabled(t) for t in ["grep", "grep_ast"]),
|
||||
host=settings.server.host,
|
||||
port=settings.server.port,
|
||||
enabled_tools=settings.enabled_tools, # Pass individual tool configuration
|
||||
|
||||
@@ -53,7 +53,9 @@ Examples:
|
||||
create_plugin_template(output_dir, args.name)
|
||||
print(f"\n✅ Plugin template created successfully!")
|
||||
print(f"\nTo use your plugin:")
|
||||
print(f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}")
|
||||
print(
|
||||
f"1. Edit the tool implementation in {output_dir / f'{args.name}_tool.py'}"
|
||||
)
|
||||
print(f"2. Restart Hanzo MCP to load the plugin")
|
||||
print(f"3. Add '{args.name}' to your mode's tool list")
|
||||
except Exception as e:
|
||||
@@ -79,9 +81,13 @@ Examples:
|
||||
print(f"\n {name}:")
|
||||
print(f" Source: {plugin.source_path}")
|
||||
if plugin.metadata:
|
||||
print(f" Version: {plugin.metadata.get('version', 'unknown')}")
|
||||
print(
|
||||
f" Version: {plugin.metadata.get('version', 'unknown')}"
|
||||
)
|
||||
print(f" Author: {plugin.metadata.get('author', 'unknown')}")
|
||||
print(f" Description: {plugin.metadata.get('description', '')}")
|
||||
print(
|
||||
f" Description: {plugin.metadata.get('description', '')}"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"❌ Error listing plugins: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
@@ -17,7 +17,11 @@ class ComputeNodeDetector:
|
||||
# Try NVIDIA GPUs
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
|
||||
[
|
||||
"nvidia-smi",
|
||||
"--query-gpu=name,memory.total",
|
||||
"--format=csv,noheader",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
@@ -26,7 +30,14 @@ class ComputeNodeDetector:
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
name, memory = line.split(", ")
|
||||
gpus.append({"type": "cuda", "name": name, "memory": memory, "id": f"cuda:{len(gpus)}"})
|
||||
gpus.append(
|
||||
{
|
||||
"type": "cuda",
|
||||
"name": name,
|
||||
"memory": memory,
|
||||
"id": f"cuda:{len(gpus)}",
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
@@ -35,7 +46,10 @@ class ComputeNodeDetector:
|
||||
try:
|
||||
# Check for Metal support
|
||||
result = subprocess.run(
|
||||
["system_profiler", "SPDisplaysDataType"], capture_output=True, text=True, timeout=2
|
||||
["system_profiler", "SPDisplaysDataType"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
)
|
||||
if result.returncode == 0 and "Metal" in result.stdout:
|
||||
# Parse GPU info from system_profiler
|
||||
@@ -44,7 +58,12 @@ class ComputeNodeDetector:
|
||||
if "Chipset Model:" in line:
|
||||
gpu_name = line.split(":")[1].strip()
|
||||
gpus.append(
|
||||
{"type": "metal", "name": gpu_name, "memory": "Shared", "id": f"metal:{len(gpus)}"}
|
||||
{
|
||||
"type": "metal",
|
||||
"name": gpu_name,
|
||||
"memory": "Shared",
|
||||
"id": f"metal:{len(gpus)}",
|
||||
}
|
||||
)
|
||||
except (FileNotFoundError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
@@ -66,7 +85,14 @@ class ComputeNodeDetector:
|
||||
result = sock.connect_ex(("localhost", int(webgpu_port)))
|
||||
sock.close()
|
||||
if result == 0:
|
||||
webgpu_nodes.append({"type": "webgpu", "name": "Chrome WebGPU", "memory": "Browser", "id": "webgpu:0"})
|
||||
webgpu_nodes.append(
|
||||
{
|
||||
"type": "webgpu",
|
||||
"name": "Chrome WebGPU",
|
||||
"memory": "Browser",
|
||||
"id": "webgpu:0",
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ class ProjectConfig:
|
||||
tasks: List[Dict[str, Any]] = field(default_factory=list)
|
||||
enabled_tools: Dict[str, bool] = field(default_factory=dict)
|
||||
disabled_tools: List[str] = field(default_factory=list)
|
||||
mcp_servers: List[str] = field(default_factory=list) # Names of enabled MCP servers for this project
|
||||
mcp_servers: List[str] = field(
|
||||
default_factory=list
|
||||
) # Names of enabled MCP servers for this project
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -107,7 +109,9 @@ class HanzoMCPSettings:
|
||||
# MCP Hub configuration
|
||||
mcp_servers: Dict[str, MCPServerConfig] = field(default_factory=dict)
|
||||
hub_enabled: bool = False
|
||||
trusted_servers: List[str] = field(default_factory=list) # Whitelist of trusted server names
|
||||
trusted_servers: List[str] = field(
|
||||
default_factory=list
|
||||
) # Whitelist of trusted server names
|
||||
|
||||
# Project-specific configurations
|
||||
projects: Dict[str, ProjectConfig] = field(default_factory=dict)
|
||||
@@ -119,7 +123,9 @@ class HanzoMCPSettings:
|
||||
def __post_init__(self):
|
||||
"""Initialize default tool states if not specified."""
|
||||
if not self.enabled_tools:
|
||||
self.enabled_tools = {name: config.enabled for name, config in TOOL_REGISTRY.items()}
|
||||
self.enabled_tools = {
|
||||
name: config.enabled for name, config in TOOL_REGISTRY.items()
|
||||
}
|
||||
|
||||
# Apply disabled_tools list to enabled_tools dict
|
||||
for tool_name in self.disabled_tools:
|
||||
@@ -133,7 +139,9 @@ class HanzoMCPSettings:
|
||||
return False
|
||||
return self.enabled_tools.get(
|
||||
tool_name,
|
||||
TOOL_REGISTRY.get(tool_name, type("obj", (object,), {"enabled": False})).enabled,
|
||||
TOOL_REGISTRY.get(
|
||||
tool_name, type("obj", (object,), {"enabled": False})
|
||||
).enabled,
|
||||
)
|
||||
|
||||
def enable_tool(self, tool_name: str) -> bool:
|
||||
@@ -391,7 +399,11 @@ def _load_from_env() -> Dict[str, Any]:
|
||||
config.setdefault("agent", {})["enabled"] = True
|
||||
|
||||
# Check for MODE/PERSONALITY/HANZO_MODE
|
||||
if mode := os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE"):
|
||||
if (
|
||||
mode := os.environ.get("HANZO_MODE")
|
||||
or os.environ.get("PERSONALITY")
|
||||
or os.environ.get("MODE")
|
||||
):
|
||||
config["active_mode"] = mode
|
||||
|
||||
# Check for other environment overrides
|
||||
@@ -484,7 +496,9 @@ def save_settings(settings: HanzoMCPSettings, global_config: bool = True) -> Pat
|
||||
return config_path
|
||||
|
||||
|
||||
def _merge_config(base_settings: HanzoMCPSettings, config_dict: Dict[str, Any]) -> HanzoMCPSettings:
|
||||
def _merge_config(
|
||||
base_settings: HanzoMCPSettings, config_dict: Dict[str, Any]
|
||||
) -> HanzoMCPSettings:
|
||||
"""Merge configuration dictionary into settings object."""
|
||||
# Convert to dict, merge, then convert back
|
||||
base_dict = asdict(base_settings)
|
||||
@@ -500,6 +514,17 @@ def _merge_config(base_settings: HanzoMCPSettings, config_dict: Dict[str, Any])
|
||||
|
||||
merged = deep_merge(base_dict, config_dict)
|
||||
|
||||
# Backwards/forwards compatibility: support a structured "tools" section
|
||||
# where each tool can define { enabled: bool, ...options } and map it to
|
||||
# the existing enabled_tools/disabled_tools layout.
|
||||
tools_cfg = merged.get("tools", {})
|
||||
if isinstance(tools_cfg, dict):
|
||||
enabled_tools = dict(merged.get("enabled_tools", {}))
|
||||
for tool_name, tool_data in tools_cfg.items():
|
||||
if isinstance(tool_data, dict) and "enabled" in tool_data:
|
||||
enabled_tools[tool_name] = bool(tool_data.get("enabled"))
|
||||
merged["enabled_tools"] = enabled_tools
|
||||
|
||||
# Reconstruct the settings object
|
||||
mcp_servers = {}
|
||||
for name, server_data in merged.get("mcp_servers", {}).items():
|
||||
|
||||
@@ -204,4 +204,6 @@ def disable_tool(tool_name: str) -> bool:
|
||||
|
||||
def is_tool_enabled(tool_name: str) -> bool:
|
||||
"""Check if a tool is enabled."""
|
||||
return TOOL_REGISTRY.get(tool_name, ToolConfig("", ToolCategory.COMMON, False)).enabled
|
||||
return TOOL_REGISTRY.get(
|
||||
tool_name, ToolConfig("", ToolCategory.COMMON, False)
|
||||
).enabled
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
"""Base Agent - Unified foundation for all AI agent implementations.
|
||||
|
||||
This module provides the single base class for all agent operations,
|
||||
following DRY principles and ensuring consistent behavior across all agents.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Generic, TypeVar, Optional, Protocol, runtime_checkable
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import field, dataclass
|
||||
|
||||
from .model_registry import ModelConfig, registry
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Type variables for generic context
|
||||
TContext = TypeVar("TContext")
|
||||
TResult = TypeVar("TResult")
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AgentContext(Protocol):
|
||||
"""Protocol for agent execution context."""
|
||||
|
||||
async def log(self, message: str, level: str = "info") -> None:
|
||||
"""Log a message."""
|
||||
pass
|
||||
|
||||
async def progress(self, message: str, percentage: Optional[float] = None) -> None:
|
||||
"""Report progress."""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Configuration for agent execution."""
|
||||
|
||||
model: str = "claude-3-5-sonnet-20241022"
|
||||
timeout: int = 300
|
||||
max_retries: int = 3
|
||||
working_dir: Optional[Path] = None
|
||||
environment: Dict[str, str] = field(default_factory=dict)
|
||||
stream_output: bool = False
|
||||
use_worktree: bool = False
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Resolve model name and validate configuration."""
|
||||
self.model = registry.resolve(self.model)
|
||||
if self.working_dir and not isinstance(self.working_dir, Path):
|
||||
self.working_dir = Path(self.working_dir)
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentResult:
|
||||
"""Result from agent execution."""
|
||||
|
||||
success: bool
|
||||
output: Optional[str] = None
|
||||
error: Optional[str] = None
|
||||
duration: Optional[float] = None
|
||||
metadata: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def content(self) -> str:
|
||||
"""Get the primary content (output or error)."""
|
||||
return self.output if self.success else (self.error or "Unknown error")
|
||||
|
||||
|
||||
class BaseAgent(ABC, Generic[TContext]):
|
||||
"""Base class for all AI agents.
|
||||
|
||||
This is the single foundation for all agent implementations,
|
||||
ensuring consistent behavior and eliminating code duplication.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[AgentConfig] = None) -> None:
|
||||
"""Initialize agent with configuration.
|
||||
|
||||
Args:
|
||||
config: Agent configuration
|
||||
"""
|
||||
self.config = config or AgentConfig()
|
||||
self._start_time: Optional[datetime] = None
|
||||
self._end_time: Optional[datetime] = None
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Agent name."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def description(self) -> str:
|
||||
"""Agent description."""
|
||||
pass
|
||||
|
||||
async def execute(
|
||||
self,
|
||||
prompt: str,
|
||||
context: Optional[TContext] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Execute agent with prompt.
|
||||
|
||||
Args:
|
||||
prompt: The prompt or task
|
||||
context: Execution context
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Agent execution result
|
||||
"""
|
||||
self._start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# Setup environment
|
||||
env = self._prepare_environment()
|
||||
|
||||
# Log start
|
||||
if context and isinstance(context, AgentContext):
|
||||
await context.log(f"Starting {self.name} with model {self.config.model}")
|
||||
|
||||
# Execute with retries
|
||||
result = await self._execute_with_retries(prompt, context, env, **kwargs)
|
||||
|
||||
# Calculate duration
|
||||
self._end_time = datetime.now()
|
||||
duration = (self._end_time - self._start_time).total_seconds()
|
||||
|
||||
return AgentResult(
|
||||
success=True,
|
||||
output=result,
|
||||
duration=duration,
|
||||
metadata={"model": self.config.model, "agent": self.name},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._end_time = datetime.now()
|
||||
duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
|
||||
|
||||
logger.error(f"Agent {self.name} failed: {e}")
|
||||
|
||||
return AgentResult(
|
||||
success=False,
|
||||
error=str(e),
|
||||
duration=duration,
|
||||
metadata={"model": self.config.model, "agent": self.name},
|
||||
)
|
||||
|
||||
def _prepare_environment(self) -> Dict[str, str]:
|
||||
"""Prepare environment variables for execution.
|
||||
|
||||
Returns:
|
||||
Environment variables dictionary
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Add model-specific API key
|
||||
model_config = registry.get(self.config.model)
|
||||
if model_config and model_config.api_key_env:
|
||||
key_var = model_config.api_key_env
|
||||
if key_var in os.environ:
|
||||
env[key_var] = os.environ[key_var]
|
||||
|
||||
# Add Hanzo unified auth
|
||||
if "HANZO_API_KEY" in os.environ:
|
||||
env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
|
||||
|
||||
# Add custom environment
|
||||
env.update(self.config.environment)
|
||||
|
||||
return env
|
||||
|
||||
async def _execute_with_retries(
|
||||
self,
|
||||
prompt: str,
|
||||
context: Optional[TContext],
|
||||
env: Dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute with retry logic.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
context: Execution context
|
||||
env: Environment variables
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Execution output
|
||||
|
||||
Raises:
|
||||
Exception: If all retries fail
|
||||
"""
|
||||
last_error = None
|
||||
|
||||
for attempt in range(self.config.max_retries):
|
||||
try:
|
||||
# Call the implementation
|
||||
result = await self._execute_impl(prompt, context, env, **kwargs)
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
last_error = f"Timeout after {self.config.timeout} seconds"
|
||||
if context and isinstance(context, AgentContext):
|
||||
await context.log(f"Attempt {attempt + 1} timed out", "warning")
|
||||
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
if context and isinstance(context, AgentContext):
|
||||
await context.log(f"Attempt {attempt + 1} failed: {e}", "warning")
|
||||
|
||||
# Don't retry on certain errors
|
||||
if "unauthorized" in str(e).lower() or "forbidden" in str(e).lower():
|
||||
raise
|
||||
|
||||
# Wait before retry (exponential backoff)
|
||||
if attempt < self.config.max_retries - 1:
|
||||
await asyncio.sleep(2 ** attempt)
|
||||
|
||||
raise Exception(f"All {self.config.max_retries} attempts failed. Last error: {last_error}")
|
||||
|
||||
@abstractmethod
|
||||
async def _execute_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
context: Optional[TContext],
|
||||
env: Dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Implementation-specific execution.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
context: Execution context
|
||||
env: Environment variables
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Execution output
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class CLIAgent(BaseAgent[TContext]):
|
||||
"""Base class for CLI-based agents."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def cli_command(self) -> str:
|
||||
"""CLI command to execute."""
|
||||
pass
|
||||
|
||||
def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
|
||||
"""Build the CLI command.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Command arguments list
|
||||
"""
|
||||
command = [self.cli_command]
|
||||
|
||||
# Add model if specified
|
||||
model_config = registry.get(self.config.model)
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
|
||||
# Add prompt
|
||||
command.append(prompt)
|
||||
|
||||
return command
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
context: Optional[TContext],
|
||||
env: Dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute CLI command.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
context: Execution context
|
||||
env: Environment variables
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
command = self.build_command(prompt, **kwargs)
|
||||
|
||||
# Determine if we need stdin
|
||||
needs_stdin = self.cli_command in ["claude", "cline"]
|
||||
|
||||
# Execute command
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE if needs_stdin else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=str(self.config.working_dir) if self.config.working_dir else None,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Handle timeout
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(prompt.encode() if needs_stdin else None),
|
||||
timeout=self.config.timeout,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
|
||||
|
||||
# Check for errors
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Command failed"
|
||||
raise Exception(error_msg)
|
||||
|
||||
return stdout.decode()
|
||||
|
||||
|
||||
class APIAgent(BaseAgent[TContext]):
|
||||
"""Base class for API-based agents."""
|
||||
|
||||
async def _execute_impl(
|
||||
self,
|
||||
prompt: str,
|
||||
context: Optional[TContext],
|
||||
env: Dict[str, str],
|
||||
**kwargs: Any,
|
||||
) -> str:
|
||||
"""Execute via API.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
context: Execution context
|
||||
env: Environment variables
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
API response
|
||||
"""
|
||||
# This would be implemented by specific API agents
|
||||
# using the appropriate client library
|
||||
raise NotImplementedError("API agents must implement _execute_impl")
|
||||
|
||||
|
||||
class AgentOrchestrator:
|
||||
"""Orchestrator for managing multiple agents."""
|
||||
|
||||
def __init__(self, default_config: Optional[AgentConfig] = None) -> None:
|
||||
"""Initialize orchestrator.
|
||||
|
||||
Args:
|
||||
default_config: Default configuration for agents
|
||||
"""
|
||||
self.default_config = default_config or AgentConfig()
|
||||
self._agents: Dict[str, BaseAgent] = {}
|
||||
self._semaphore: Optional[asyncio.Semaphore] = None
|
||||
|
||||
def register(self, agent: BaseAgent) -> None:
|
||||
"""Register an agent.
|
||||
|
||||
Args:
|
||||
agent: Agent to register
|
||||
"""
|
||||
self._agents[agent.name] = agent
|
||||
|
||||
def get_agent(self, name: str) -> Optional[BaseAgent]:
|
||||
"""Get agent by name.
|
||||
|
||||
Args:
|
||||
name: Agent name
|
||||
|
||||
Returns:
|
||||
Agent instance or None
|
||||
"""
|
||||
return self._agents.get(name)
|
||||
|
||||
async def execute_single(
|
||||
self,
|
||||
agent_name: str,
|
||||
prompt: str,
|
||||
context: Optional[Any] = None,
|
||||
**kwargs: Any,
|
||||
) -> AgentResult:
|
||||
"""Execute single agent.
|
||||
|
||||
Args:
|
||||
agent_name: Name of agent to use
|
||||
prompt: The prompt
|
||||
context: Execution context
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
agent = self.get_agent(agent_name)
|
||||
if not agent:
|
||||
return AgentResult(
|
||||
success=False,
|
||||
error=f"Agent '{agent_name}' not found",
|
||||
)
|
||||
|
||||
return await agent.execute(prompt, context, **kwargs)
|
||||
|
||||
async def execute_parallel(
|
||||
self,
|
||||
tasks: List[Dict[str, Any]],
|
||||
max_concurrent: int = 5,
|
||||
) -> List[AgentResult]:
|
||||
"""Execute multiple agents in parallel.
|
||||
|
||||
Args:
|
||||
tasks: List of task definitions
|
||||
max_concurrent: Maximum concurrent executions
|
||||
|
||||
Returns:
|
||||
List of results
|
||||
"""
|
||||
self._semaphore = asyncio.Semaphore(max_concurrent)
|
||||
|
||||
async def run_with_semaphore(task: Dict[str, Any]) -> AgentResult:
|
||||
async with self._semaphore:
|
||||
return await self.execute_single(
|
||||
task["agent"],
|
||||
task["prompt"],
|
||||
task.get("context"),
|
||||
**task.get("kwargs", {}),
|
||||
)
|
||||
|
||||
return await asyncio.gather(
|
||||
*[run_with_semaphore(task) for task in tasks],
|
||||
return_exceptions=False,
|
||||
)
|
||||
|
||||
async def execute_consensus(
|
||||
self,
|
||||
prompt: str,
|
||||
agents: List[str],
|
||||
threshold: float = 0.66,
|
||||
) -> Dict[str, Any]:
|
||||
"""Execute consensus operation with multiple agents.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
agents: List of agent names
|
||||
threshold: Agreement threshold
|
||||
|
||||
Returns:
|
||||
Consensus results
|
||||
"""
|
||||
# Execute all agents in parallel
|
||||
tasks = [{"agent": agent, "prompt": prompt} for agent in agents]
|
||||
results = await self.execute_parallel(tasks)
|
||||
|
||||
# Analyze consensus
|
||||
successful = [r for r in results if r.success]
|
||||
agreement = len(successful) / len(results) if results else 0
|
||||
|
||||
return {
|
||||
"consensus_reached": agreement >= threshold,
|
||||
"agreement_score": agreement,
|
||||
"individual_results": results,
|
||||
"agents_used": agents,
|
||||
}
|
||||
|
||||
async def execute_chain(
|
||||
self,
|
||||
initial_prompt: str,
|
||||
agents: List[str],
|
||||
) -> List[AgentResult]:
|
||||
"""Execute agents in a chain, passing output forward.
|
||||
|
||||
Args:
|
||||
initial_prompt: Initial prompt
|
||||
agents: List of agent names
|
||||
|
||||
Returns:
|
||||
List of results from each step
|
||||
"""
|
||||
results = []
|
||||
current_prompt = initial_prompt
|
||||
|
||||
for agent_name in agents:
|
||||
result = await self.execute_single(agent_name, current_prompt)
|
||||
results.append(result)
|
||||
|
||||
if result.success and result.output:
|
||||
# Use output as input for next agent
|
||||
current_prompt = f"Review and improve:\n{result.output}"
|
||||
else:
|
||||
# Chain broken
|
||||
break
|
||||
|
||||
return results
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AgentContext",
|
||||
"AgentConfig",
|
||||
"AgentResult",
|
||||
"BaseAgent",
|
||||
"CLIAgent",
|
||||
"APIAgent",
|
||||
"AgentOrchestrator",
|
||||
]
|
||||
@@ -0,0 +1,436 @@
|
||||
"""Unified Model Registry - Single source of truth for all AI model mappings.
|
||||
|
||||
This module provides a centralized registry for AI model configurations,
|
||||
eliminating duplication and ensuring consistency across the codebase.
|
||||
Thread-safe singleton implementation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from enum import Enum
|
||||
from typing import Any, Set, Dict, List, Optional
|
||||
from dataclasses import field, dataclass
|
||||
|
||||
|
||||
class ModelProvider(Enum):
|
||||
"""Enumeration of AI model providers."""
|
||||
|
||||
ANTHROPIC = "anthropic"
|
||||
OPENAI = "openai"
|
||||
GOOGLE = "google"
|
||||
XAI = "xai"
|
||||
OLLAMA = "ollama"
|
||||
DEEPSEEK = "deepseek"
|
||||
MISTRAL = "mistral"
|
||||
META = "meta"
|
||||
HANZO = "hanzo"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelConfig:
|
||||
"""Configuration for a single AI model."""
|
||||
|
||||
full_name: str
|
||||
provider: ModelProvider
|
||||
aliases: Set[str] = field(default_factory=set)
|
||||
default_params: Dict[str, Any] = field(default_factory=dict)
|
||||
supports_vision: bool = False
|
||||
supports_tools: bool = False
|
||||
supports_streaming: bool = True
|
||||
context_window: int = 8192
|
||||
max_output: int = 4096
|
||||
api_key_env: Optional[str] = None
|
||||
cli_command: Optional[str] = None
|
||||
|
||||
|
||||
class ModelRegistry:
|
||||
"""Centralized registry for all AI models.
|
||||
|
||||
Thread-safe singleton implementation ensuring single source of truth
|
||||
for model configurations across the codebase.
|
||||
"""
|
||||
|
||||
_instance: Optional[ModelRegistry] = None
|
||||
_lock = threading.Lock()
|
||||
_models: Dict[str, ModelConfig] = {}
|
||||
_initialized = False
|
||||
|
||||
def __new__(cls) -> ModelRegistry:
|
||||
"""Thread-safe singleton pattern."""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize model registry once."""
|
||||
if not self._initialized:
|
||||
with self._lock:
|
||||
if not self._initialized:
|
||||
self._initialize_models()
|
||||
self._initialized = True
|
||||
|
||||
def _initialize_models(self) -> None:
|
||||
"""Initialize all model configurations."""
|
||||
# Claude models
|
||||
self._register(ModelConfig(
|
||||
full_name="claude-3-5-sonnet-20241022",
|
||||
provider=ModelProvider.ANTHROPIC,
|
||||
aliases={"claude", "cc", "claude-code", "sonnet", "sonnet-4.1"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=200000,
|
||||
max_output=8192,
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
cli_command="claude",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="claude-opus-4-1-20250805",
|
||||
provider=ModelProvider.ANTHROPIC,
|
||||
aliases={"opus", "opus-4.1", "claude-opus"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=200000,
|
||||
max_output=8192,
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
cli_command="claude",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="claude-3-haiku-20240307",
|
||||
provider=ModelProvider.ANTHROPIC,
|
||||
aliases={"haiku", "claude-haiku"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=200000,
|
||||
max_output=4096,
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
cli_command="claude",
|
||||
))
|
||||
|
||||
# OpenAI models
|
||||
self._register(ModelConfig(
|
||||
full_name="gpt-4-turbo",
|
||||
provider=ModelProvider.OPENAI,
|
||||
aliases={"gpt4", "gpt-4", "codex"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=128000,
|
||||
max_output=4096,
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
cli_command="openai",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="gpt-5-turbo",
|
||||
provider=ModelProvider.OPENAI,
|
||||
aliases={"gpt5", "gpt-5"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=256000,
|
||||
max_output=16384,
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
cli_command="openai",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="o1-preview",
|
||||
provider=ModelProvider.OPENAI,
|
||||
aliases={"o1", "openai-o1"},
|
||||
supports_vision=False,
|
||||
supports_tools=False,
|
||||
context_window=128000,
|
||||
max_output=32768,
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
cli_command="openai",
|
||||
))
|
||||
|
||||
# Google models
|
||||
self._register(ModelConfig(
|
||||
full_name="gemini-2.0-flash-exp",
|
||||
provider=ModelProvider.GOOGLE,
|
||||
aliases={"gemini-2", "gemini-2.0", "gemini2"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=1000000,
|
||||
max_output=8192,
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
cli_command="gemini",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="gemini-exp-1206",
|
||||
provider=ModelProvider.GOOGLE,
|
||||
aliases={"gemini-2.5", "gemini-2.5-pro", "gemini-pro-2.5"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=2000000,
|
||||
max_output=8192,
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
cli_command="gemini",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="gemini-1.5-pro",
|
||||
provider=ModelProvider.GOOGLE,
|
||||
aliases={"gemini", "gemini-pro", "gemini-1.5"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=2000000,
|
||||
max_output=8192,
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
cli_command="gemini",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="gemini-1.5-flash",
|
||||
provider=ModelProvider.GOOGLE,
|
||||
aliases={"gemini-flash", "flash"},
|
||||
supports_vision=True,
|
||||
supports_tools=True,
|
||||
context_window=1000000,
|
||||
max_output=8192,
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
cli_command="gemini",
|
||||
))
|
||||
|
||||
# xAI models
|
||||
self._register(ModelConfig(
|
||||
full_name="grok-4",
|
||||
provider=ModelProvider.XAI,
|
||||
aliases={"grok", "xai-grok", "grok-2"}, # grok-2 for backward compat
|
||||
supports_vision=True, # Grok-4 supports multimodal
|
||||
supports_tools=True,
|
||||
context_window=128000,
|
||||
max_output=8192,
|
||||
api_key_env="XAI_API_KEY",
|
||||
cli_command="grok",
|
||||
))
|
||||
|
||||
# Ollama models
|
||||
self._register(ModelConfig(
|
||||
full_name="ollama/llama-3.2-3b",
|
||||
provider=ModelProvider.OLLAMA,
|
||||
aliases={"llama", "llama-3.2", "llama3"},
|
||||
supports_vision=False,
|
||||
supports_tools=False,
|
||||
context_window=128000,
|
||||
max_output=4096,
|
||||
api_key_env=None, # Local model
|
||||
cli_command="ollama",
|
||||
))
|
||||
|
||||
self._register(ModelConfig(
|
||||
full_name="ollama/mistral:7b",
|
||||
provider=ModelProvider.MISTRAL,
|
||||
aliases={"mistral", "mistral-7b"},
|
||||
supports_vision=False,
|
||||
supports_tools=False,
|
||||
context_window=32000,
|
||||
max_output=4096,
|
||||
api_key_env=None, # Local model
|
||||
cli_command="ollama",
|
||||
))
|
||||
|
||||
# DeepSeek models
|
||||
self._register(ModelConfig(
|
||||
full_name="deepseek-coder-v2",
|
||||
provider=ModelProvider.DEEPSEEK,
|
||||
aliases={"deepseek", "deepseek-coder"},
|
||||
supports_vision=False,
|
||||
supports_tools=True,
|
||||
context_window=128000,
|
||||
max_output=8192,
|
||||
api_key_env="DEEPSEEK_API_KEY",
|
||||
cli_command="deepseek",
|
||||
))
|
||||
|
||||
def _register(self, config: ModelConfig) -> None:
|
||||
"""Register a model configuration.
|
||||
|
||||
Args:
|
||||
config: Model configuration to register
|
||||
"""
|
||||
# Register by full name
|
||||
self._models[config.full_name] = config
|
||||
|
||||
# Register all aliases
|
||||
for alias in config.aliases:
|
||||
self._models[alias.lower()] = config
|
||||
|
||||
def get(self, model_name: str) -> Optional[ModelConfig]:
|
||||
"""Get model configuration by name or alias.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Model configuration or None if not found
|
||||
"""
|
||||
return self._models.get(model_name.lower())
|
||||
|
||||
def resolve(self, model_name: str) -> str:
|
||||
"""Resolve model name or alias to full model name.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Full model name, or original if not found
|
||||
"""
|
||||
config = self.get(model_name)
|
||||
return config.full_name if config else model_name
|
||||
|
||||
def get_by_provider(self, provider: ModelProvider) -> List[ModelConfig]:
|
||||
"""Get all unique models for a specific provider.
|
||||
|
||||
Args:
|
||||
provider: Model provider
|
||||
|
||||
Returns:
|
||||
List of unique model configurations
|
||||
"""
|
||||
seen_names = set()
|
||||
results = []
|
||||
for config in self._models.values():
|
||||
if config.provider == provider and config.full_name not in seen_names:
|
||||
seen_names.add(config.full_name)
|
||||
results.append(config)
|
||||
return results
|
||||
|
||||
def get_models_supporting(
|
||||
self,
|
||||
vision: Optional[bool] = None,
|
||||
tools: Optional[bool] = None,
|
||||
streaming: Optional[bool] = None,
|
||||
) -> List[ModelConfig]:
|
||||
"""Get unique models supporting specific features.
|
||||
|
||||
Args:
|
||||
vision: Filter by vision support
|
||||
tools: Filter by tool support
|
||||
streaming: Filter by streaming support
|
||||
|
||||
Returns:
|
||||
List of unique matching model configurations
|
||||
"""
|
||||
seen_names = set()
|
||||
results = []
|
||||
|
||||
for config in self._models.values():
|
||||
if config.full_name in seen_names:
|
||||
continue
|
||||
|
||||
if vision is not None and config.supports_vision != vision:
|
||||
continue
|
||||
if tools is not None and config.supports_tools != tools:
|
||||
continue
|
||||
if streaming is not None and config.supports_streaming != streaming:
|
||||
continue
|
||||
|
||||
seen_names.add(config.full_name)
|
||||
results.append(config)
|
||||
|
||||
return results
|
||||
|
||||
def get_api_key_env(self, model_name: str) -> Optional[str]:
|
||||
"""Get the API key environment variable for a model.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Environment variable name or None
|
||||
"""
|
||||
config = self.get(model_name)
|
||||
return config.api_key_env if config else None
|
||||
|
||||
def get_cli_command(self, model_name: str) -> Optional[str]:
|
||||
"""Get the CLI command for a model.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
CLI command or None
|
||||
"""
|
||||
config = self.get(model_name)
|
||||
return config.cli_command if config else None
|
||||
|
||||
def list_all_models(self) -> List[str]:
|
||||
"""List all unique model full names.
|
||||
|
||||
Returns:
|
||||
Sorted list of unique full model names
|
||||
"""
|
||||
seen_names = set()
|
||||
for config in self._models.values():
|
||||
seen_names.add(config.full_name)
|
||||
return sorted(list(seen_names))
|
||||
|
||||
def list_all_aliases(self) -> Dict[str, str]:
|
||||
"""List all aliases and their full names.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping aliases to full names
|
||||
"""
|
||||
result = {}
|
||||
for key, config in self._models.items():
|
||||
if key != config.full_name:
|
||||
result[key] = config.full_name
|
||||
return result
|
||||
|
||||
|
||||
# Global singleton instance
|
||||
registry = ModelRegistry()
|
||||
|
||||
|
||||
# Convenience functions
|
||||
def resolve_model(model_name: str) -> str:
|
||||
"""Resolve model name or alias to full model name.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Full model name
|
||||
"""
|
||||
return registry.resolve(model_name)
|
||||
|
||||
|
||||
def get_model_config(model_name: str) -> Optional[ModelConfig]:
|
||||
"""Get model configuration.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Model configuration or None
|
||||
"""
|
||||
return registry.get(model_name)
|
||||
|
||||
|
||||
def get_api_key_env(model_name: str) -> Optional[str]:
|
||||
"""Get API key environment variable for model.
|
||||
|
||||
Args:
|
||||
model_name: Model name or alias
|
||||
|
||||
Returns:
|
||||
Environment variable name or None
|
||||
"""
|
||||
return registry.get_api_key_env(model_name)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ModelProvider",
|
||||
"ModelConfig",
|
||||
"ModelRegistry",
|
||||
"registry",
|
||||
"resolve_model",
|
||||
"get_model_config",
|
||||
"get_api_key_env",
|
||||
]
|
||||
@@ -159,8 +159,12 @@ class DevServer:
|
||||
# Since MCP servers run in the same process, we need to handle this differently
|
||||
# For now, we'll log a message indicating a restart is needed
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning("\n⚠️ Server restart required. Please restart the MCP client to reload changes.")
|
||||
logger.info("💡 Tip: In development, consider using the MCP test client for easier reloading.")
|
||||
logger.warning(
|
||||
"\n⚠️ Server restart required. Please restart the MCP client to reload changes."
|
||||
)
|
||||
logger.info(
|
||||
"💡 Tip: In development, consider using the MCP test client for easier reloading."
|
||||
)
|
||||
|
||||
async def run_async(self, transport: str = "stdio"):
|
||||
"""Run the development server asynchronously."""
|
||||
@@ -214,8 +218,12 @@ def run_dev_server():
|
||||
"""Entry point for development server."""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run Hanzo AI in development mode with hot reload")
|
||||
parser.add_argument("--name", type=str, default="hanzo-dev", help="Name of the MCP server")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Run Hanzo AI in development mode with hot reload"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--name", type=str, default="hanzo-dev", help="Name of the MCP server"
|
||||
)
|
||||
parser.add_argument("--project-dir", type=str, help="Project directory to serve")
|
||||
parser.add_argument(
|
||||
"--allowed-path",
|
||||
@@ -231,7 +239,9 @@ def run_dev_server():
|
||||
choices=["stdio", "sse"],
|
||||
help="Transport type (default: stdio)",
|
||||
)
|
||||
parser.add_argument("--host", type=str, default="127.0.0.1", help="Host for SSE transport")
|
||||
parser.add_argument(
|
||||
"--host", type=str, default="127.0.0.1", help="Host for SSE transport"
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=3000, help="Port for SSE transport")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
@@ -52,7 +52,9 @@ def create_project_system_prompt(project_path: str):
|
||||
platform, _, os_version = get_os_info()
|
||||
|
||||
# Get directory structure
|
||||
directory_structure = get_directory_structure(working_directory, max_depth=3, include_filtered=False)
|
||||
directory_structure = get_directory_structure(
|
||||
working_directory, max_depth=3, include_filtered=False
|
||||
)
|
||||
|
||||
# Get git information
|
||||
git_info = get_git_info(working_directory)
|
||||
@@ -76,7 +78,9 @@ def create_project_system_prompt(project_path: str):
|
||||
return project_system_prompt
|
||||
|
||||
|
||||
def register_all_prompts(mcp_server: FastMCP, projects: list[str] | None = None) -> None:
|
||||
def register_all_prompts(
|
||||
mcp_server: FastMCP, projects: list[str] | None = None
|
||||
) -> None:
|
||||
@mcp_server.prompt(name="Compact current conversation")
|
||||
def compact() -> str:
|
||||
"""
|
||||
|
||||
@@ -49,9 +49,13 @@ def format_todo_list_concise(todos: list[dict[str, Any]]) -> str:
|
||||
}.get(status, "[?]")
|
||||
|
||||
# Create priority indicator
|
||||
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(priority, "⚪")
|
||||
priority_indicator = {"high": "🔴", "medium": "🟡", "low": "🟢"}.get(
|
||||
priority, "⚪"
|
||||
)
|
||||
|
||||
formatted_lines.append(f"{status_indicator} {priority_indicator} {content} (id: {todo_id})")
|
||||
formatted_lines.append(
|
||||
f"{status_indicator} {priority_indicator} {content} (id: {todo_id})"
|
||||
)
|
||||
|
||||
return "\n".join(formatted_lines)
|
||||
|
||||
@@ -101,4 +105,6 @@ def get_project_todo_reminder(session_id: str | None = None) -> str:
|
||||
|
||||
# Format the todo list and return the reminder with content
|
||||
formatted_todos = format_todo_list_concise(todos)
|
||||
return PROJECT_TODO_REMINDER.format(session_id=session_id, todo_list=formatted_todos)
|
||||
return PROJECT_TODO_REMINDER.format(
|
||||
session_id=session_id, todo_list=formatted_todos
|
||||
)
|
||||
|
||||
@@ -596,7 +596,9 @@ def create_tool_category_prompt(category: str, tools: list[str]):
|
||||
"batch": BATCH_TOOL_EXAMPLES,
|
||||
}
|
||||
|
||||
base_prompt = tool_descriptions.get(category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n")
|
||||
base_prompt = tool_descriptions.get(
|
||||
category, f"# {category.title()} Tools\n\nAvailable tools in this category:\n"
|
||||
)
|
||||
|
||||
if category not in tool_descriptions:
|
||||
base_prompt += "\n".join(f"- **{tool}**: [Tool description]" for tool in tools)
|
||||
|
||||
@@ -51,7 +51,9 @@ def get_os_info() -> tuple[str, str, str]:
|
||||
return system, release, version
|
||||
|
||||
|
||||
def get_directory_structure(path: str, max_depth: int = 3, include_filtered: bool = False) -> str:
|
||||
def get_directory_structure(
|
||||
path: str, max_depth: int = 3, include_filtered: bool = False
|
||||
) -> str:
|
||||
"""Get a directory structure similar to directory_tree tool.
|
||||
|
||||
Args:
|
||||
@@ -100,7 +102,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
|
||||
|
||||
try:
|
||||
# Sort entries: directories first, then files alphabetically
|
||||
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
||||
entries = sorted(
|
||||
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
if entry.is_dir():
|
||||
@@ -143,7 +147,9 @@ def get_directory_structure(path: str, max_depth: int = 3, include_filtered: boo
|
||||
# Format based on type
|
||||
if item["type"] == "directory":
|
||||
if "skipped" in item:
|
||||
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
|
||||
lines.append(
|
||||
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{indent}{item['name']}/")
|
||||
# Add children with increased indentation if present
|
||||
@@ -222,7 +228,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
|
||||
change_type = item.change_type
|
||||
status_lines.append(f"{change_type[0].upper()} {item.a_path}")
|
||||
if len(staged_files) > 25:
|
||||
status_lines.append(f"... and {len(staged_files) - 25} more staged files")
|
||||
status_lines.append(
|
||||
f"... and {len(staged_files) - 25} more staged files"
|
||||
)
|
||||
|
||||
# Check for unstaged changes
|
||||
unstaged_files = list(repo.index.diff(None))
|
||||
@@ -230,7 +238,9 @@ def get_git_info(path: str) -> dict[str, str | None]:
|
||||
for item in unstaged_files[:25]: # Limit to first 25
|
||||
status_lines.append(f"M {item.a_path}")
|
||||
if len(unstaged_files) > 25:
|
||||
status_lines.append(f"... and {len(unstaged_files) - 25} more modified files")
|
||||
status_lines.append(
|
||||
f"... and {len(unstaged_files) - 25} more modified files"
|
||||
)
|
||||
|
||||
# Check for untracked files
|
||||
untracked_files = repo.untracked_files
|
||||
@@ -238,9 +248,13 @@ def get_git_info(path: str) -> dict[str, str | None]:
|
||||
for file in untracked_files[:25]: # Limit to first 25
|
||||
status_lines.append(f"?? {file}")
|
||||
if len(untracked_files) > 25:
|
||||
status_lines.append(f"... and {len(untracked_files) - 25} more untracked files")
|
||||
status_lines.append(
|
||||
f"... and {len(untracked_files) - 25} more untracked files"
|
||||
)
|
||||
|
||||
git_status = "\n".join(status_lines) if status_lines else "Working tree clean"
|
||||
git_status = (
|
||||
"\n".join(status_lines) if status_lines else "Working tree clean"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
git_status = "Unable to get git status"
|
||||
|
||||
@@ -9,7 +9,9 @@ import threading
|
||||
from typing import Literal, cast, final
|
||||
|
||||
# Suppress litellm deprecation warnings about event loop
|
||||
warnings.filterwarnings("ignore", message="There is no current event loop", category=DeprecationWarning)
|
||||
warnings.filterwarnings(
|
||||
"ignore", message="There is no current event loop", category=DeprecationWarning
|
||||
)
|
||||
|
||||
try:
|
||||
from fastmcp import FastMCP
|
||||
@@ -171,7 +173,9 @@ class HanzoMCPServer:
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
|
||||
# Start background cleanup thread for periodic cleanup
|
||||
self._cleanup_thread = threading.Thread(target=self._background_cleanup, daemon=True)
|
||||
self._cleanup_thread = threading.Thread(
|
||||
target=self._background_cleanup, daemon=True
|
||||
)
|
||||
self._cleanup_thread.start()
|
||||
|
||||
self._cleanup_registered = True
|
||||
|
||||
@@ -1,55 +1,70 @@
|
||||
"""Tools package for Hanzo AI.
|
||||
|
||||
This package contains all the tools for the Hanzo AI server.
|
||||
It provides a interface for registering all tools with an MCP server.
|
||||
This package contains all tools for the Hanzo MCP server. To keep imports
|
||||
robust across environments (e.g., older Python during CI), heavy imports are
|
||||
guarded. Submodules can still be imported directly, e.g.:
|
||||
|
||||
This includes a "think" tool implementation based on Anthropic's research showing
|
||||
improved performance for complex tool-based interactions when Claude has a dedicated
|
||||
space for structured thinking. It also includes an "agent" tool that enables Claude
|
||||
to delegate tasks to sub-agents for concurrent execution and specialized processing.
|
||||
from hanzo_mcp.tools.llm.llm_tool import LLMTool
|
||||
"""
|
||||
|
||||
from mcp.server import FastMCP
|
||||
# Defer annotation evaluation to avoid import-time NameErrors in constrained envs
|
||||
from __future__ import annotations
|
||||
|
||||
from hanzo_mcp.tools.todo import register_todo_tools
|
||||
from hanzo_mcp.tools.agent import register_agent_tools
|
||||
from hanzo_mcp.tools.shell import register_shell_tools
|
||||
from hanzo_mcp.tools.common import (
|
||||
register_batch_tool,
|
||||
register_critic_tool,
|
||||
register_thinking_tool,
|
||||
)
|
||||
from hanzo_mcp.tools.vector import register_vector_tools
|
||||
from hanzo_mcp.tools.jupyter import register_jupyter_tools
|
||||
from hanzo_mcp.tools.database import DatabaseManager, register_database_tools
|
||||
from hanzo_mcp.tools.filesystem import register_filesystem_tools
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.stats import StatsTool
|
||||
from hanzo_mcp.tools.common.tool_list import ToolListTool
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
from hanzo_mcp.tools.common.tool_enable import ToolEnableTool
|
||||
from hanzo_mcp.tools.common.tool_disable import ToolDisableTool
|
||||
# NOTE: Keep top-level imports resilient for environments without optional deps
|
||||
try: # pragma: no cover - import guards for CI environments
|
||||
from mcp.server import FastMCP # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
|
||||
# Try to import memory tools, but don't fail if hanzo-memory is not installed
|
||||
try:
|
||||
from hanzo_mcp.tools.memory import register_memory_tools
|
||||
class FastMCP: # type: ignore
|
||||
pass
|
||||
|
||||
MEMORY_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
MEMORY_TOOLS_AVAILABLE = False
|
||||
register_memory_tools = None
|
||||
from hanzo_mcp.tools.llm import (
|
||||
LLMTool,
|
||||
ConsensusTool,
|
||||
LLMManageTool,
|
||||
create_provider_tools,
|
||||
)
|
||||
from hanzo_mcp.tools.mcp import MCPTool, McpAddTool, McpStatsTool, McpRemoveTool
|
||||
from hanzo_mcp.tools.editor import NeovimEditTool, NeovimCommandTool, NeovimSessionTool
|
||||
from hanzo_mcp.tools.common.mode import activate_mode_from_env
|
||||
from hanzo_mcp.tools.config.mode_tool import mode_tool
|
||||
from hanzo_mcp.tools.common.mode_loader import ModeLoader
|
||||
from hanzo_mcp.tools.common.plugin_loader import load_user_plugins
|
||||
|
||||
try: # pragma: no cover
|
||||
from hanzo_mcp.tools.llm import (
|
||||
LLMTool,
|
||||
ConsensusTool,
|
||||
LLMManageTool,
|
||||
create_provider_tools,
|
||||
)
|
||||
from hanzo_mcp.tools.mcp import (
|
||||
MCPTool,
|
||||
McpAddTool,
|
||||
McpStatsTool,
|
||||
McpRemoveTool,
|
||||
)
|
||||
from hanzo_mcp.tools.todo import register_todo_tools
|
||||
from hanzo_mcp.tools.agent import register_agent_tools
|
||||
from hanzo_mcp.tools.shell import register_shell_tools
|
||||
from hanzo_mcp.tools.editor import (
|
||||
NeovimEditTool,
|
||||
NeovimCommandTool,
|
||||
NeovimSessionTool,
|
||||
)
|
||||
from hanzo_mcp.tools.vector import register_vector_tools
|
||||
from hanzo_mcp.tools.jupyter import register_jupyter_tools
|
||||
from hanzo_mcp.tools.database import DatabaseManager, register_database_tools
|
||||
from hanzo_mcp.tools.filesystem import register_filesystem_tools
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.mode import activate_mode_from_env
|
||||
from hanzo_mcp.tools.common.stats import StatsTool
|
||||
from hanzo_mcp.tools.common.tool_list import ToolListTool
|
||||
from hanzo_mcp.tools.config.mode_tool import mode_tool
|
||||
from hanzo_mcp.tools.common.mode_loader import ModeLoader
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
from hanzo_mcp.tools.common.tool_enable import ToolEnableTool
|
||||
from hanzo_mcp.tools.common.tool_disable import ToolDisableTool
|
||||
|
||||
# Try memory tools
|
||||
try:
|
||||
from hanzo_mcp.tools.memory import register_memory_tools
|
||||
|
||||
MEMORY_TOOLS_AVAILABLE = True
|
||||
except Exception:
|
||||
MEMORY_TOOLS_AVAILABLE = False
|
||||
register_memory_tools = None # type: ignore
|
||||
except Exception:
|
||||
# Minimal surface to allow submodule imports elsewhere
|
||||
pass
|
||||
|
||||
# Try to import LSP tool
|
||||
try:
|
||||
@@ -109,7 +124,9 @@ def register_all_tools(
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if plugins:
|
||||
logger.info(f"Loaded {len(plugins)} user plugin tools: {', '.join(plugins.keys())}")
|
||||
logger.info(
|
||||
f"Loaded {len(plugins)} user plugin tools: {', '.join(plugins.keys())}"
|
||||
)
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
@@ -122,7 +139,9 @@ def register_all_tools(
|
||||
# First check for mode activation from environment
|
||||
activate_mode_from_env()
|
||||
|
||||
tool_config = ModeLoader.get_enabled_tools_from_mode(base_enabled_tools=enabled_tools, force_mode=force_mode)
|
||||
tool_config = ModeLoader.get_enabled_tools_from_mode(
|
||||
base_enabled_tools=enabled_tools, force_mode=force_mode
|
||||
)
|
||||
# Apply mode environment variables
|
||||
ModeLoader.apply_environment_from_mode()
|
||||
else:
|
||||
@@ -167,7 +186,9 @@ def register_all_tools(
|
||||
search_paths = [str(path) for path in permission_manager.allowed_paths]
|
||||
project_manager = ProjectVectorManager(
|
||||
global_db_path=vector_config.get("data_path"),
|
||||
embedding_model=vector_config.get("embedding_model", "text-embedding-3-small"),
|
||||
embedding_model=vector_config.get(
|
||||
"embedding_model", "text-embedding-3-small"
|
||||
),
|
||||
dimension=vector_config.get("dimension", 1536),
|
||||
)
|
||||
# Auto-detect projects from search paths
|
||||
@@ -194,7 +215,9 @@ def register_all_tools(
|
||||
}
|
||||
|
||||
if any(jupyter_enabled.values()):
|
||||
jupyter_tools = register_jupyter_tools(mcp_server, permission_manager, enabled_tools=jupyter_enabled)
|
||||
jupyter_tools = register_jupyter_tools(
|
||||
mcp_server, permission_manager, enabled_tools=jupyter_enabled
|
||||
)
|
||||
for tool in jupyter_tools:
|
||||
all_tools[tool.name] = tool
|
||||
|
||||
@@ -205,7 +228,11 @@ def register_all_tools(
|
||||
all_tools[tool.name] = tool
|
||||
|
||||
# Register agent tools if enabled
|
||||
agent_enabled = enable_agent_tool or is_tool_enabled("agent", False) or is_tool_enabled("dispatch_agent", False)
|
||||
agent_enabled = (
|
||||
enable_agent_tool
|
||||
or is_tool_enabled("agent", False)
|
||||
or is_tool_enabled("dispatch_agent", False)
|
||||
)
|
||||
swarm_enabled = is_tool_enabled("swarm", False)
|
||||
|
||||
if agent_enabled or swarm_enabled:
|
||||
@@ -221,7 +248,12 @@ def register_all_tools(
|
||||
)
|
||||
# Filter based on what's enabled
|
||||
for tool in agent_tools:
|
||||
if tool.name == "agent" and agent_enabled or tool.name == "swarm" and swarm_enabled:
|
||||
if (
|
||||
tool.name == "agent"
|
||||
and agent_enabled
|
||||
or tool.name == "swarm"
|
||||
and swarm_enabled
|
||||
):
|
||||
all_tools[tool.name] = tool
|
||||
elif tool.name in ["claude", "codex", "gemini", "grok", "code_auth"]:
|
||||
# CLI tools and auth are always included when agent tools are enabled
|
||||
|
||||
@@ -8,16 +8,26 @@ from mcp.server import FastMCP
|
||||
|
||||
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
|
||||
|
||||
# Import unified CLI tools (single source of truth)
|
||||
from hanzo_mcp.tools.agent.cli_tools import (
|
||||
GrokCLITool,
|
||||
AiderCLITool,
|
||||
ClineCLITool,
|
||||
CodexCLITool,
|
||||
ClaudeCLITool,
|
||||
GeminiCLITool,
|
||||
HanzoDevCLITool,
|
||||
OpenHandsCLITool,
|
||||
ClaudeCodeCLITool, # cc alias
|
||||
OpenHandsShortCLITool, # oh alias
|
||||
register_cli_tools,
|
||||
)
|
||||
|
||||
# Import the main implementations (using hanzo-agents SDK)
|
||||
from hanzo_mcp.tools.agent.agent_tool import AgentTool
|
||||
from hanzo_mcp.tools.agent.swarm_tool import SwarmTool
|
||||
from hanzo_mcp.tools.agent.network_tool import NetworkTool
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
from hanzo_mcp.tools.agent.grok_cli_tool import GrokCLITool
|
||||
from hanzo_mcp.tools.agent.code_auth_tool import CodeAuthTool
|
||||
from hanzo_mcp.tools.agent.codex_cli_tool import CodexCLITool
|
||||
from hanzo_mcp.tools.agent.claude_cli_tool import ClaudeCLITool
|
||||
from hanzo_mcp.tools.agent.gemini_cli_tool import GeminiCLITool
|
||||
|
||||
|
||||
def register_agent_tools(
|
||||
@@ -57,37 +67,48 @@ def register_agent_tools(
|
||||
max_tool_uses=agent_max_tool_uses,
|
||||
)
|
||||
|
||||
# Create swarm tool
|
||||
swarm_tool = SwarmTool(
|
||||
permission_manager=permission_manager,
|
||||
model=agent_model,
|
||||
api_key=agent_api_key,
|
||||
base_url=agent_base_url,
|
||||
max_tokens=agent_max_tokens,
|
||||
agent_max_iterations=agent_max_iterations,
|
||||
agent_max_tool_uses=agent_max_tool_uses,
|
||||
)
|
||||
# Register a swarm alias that forwards to AgentTool with default concurrency
|
||||
class SwarmAliasTool(BaseTool):
|
||||
name = "swarm"
|
||||
description = (
|
||||
"Alias for agent with concurrency. swarm == agent:5 by default.\n"
|
||||
"Use 'swarm' for parallel multi-agent runs; 'swarm:N' for N agents."
|
||||
)
|
||||
|
||||
# Create CLI agent tools
|
||||
claude_cli_tool = ClaudeCLITool(
|
||||
permission_manager=permission_manager,
|
||||
model=agent_model, # Can override default Sonnet
|
||||
)
|
||||
def __init__(self, agent_tool: AgentTool):
|
||||
self._agent = agent_tool
|
||||
|
||||
codex_cli_tool = CodexCLITool(
|
||||
permission_manager=permission_manager,
|
||||
model=agent_model if agent_model and "gpt" in agent_model else None,
|
||||
)
|
||||
async def call(self, ctx, **params): # type: ignore[override]
|
||||
# Default to 5 agents unless explicitly provided
|
||||
params = dict(params)
|
||||
params.setdefault("concurrency", 5)
|
||||
return await self._agent.call(ctx, **params)
|
||||
|
||||
gemini_cli_tool = GeminiCLITool(
|
||||
permission_manager=permission_manager,
|
||||
model=agent_model if agent_model and "gemini" in agent_model else None,
|
||||
)
|
||||
def register(self, mcp_server: FastMCP): # type: ignore[override]
|
||||
tool_self = self
|
||||
|
||||
grok_cli_tool = GrokCLITool(
|
||||
permission_manager=permission_manager,
|
||||
model=agent_model if agent_model and "grok" in agent_model else None,
|
||||
)
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def swarm(
|
||||
ctx,
|
||||
prompts: str | list[str], # forwarded
|
||||
concurrency: int | None = None,
|
||||
model: str | None = None,
|
||||
use_memory: bool | None = None,
|
||||
memory_backend: str | None = None,
|
||||
) -> str:
|
||||
p = {
|
||||
"prompts": prompts,
|
||||
}
|
||||
if concurrency is not None:
|
||||
p["concurrency"] = concurrency
|
||||
if model is not None:
|
||||
p["model"] = model
|
||||
if use_memory is not None:
|
||||
p["use_memory"] = use_memory
|
||||
if memory_backend is not None:
|
||||
p["memory_backend"] = memory_backend
|
||||
return await tool_self.call(ctx, **p)
|
||||
return tool_self
|
||||
|
||||
# Create auth management tool
|
||||
code_auth_tool = CodeAuthTool()
|
||||
@@ -98,24 +119,14 @@ def register_agent_tools(
|
||||
default_mode="hybrid", # Prefer local, fallback to cloud
|
||||
)
|
||||
|
||||
# Register tools
|
||||
# Register core agent tools
|
||||
ToolRegistry.register_tool(mcp_server, agent_tool)
|
||||
ToolRegistry.register_tool(mcp_server, swarm_tool)
|
||||
ToolRegistry.register_tool(mcp_server, SwarmAliasTool(agent_tool))
|
||||
ToolRegistry.register_tool(mcp_server, network_tool)
|
||||
ToolRegistry.register_tool(mcp_server, claude_cli_tool)
|
||||
ToolRegistry.register_tool(mcp_server, codex_cli_tool)
|
||||
ToolRegistry.register_tool(mcp_server, gemini_cli_tool)
|
||||
ToolRegistry.register_tool(mcp_server, grok_cli_tool)
|
||||
ToolRegistry.register_tool(mcp_server, code_auth_tool)
|
||||
|
||||
# Register all CLI tools (includes claude, codex, gemini, grok, etc.)
|
||||
cli_tools = register_cli_tools(mcp_server, permission_manager)
|
||||
|
||||
# Return list of registered tools
|
||||
return [
|
||||
agent_tool,
|
||||
swarm_tool,
|
||||
network_tool,
|
||||
claude_cli_tool,
|
||||
codex_cli_tool,
|
||||
gemini_cli_tool,
|
||||
grok_cli_tool,
|
||||
code_auth_tool,
|
||||
]
|
||||
return [agent_tool, network_tool, code_auth_tool] + cli_tools
|
||||
|
||||
@@ -118,17 +118,23 @@ class AgentParams(TypedDict, total=False):
|
||||
class RPCAgent:
|
||||
"""Long-running RPC agent."""
|
||||
|
||||
def __init__(self, agent_id: str, model: str, system_prompt: str, tools: List[BaseTool]):
|
||||
def __init__(
|
||||
self, agent_id: str, model: str, system_prompt: str, tools: List[BaseTool]
|
||||
):
|
||||
self.agent_id = agent_id
|
||||
self.model = model
|
||||
self.system_prompt = system_prompt
|
||||
self.tools = tools
|
||||
self.messages: List[ChatCompletionMessageParam] = [{"role": "system", "content": system_prompt}]
|
||||
self.messages: List[ChatCompletionMessageParam] = [
|
||||
{"role": "system", "content": system_prompt}
|
||||
]
|
||||
self.created_at = time.time()
|
||||
self.last_used = time.time()
|
||||
self.call_count = 0
|
||||
|
||||
async def call_method(self, method: str, args: Dict[str, Any], tool_ctx: ToolContext) -> str:
|
||||
async def call_method(
|
||||
self, method: str, args: Dict[str, Any], tool_ctx: ToolContext
|
||||
) -> str:
|
||||
"""Call a method on the RPC agent."""
|
||||
self.last_used = time.time()
|
||||
self.call_count += 1
|
||||
@@ -183,9 +189,15 @@ class AgentTool(BaseTool):
|
||||
|
||||
# Available tools
|
||||
self.available_tools: list[BaseTool] = []
|
||||
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
|
||||
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
|
||||
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
|
||||
self.available_tools.extend(
|
||||
get_read_only_filesystem_tools(self.permission_manager)
|
||||
)
|
||||
self.available_tools.extend(
|
||||
get_read_only_jupyter_tools(self.permission_manager)
|
||||
)
|
||||
self.available_tools.append(
|
||||
BatchTool({t.name: t for t in self.available_tools})
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
@@ -260,10 +272,14 @@ Modes:
|
||||
|
||||
if len(prompt_list) == 1:
|
||||
await tool_ctx.info("Launching agent")
|
||||
result = await self._execute_agent(prompt_list[0], params.get("model"), tool_ctx)
|
||||
result = await self._execute_agent(
|
||||
prompt_list[0], params.get("model"), tool_ctx
|
||||
)
|
||||
else:
|
||||
await tool_ctx.info(f"Launching {len(prompt_list)} agents in parallel")
|
||||
result = await self._execute_multiple_agents(prompt_list, params.get("model"), tool_ctx)
|
||||
result = await self._execute_multiple_agents(
|
||||
prompt_list, params.get("model"), tool_ctx
|
||||
)
|
||||
|
||||
execution_time = time.time() - start_time
|
||||
|
||||
@@ -373,13 +389,17 @@ Use 'agent --action call --agent-id {agent_id} --method <method> --args <args>'
|
||||
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
|
||||
return bool(re.search(absolute_path_pattern, prompt))
|
||||
|
||||
async def _execute_agent(self, prompt: str, model: Optional[str], tool_ctx: ToolContext) -> str:
|
||||
async def _execute_agent(
|
||||
self, prompt: str, model: Optional[str], tool_ctx: ToolContext
|
||||
) -> str:
|
||||
"""Execute a single agent (simplified - would use full logic from agent_tool.py)."""
|
||||
# This would integrate the full agent execution logic from agent_tool.py
|
||||
# For now, return a placeholder
|
||||
return f"Executed agent with prompt: {prompt[:100]}..."
|
||||
|
||||
async def _execute_multiple_agents(self, prompts: List[str], model: Optional[str], tool_ctx: ToolContext) -> str:
|
||||
async def _execute_multiple_agents(
|
||||
self, prompts: List[str], model: Optional[str], tool_ctx: ToolContext
|
||||
) -> str:
|
||||
"""Execute multiple agents in parallel."""
|
||||
tasks = []
|
||||
for prompt in prompts:
|
||||
|
||||
@@ -109,6 +109,7 @@ class AgentToolParams(TypedDict, total=False):
|
||||
model: Optional[str]
|
||||
use_memory: Optional[bool]
|
||||
memory_backend: Optional[str]
|
||||
concurrency: Optional[int]
|
||||
|
||||
|
||||
class MCPAgentState(State):
|
||||
@@ -196,7 +197,9 @@ class MCPAgent(Agent):
|
||||
adapter = MCPToolAdapter(mcp_tool, ctx)
|
||||
self.register_tool(adapter)
|
||||
|
||||
async def run(self, state: MCPAgentState, history: History, network: Network) -> InferenceResult:
|
||||
async def run(
|
||||
self, state: MCPAgentState, history: History, network: Network
|
||||
) -> InferenceResult:
|
||||
"""Execute the agent."""
|
||||
# Get current prompt
|
||||
if state.current_prompt_index >= len(state.prompts):
|
||||
@@ -321,8 +324,12 @@ Usage notes:
|
||||
|
||||
# Set up available tools
|
||||
self.available_tools: list[BaseTool] = []
|
||||
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
|
||||
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
|
||||
self.available_tools.extend(
|
||||
get_read_only_filesystem_tools(self.permission_manager)
|
||||
)
|
||||
self.available_tools.extend(
|
||||
get_read_only_jupyter_tools(self.permission_manager)
|
||||
)
|
||||
|
||||
# Add edit tools
|
||||
self.available_tools.append(Edit(self.permission_manager))
|
||||
@@ -334,7 +341,9 @@ Usage notes:
|
||||
self.available_tools.append(ReviewTool())
|
||||
self.available_tools.append(IChingTool())
|
||||
|
||||
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
|
||||
self.available_tools.append(
|
||||
BatchTool({t.name: t for t in self.available_tools})
|
||||
)
|
||||
|
||||
@override
|
||||
async def call(
|
||||
@@ -379,8 +388,20 @@ Usage notes:
|
||||
await tool_ctx.error("hanzo-agents SDK is required but not available")
|
||||
return "Error: hanzo-agents SDK is required for agent tool functionality. Please install it with: pip install hanzo-agents"
|
||||
|
||||
# Use hanzo-agents SDK
|
||||
await tool_ctx.info(f"Launching {len(prompt_list)} agent(s) using hanzo-agents SDK")
|
||||
# Determine concurrency (parallel agents)
|
||||
concurrency = params.get("concurrency")
|
||||
if concurrency is not None and isinstance(concurrency, int) and concurrency > 0:
|
||||
# Expand prompt list to match concurrency
|
||||
if len(prompt_list) == 1:
|
||||
prompt_list = prompt_list * concurrency
|
||||
elif len(prompt_list) < concurrency:
|
||||
# Repeat prompts to reach concurrency
|
||||
times = (concurrency + len(prompt_list) - 1) // len(prompt_list)
|
||||
prompt_list = (prompt_list * times)[:concurrency]
|
||||
|
||||
await tool_ctx.info(
|
||||
f"Launching {len(prompt_list)} agent(s) using hanzo-agents SDK"
|
||||
)
|
||||
|
||||
# Determine model and agent type
|
||||
model = params.get("model", self.model_override)
|
||||
|
||||
@@ -151,8 +151,12 @@ Usage notes:
|
||||
self.max_iterations = max_iterations
|
||||
self.max_tool_uses = max_tool_uses
|
||||
self.available_tools: list[BaseTool] = []
|
||||
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
|
||||
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
|
||||
self.available_tools.extend(
|
||||
get_read_only_filesystem_tools(self.permission_manager)
|
||||
)
|
||||
self.available_tools.extend(
|
||||
get_read_only_jupyter_tools(self.permission_manager)
|
||||
)
|
||||
|
||||
# Always add edit tools - agents should have edit access
|
||||
self.available_tools.append(Edit(self.permission_manager))
|
||||
@@ -170,7 +174,9 @@ Usage notes:
|
||||
# Add I Ching tool for creative guidance
|
||||
self.available_tools.append(IChingTool())
|
||||
|
||||
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
|
||||
self.available_tools.append(
|
||||
BatchTool({t.name: t for t in self.available_tools})
|
||||
)
|
||||
|
||||
# Initialize protocols
|
||||
self.critic_protocol = CriticProtocol()
|
||||
@@ -233,7 +239,9 @@ Example of correct usage:
|
||||
absolute_path_pattern = r"/(?:[^/\s]+/)*[^/\s]+"
|
||||
for prompt in prompt_list:
|
||||
if not re.search(absolute_path_pattern, prompt):
|
||||
await tool_ctx.error(f"Prompt does not contain absolute path: {prompt[:50]}...")
|
||||
await tool_ctx.error(
|
||||
f"Prompt does not contain absolute path: {prompt[:50]}..."
|
||||
)
|
||||
return """Error: All prompts must contain at least one absolute path.
|
||||
|
||||
IMPORTANT REMINDER FOR CLAUDE:
|
||||
@@ -263,7 +271,9 @@ AGENT RESPONSE:
|
||||
|
||||
AGENT RESPONSES:
|
||||
{result}"""
|
||||
await tool_ctx.info(f"Multi-agent execution completed in {execution_time:.2f}s")
|
||||
await tool_ctx.info(
|
||||
f"Multi-agent execution completed in {execution_time:.2f}s"
|
||||
)
|
||||
return formatted_result
|
||||
|
||||
async def _execute_agent(self, prompt: str, tool_ctx: ToolContext) -> str:
|
||||
@@ -300,7 +310,9 @@ AGENT RESPONSES:
|
||||
|
||||
# Execute agent
|
||||
await tool_ctx.info(f"Executing agent task: {prompt[:50]}...")
|
||||
result = await self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
|
||||
result = await self._execute_agent_with_tools(
|
||||
system_prompt, prompt, agent_tools, openai_tools, tool_ctx
|
||||
)
|
||||
except Exception as e:
|
||||
# Log and return error result
|
||||
error_message = f"Error executing agent: {str(e)}"
|
||||
@@ -309,7 +321,9 @@ AGENT RESPONSES:
|
||||
|
||||
return result if result else "No results returned from agent"
|
||||
|
||||
async def _execute_multiple_agents(self, prompts: list[str], tool_ctx: ToolContext) -> str:
|
||||
async def _execute_multiple_agents(
|
||||
self, prompts: list[str], tool_ctx: ToolContext
|
||||
) -> str:
|
||||
"""Execute multiple agents concurrently.
|
||||
|
||||
Args:
|
||||
@@ -338,7 +352,9 @@ AGENT RESPONSES:
|
||||
tasks = []
|
||||
for i, prompt in enumerate(prompts):
|
||||
await tool_ctx.info(f"Creating agent task {i + 1}: {prompt[:50]}...")
|
||||
task = self._execute_agent_with_tools(system_prompt, prompt, agent_tools, openai_tools, tool_ctx)
|
||||
task = self._execute_agent_with_tools(
|
||||
system_prompt, prompt, agent_tools, openai_tools, tool_ctx
|
||||
)
|
||||
tasks.append(task)
|
||||
|
||||
# Execute all agents concurrently
|
||||
@@ -397,7 +413,9 @@ AGENT RESPONSES:
|
||||
total_tool_use_count = 0
|
||||
iteration_count = 0
|
||||
max_tool_uses = self.max_tool_uses # Safety limit to prevent infinite loops
|
||||
max_iterations = self.max_iterations # Add a maximum number of iterations for safety
|
||||
max_iterations = (
|
||||
self.max_iterations
|
||||
) # Add a maximum number of iterations for safety
|
||||
|
||||
# Execute until the agent completes or reaches the limit
|
||||
while total_tool_use_count < max_tool_uses and iteration_count < max_iterations:
|
||||
@@ -462,7 +480,9 @@ AGENT RESPONSES:
|
||||
function_args = {}
|
||||
|
||||
# Find the matching tool
|
||||
tool = next((t for t in available_tools if t.name == function_name), None)
|
||||
tool = next(
|
||||
(t for t in available_tools if t.name == function_name), None
|
||||
)
|
||||
if not tool:
|
||||
tool_result = f"Error: Tool '{function_name}' not found"
|
||||
# Special handling for clarification requests
|
||||
@@ -485,7 +505,9 @@ AGENT RESPONSES:
|
||||
options=options,
|
||||
)
|
||||
|
||||
tool_result = self.format_clarification_in_output(question, answer)
|
||||
tool_result = self.format_clarification_in_output(
|
||||
question, answer
|
||||
)
|
||||
except Exception as e:
|
||||
tool_result = f"Error processing clarification: {str(e)}"
|
||||
# Special handling for critic requests
|
||||
@@ -530,7 +552,9 @@ AGENT RESPONSES:
|
||||
tool_result = f"Error processing review: {str(e)}"
|
||||
else:
|
||||
try:
|
||||
tool_result = await tool.call(ctx=tool_ctx.mcp_context, **function_args)
|
||||
tool_result = await tool.call(
|
||||
ctx=tool_ctx.mcp_context, **function_args
|
||||
)
|
||||
except Exception as e:
|
||||
tool_result = f"Error executing {function_name}: {str(e)}"
|
||||
|
||||
@@ -548,7 +572,9 @@ AGENT RESPONSES:
|
||||
)
|
||||
|
||||
# Log progress
|
||||
await tool_ctx.info(f"Processed {len(message.tool_calls)} tool calls. Total: {total_tool_use_count}")
|
||||
await tool_ctx.info(
|
||||
f"Processed {len(message.tool_calls)} tool calls. Total: {total_tool_use_count}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Error in model call: {str(e)}")
|
||||
@@ -576,7 +602,8 @@ AGENT RESPONSES:
|
||||
)
|
||||
|
||||
return (
|
||||
final_response.choices[0].message.content or "Agent reached max iteration limit without a response."
|
||||
final_response.choices[0].message.content
|
||||
or "Agent reached max iteration limit without a response."
|
||||
) # pyright: ignore
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Error in final model call: {str(e)}")
|
||||
|
||||
@@ -96,7 +96,9 @@ class ClaudeDesktopAuth:
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
async def login_interactive(self, account: Optional[str] = None, headless: bool = False) -> Tuple[bool, str]:
|
||||
async def login_interactive(
|
||||
self, account: Optional[str] = None, headless: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
"""Login to Claude Desktop interactively.
|
||||
|
||||
Args:
|
||||
@@ -190,7 +192,9 @@ class ClaudeDesktopAuth:
|
||||
# For now, return a placeholder
|
||||
return False, "Headless login not yet implemented"
|
||||
|
||||
async def _exchange_code_for_session(self, code: str, account: Optional[str]) -> bool:
|
||||
async def _exchange_code_for_session(
|
||||
self, code: str, account: Optional[str]
|
||||
) -> bool:
|
||||
"""Exchange auth code for session token."""
|
||||
# This would make API calls to exchange the code
|
||||
# For now, create a mock session
|
||||
@@ -353,7 +357,9 @@ class ClaudeDesktopAuth:
|
||||
# Generate agent-specific account
|
||||
return f"agent_{agent_id}@claude.local"
|
||||
|
||||
async def ensure_agent_auth(self, agent_id: str, force_new: bool = False) -> Tuple[bool, str]:
|
||||
async def ensure_agent_auth(
|
||||
self, agent_id: str, force_new: bool = False
|
||||
) -> Tuple[bool, str]:
|
||||
"""Ensure an agent is authenticated with its own account.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -97,7 +97,9 @@ class CLIAgentBase(BaseTool):
|
||||
|
||||
# Check if installed
|
||||
if not self.is_installed():
|
||||
error_msg = f"{self.provider_name} CLI ({self.command_name}) is not installed. "
|
||||
error_msg = (
|
||||
f"{self.provider_name} CLI ({self.command_name}) is not installed. "
|
||||
)
|
||||
error_msg += f"Please install it first: https://github.com/anthropics/{self.command_name}"
|
||||
await tool_ctx.error(error_msg)
|
||||
return f"Error: {error_msg}"
|
||||
@@ -113,11 +115,15 @@ class CLIAgentBase(BaseTool):
|
||||
cli_args = self.get_cli_args(prompt, **kwargs)
|
||||
|
||||
# Log command
|
||||
await tool_ctx.info(f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}...")
|
||||
await tool_ctx.info(
|
||||
f"Executing {self.provider_name}: {self.command_name} {' '.join(cli_args[:3])}..."
|
||||
)
|
||||
|
||||
try:
|
||||
# Create temp file for prompt if needed
|
||||
with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".txt", delete=False
|
||||
) as f:
|
||||
f.write(prompt)
|
||||
prompt_file = f.name
|
||||
|
||||
@@ -125,7 +131,12 @@ class CLIAgentBase(BaseTool):
|
||||
if "--prompt-file" in cli_args:
|
||||
# Replace placeholder with actual file
|
||||
cli_args = [
|
||||
(arg.replace("--prompt-file", prompt_file) if arg == "--prompt-file" else arg) for arg in cli_args
|
||||
(
|
||||
arg.replace("--prompt-file", prompt_file)
|
||||
if arg == "--prompt-file"
|
||||
else arg
|
||||
)
|
||||
for arg in cli_args
|
||||
]
|
||||
|
||||
# Execute command
|
||||
@@ -140,9 +151,13 @@ class CLIAgentBase(BaseTool):
|
||||
|
||||
# Send prompt via stdin if not using file
|
||||
if "--prompt-file" not in cli_args:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(input=prompt.encode()), timeout=timeout)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input=prompt.encode()), timeout=timeout
|
||||
)
|
||||
else:
|
||||
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=timeout
|
||||
)
|
||||
|
||||
# Clean up temp file
|
||||
try:
|
||||
@@ -160,7 +175,9 @@ class CLIAgentBase(BaseTool):
|
||||
return result
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
await tool_ctx.error(f"{self.provider_name} timed out after {timeout} seconds")
|
||||
await tool_ctx.error(
|
||||
f"{self.provider_name} timed out after {timeout} seconds"
|
||||
)
|
||||
return f"Error: Command timed out after {timeout} seconds"
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"{self.provider_name} error: {str(e)}")
|
||||
|
||||
@@ -0,0 +1,543 @@
|
||||
"""CLI tool implementations for direct batch execution.
|
||||
|
||||
This module provides CLI tool wrappers that can be used directly in batch operations,
|
||||
including claude (cc), codex, gemini, grok, openhands (oh), hanzo-dev, cline, and aider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Unpack, Optional, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.context import create_tool_context
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
|
||||
# Parameter types for CLI tools
|
||||
Prompt = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The prompt or command to send to the CLI tool",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Model = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Optional model override for the CLI tool",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
WorkingDir = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Working directory for the command",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Timeout = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Timeout in seconds for the command",
|
||||
default=300, # 5 minutes default
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class CLIToolParams(TypedDict, total=False):
|
||||
"""Common parameters for CLI tools."""
|
||||
prompt: str
|
||||
model: Optional[str]
|
||||
working_dir: Optional[str]
|
||||
timeout: Optional[int]
|
||||
|
||||
|
||||
class BaseCLITool(BaseTool):
|
||||
"""Base class for CLI tool implementations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
permission_manager: Optional[PermissionManager] = None,
|
||||
default_model: Optional[str] = None,
|
||||
api_key_env: Optional[str] = None,
|
||||
):
|
||||
"""Initialize CLI tool.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
default_model: Default model to use
|
||||
api_key_env: Environment variable name for API key
|
||||
"""
|
||||
self.permission_manager = permission_manager
|
||||
self.default_model = default_model
|
||||
self.api_key_env = api_key_env
|
||||
|
||||
def get_auth_env(self) -> dict[str, str]:
|
||||
"""Get authentication environment variables."""
|
||||
env = os.environ.copy()
|
||||
|
||||
# Add API key if configured
|
||||
if self.api_key_env and self.api_key_env in os.environ:
|
||||
env[self.api_key_env] = os.environ[self.api_key_env]
|
||||
|
||||
# Add Hanzo API key for unified auth
|
||||
if "HANZO_API_KEY" in os.environ:
|
||||
env["HANZO_API_KEY"] = os.environ["HANZO_API_KEY"]
|
||||
|
||||
return env
|
||||
|
||||
async def execute_cli(
|
||||
self,
|
||||
command: list[str],
|
||||
input_text: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
timeout: int = 300,
|
||||
) -> str:
|
||||
"""Execute CLI command with proper error handling.
|
||||
|
||||
Args:
|
||||
command: Command and arguments
|
||||
input_text: Optional stdin input
|
||||
working_dir: Working directory
|
||||
timeout: Timeout in seconds
|
||||
|
||||
Returns:
|
||||
Command output
|
||||
"""
|
||||
try:
|
||||
# Set up environment with auth
|
||||
env = self.get_auth_env()
|
||||
|
||||
# Execute command
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE if input_text else None,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
cwd=working_dir,
|
||||
env=env,
|
||||
)
|
||||
|
||||
# Send input and get output
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(input_text.encode() if input_text else None),
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
# Check for errors
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode() if stderr else "Unknown error"
|
||||
return f"Error: {error_msg}"
|
||||
|
||||
return stdout.decode()
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
return f"Error: Command timed out after {timeout} seconds"
|
||||
except Exception as e:
|
||||
return f"Error executing command: {str(e)}"
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register this tool with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
"""
|
||||
tool_self = self # Create a reference to self for use in the closure
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def tool_wrapper(
|
||||
prompt: str,
|
||||
ctx: Context[Any, Any, Any],
|
||||
model: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
timeout: int = 300,
|
||||
) -> str:
|
||||
result: str = await tool_self.call(
|
||||
ctx, prompt=prompt, model=model, working_dir=working_dir, timeout=timeout
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
class ClaudeCLITool(BaseCLITool):
|
||||
"""Claude CLI tool (also available as 'cc' alias)."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="claude-3-5-sonnet-20241022",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "claude"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute Claude CLI for AI assistance using Anthropic's models"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt: str = params.get("prompt", "")
|
||||
model: Optional[str] = params.get("model") or self.default_model
|
||||
working_dir: Optional[str] = params.get("working_dir")
|
||||
timeout: int = params.get("timeout", 300)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["claude"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
input_text=prompt,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class ClaudeCodeCLITool(ClaudeCLITool):
|
||||
"""Claude Code CLI tool (cc alias)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cc"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Claude Code CLI (alias for claude)"
|
||||
|
||||
|
||||
class CodexCLITool(BaseCLITool):
|
||||
"""OpenAI Codex/GPT-4 CLI tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="gpt-4-turbo",
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "codex"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute OpenAI Codex/GPT-4 CLI for code generation and AI assistance"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt: str = params.get("prompt", "")
|
||||
model: Optional[str] = params.get("model") or self.default_model
|
||||
working_dir: Optional[str] = params.get("working_dir")
|
||||
timeout: int = params.get("timeout", 300)
|
||||
|
||||
# Build command (using openai CLI or custom wrapper)
|
||||
command: list[str] = ["openai", "api", "chat.completions.create"]
|
||||
if model:
|
||||
command.extend(["-m", model])
|
||||
command.extend(["-g", "user", prompt])
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class GeminiCLITool(BaseCLITool):
|
||||
"""Google Gemini CLI tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="gemini-1.5-pro",
|
||||
api_key_env="GEMINI_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "gemini"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute Google Gemini CLI for multimodal AI assistance"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt: str = params.get("prompt", "")
|
||||
model: Optional[str] = params.get("model") or self.default_model
|
||||
working_dir: Optional[str] = params.get("working_dir")
|
||||
timeout: int = params.get("timeout", 300)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["gemini"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
command.append(prompt)
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class GrokCLITool(BaseCLITool):
|
||||
"""xAI Grok CLI tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="grok-4",
|
||||
api_key_env="XAI_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "grok"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute xAI Grok CLI for real-time AI assistance"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt: str = params.get("prompt", "")
|
||||
model: Optional[str] = params.get("model") or self.default_model
|
||||
working_dir: Optional[str] = params.get("working_dir")
|
||||
timeout: int = params.get("timeout", 300)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["grok"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
command.append(prompt)
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class OpenHandsCLITool(BaseCLITool):
|
||||
"""OpenHands (OpenDevin) CLI tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="claude-3-5-sonnet-20241022",
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "openhands"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute OpenHands (OpenDevin) for autonomous coding assistance"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt = params.get("prompt", "")
|
||||
model = params.get("model") or self.default_model
|
||||
working_dir: str = params.get("working_dir") or os.getcwd()
|
||||
timeout: int = params.get("timeout", 600) # 10 minutes for OpenHands
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["openhands", "run", prompt]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
command.extend(["--workspace", working_dir])
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class OpenHandsShortCLITool(OpenHandsCLITool):
|
||||
"""OpenHands CLI tool (oh alias)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "oh"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "OpenHands CLI (alias for openhands)"
|
||||
|
||||
|
||||
class HanzoDevCLITool(BaseCLITool):
|
||||
"""Hanzo Dev AI coding assistant."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="claude-3-5-sonnet-20241022",
|
||||
api_key_env="HANZO_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "hanzo_dev"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute Hanzo Dev for AI-powered code editing and development"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt = params.get("prompt", "")
|
||||
model = params.get("model") or self.default_model
|
||||
working_dir: str = params.get("working_dir") or os.getcwd()
|
||||
timeout: int = params.get("timeout", 600)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["hanzo", "dev"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
command.extend(["--prompt", prompt])
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class ClineCLITool(BaseCLITool):
|
||||
"""Cline (formerly Claude Engineer) CLI tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="claude-3-5-sonnet-20241022",
|
||||
api_key_env="ANTHROPIC_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cline"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute Cline for autonomous coding with Claude"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt = params.get("prompt", "")
|
||||
working_dir: str = params.get("working_dir") or os.getcwd()
|
||||
timeout: int = params.get("timeout", 600)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["cline", prompt]
|
||||
command.extend(["--no-interactive"]) # Non-interactive mode for batch
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
class AiderCLITool(BaseCLITool):
|
||||
"""Aider AI pair programming tool."""
|
||||
|
||||
def __init__(self, permission_manager: Optional[PermissionManager] = None):
|
||||
super().__init__(
|
||||
permission_manager=permission_manager,
|
||||
default_model="gpt-4-turbo",
|
||||
api_key_env="OPENAI_API_KEY",
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "aider"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return "Execute Aider for AI pair programming"
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
prompt = params.get("prompt", "")
|
||||
model = params.get("model") or self.default_model
|
||||
working_dir: str = params.get("working_dir") or os.getcwd()
|
||||
timeout: int = params.get("timeout", 600)
|
||||
|
||||
# Build command
|
||||
command: list[str] = ["aider"]
|
||||
if model:
|
||||
command.extend(["--model", model])
|
||||
command.extend(["--message", prompt])
|
||||
command.extend(["--yes"]) # Auto-approve changes
|
||||
command.extend(["--no-stream"]) # No streaming for batch
|
||||
|
||||
# Execute
|
||||
return await self.execute_cli(
|
||||
command,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def register_cli_tools(
|
||||
mcp_server: FastMCP,
|
||||
permission_manager: Optional[PermissionManager] = None,
|
||||
) -> list[BaseTool]:
|
||||
"""Register all CLI tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
permission_manager: Permission manager for access control
|
||||
|
||||
Returns:
|
||||
List of registered CLI tools
|
||||
"""
|
||||
tools: list[BaseTool] = [
|
||||
ClaudeCLITool(permission_manager),
|
||||
ClaudeCodeCLITool(permission_manager), # cc alias
|
||||
CodexCLITool(permission_manager),
|
||||
GeminiCLITool(permission_manager),
|
||||
GrokCLITool(permission_manager),
|
||||
OpenHandsCLITool(permission_manager),
|
||||
OpenHandsShortCLITool(permission_manager), # oh alias
|
||||
HanzoDevCLITool(permission_manager),
|
||||
ClineCLITool(permission_manager),
|
||||
AiderCLITool(permission_manager),
|
||||
]
|
||||
|
||||
# Register each tool
|
||||
for tool in tools:
|
||||
tool.register(mcp_server)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
# Export all CLI tool classes
|
||||
__all__ = [
|
||||
"ClaudeCLITool",
|
||||
"ClaudeCodeCLITool",
|
||||
"CodexCLITool",
|
||||
"GeminiCLITool",
|
||||
"GrokCLITool",
|
||||
"OpenHandsCLITool",
|
||||
"OpenHandsShortCLITool",
|
||||
"HanzoDevCLITool",
|
||||
"ClineCLITool",
|
||||
"AiderCLITool",
|
||||
"register_cli_tools",
|
||||
]
|
||||
@@ -322,7 +322,9 @@ class CodeAuthManager:
|
||||
api_key = None
|
||||
if parent_info.get("has_keyring"):
|
||||
try:
|
||||
api_key = keyring.get_password(f"hanzo-{parent_info['provider']}", parent_account)
|
||||
api_key = keyring.get_password(
|
||||
f"hanzo-{parent_info['provider']}", parent_account
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -117,7 +117,9 @@ Providers: claude, openai, azure, deepseek, google, groq"""
|
||||
model = params.get("model")
|
||||
description = params.get("description")
|
||||
|
||||
success, msg = self.auth_manager.create_account(account, provider, api_key, model, description)
|
||||
success, msg = self.auth_manager.create_account(
|
||||
account, provider, api_key, model, description
|
||||
)
|
||||
return msg
|
||||
|
||||
elif action == "login":
|
||||
@@ -146,7 +148,9 @@ Providers: claude, openai, azure, deepseek, google, groq"""
|
||||
parent_account = params.get("parent_account")
|
||||
|
||||
# Try to create agent account
|
||||
success, result = self.auth_manager.create_agent_account(agent_id, provider, parent_account)
|
||||
success, result = self.auth_manager.create_agent_account(
|
||||
agent_id, provider, parent_account
|
||||
)
|
||||
|
||||
if success:
|
||||
# Get credentials
|
||||
|
||||
@@ -117,7 +117,9 @@ class AutoCritic:
|
||||
) -> str:
|
||||
"""Perform automated critical review."""
|
||||
review_func = self.review_patterns.get(review_type, self._review_general)
|
||||
return review_func(work_description, code_snippets, file_paths, specific_concerns)
|
||||
return review_func(
|
||||
work_description, code_snippets, file_paths, specific_concerns
|
||||
)
|
||||
|
||||
def _review_code_quality(
|
||||
self,
|
||||
@@ -135,11 +137,15 @@ class AutoCritic:
|
||||
for snippet in code_snippets:
|
||||
# Check for proper error handling
|
||||
if "error" in snippet.lower() and "if err" not in snippet:
|
||||
issues.append("❌ Missing error handling - always check errors in Go")
|
||||
issues.append(
|
||||
"❌ Missing error handling - always check errors in Go"
|
||||
)
|
||||
|
||||
# Check for magic numbers
|
||||
if any(char.isdigit() for char in snippet) and "const" not in snippet:
|
||||
suggestions.append("💡 Consider extracting magic numbers to named constants")
|
||||
suggestions.append(
|
||||
"💡 Consider extracting magic numbers to named constants"
|
||||
)
|
||||
|
||||
# Check for proper imports
|
||||
if "import" in snippet:
|
||||
@@ -154,7 +160,9 @@ class AutoCritic:
|
||||
suggestions.append("💡 Consider edge cases and error scenarios")
|
||||
|
||||
if file_paths and len(file_paths) > 5:
|
||||
suggestions.append("💡 Large number of files modified - consider breaking into smaller PRs")
|
||||
suggestions.append(
|
||||
"💡 Large number of files modified - consider breaking into smaller PRs"
|
||||
)
|
||||
|
||||
# Build response
|
||||
response = "🔍 CODE QUALITY REVIEW:\n\n"
|
||||
@@ -165,7 +173,9 @@ class AutoCritic:
|
||||
response += "✅ No major code quality issues detected.\n\n"
|
||||
|
||||
if suggestions:
|
||||
response += "Suggestions for Improvement:\n" + "\n".join(suggestions) + "\n\n"
|
||||
response += (
|
||||
"Suggestions for Improvement:\n" + "\n".join(suggestions) + "\n\n"
|
||||
)
|
||||
|
||||
if specific_concerns:
|
||||
response += f"Regarding your concern: '{specific_concerns}'\n"
|
||||
@@ -173,7 +183,9 @@ class AutoCritic:
|
||||
response += "→ Imports look properly formatted. Ensure they're in the standard order: stdlib, external, internal.\n"
|
||||
|
||||
response += "\nOverall: " + (
|
||||
"⚠️ Address the issues before proceeding." if issues else "✅ Good work, but always room for improvement!"
|
||||
"⚠️ Address the issues before proceeding."
|
||||
if issues
|
||||
else "✅ Good work, but always room for improvement!"
|
||||
)
|
||||
|
||||
return response
|
||||
@@ -257,7 +269,9 @@ Security Checklist:
|
||||
response = "🔍 COMPLETENESS REVIEW:\n\n"
|
||||
|
||||
if "fix" in tasks_mentioned and "test" not in tasks_mentioned:
|
||||
response += "❌ No mention of tests - have you verified the fix with tests?\n"
|
||||
response += (
|
||||
"❌ No mention of tests - have you verified the fix with tests?\n"
|
||||
)
|
||||
|
||||
if "import" in tasks_mentioned:
|
||||
response += "✓ Import fixes mentioned\n"
|
||||
@@ -368,6 +382,8 @@ class CriticProtocol:
|
||||
except KeyError:
|
||||
review_enum = ReviewType.GENERAL
|
||||
|
||||
review = self.auto_critic.review(review_enum, work_description, code_snippets, file_paths, specific_concerns)
|
||||
review = self.auto_critic.review(
|
||||
review_enum, work_description, code_snippets, file_paths, specific_concerns
|
||||
)
|
||||
|
||||
return f"Review {self.review_count}/{self.max_reviews}:\n\n{review}"
|
||||
|
||||
@@ -423,13 +423,17 @@ class IChing:
|
||||
lines += "1"
|
||||
return Hexagram(lines)
|
||||
|
||||
def select_principles(self, hexagram: Hexagram, challenge: str) -> List[HanzoPrinciple]:
|
||||
def select_principles(
|
||||
self, hexagram: Hexagram, challenge: str
|
||||
) -> List[HanzoPrinciple]:
|
||||
"""Select relevant Hanzo principles based on hexagram and challenge."""
|
||||
# Use hexagram pattern to deterministically but creatively select principles
|
||||
selected = []
|
||||
|
||||
# Primary principle based on hexagram pattern
|
||||
primary_index = sum(int(bit) * (2**i) for i, bit in enumerate(hexagram.lines)) % len(self.principles)
|
||||
primary_index = sum(
|
||||
int(bit) * (2**i) for i, bit in enumerate(hexagram.lines)
|
||||
) % len(self.principles)
|
||||
selected.append(self.principles[primary_index])
|
||||
|
||||
# Supporting principles based on challenge keywords
|
||||
@@ -454,7 +458,9 @@ class IChing:
|
||||
# Add complementary principle based on changing lines
|
||||
changing_lines = hexagram.get_changing_lines()
|
||||
if changing_lines:
|
||||
complement_index = (primary_index + sum(changing_lines)) % len(self.principles)
|
||||
complement_index = (primary_index + sum(changing_lines)) % len(
|
||||
self.principles
|
||||
)
|
||||
selected.append(self.principles[complement_index])
|
||||
|
||||
# Ensure uniqueness and limit to 3-5 principles
|
||||
@@ -467,7 +473,9 @@ class IChing:
|
||||
|
||||
return unique_selected[:5]
|
||||
|
||||
def generate_guidance(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
|
||||
def generate_guidance(
|
||||
self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str
|
||||
) -> str:
|
||||
"""Generate creative guidance combining I Ching wisdom and Hanzo principles."""
|
||||
guidance = f"☯️ I CHING GUIDANCE FOR ENGINEERING CHALLENGE ☯️\n\n"
|
||||
guidance += f"**Your Challenge:** {challenge}\n\n"
|
||||
@@ -490,15 +498,21 @@ class IChing:
|
||||
if "Creative" in hexagram.title:
|
||||
guidance += "• This is a time for bold innovation. Don't hold back on ambitious ideas.\n"
|
||||
elif "Receptive" in hexagram.title:
|
||||
guidance += "• Listen deeply to user needs and system constraints before acting.\n"
|
||||
guidance += (
|
||||
"• Listen deeply to user needs and system constraints before acting.\n"
|
||||
)
|
||||
elif "Difficulty" in hexagram.title:
|
||||
guidance += "• Challenges are teachers. Each obstacle reveals the path forward.\n"
|
||||
guidance += (
|
||||
"• Challenges are teachers. Each obstacle reveals the path forward.\n"
|
||||
)
|
||||
elif "Waiting" in hexagram.title:
|
||||
guidance += "• Strategic patience required. Prepare thoroughly before implementation.\n"
|
||||
elif "Conflict" in hexagram.title:
|
||||
guidance += "• Technical disagreements? Seek data-driven resolution.\n"
|
||||
elif "Peace" in hexagram.title:
|
||||
guidance += "• Harmony achieved. Now build sustainably on this foundation.\n"
|
||||
guidance += (
|
||||
"• Harmony achieved. Now build sustainably on this foundation.\n"
|
||||
)
|
||||
|
||||
# Principle-specific actionable advice
|
||||
principle_actions = {
|
||||
@@ -520,7 +534,9 @@ class IChing:
|
||||
changing_lines = hexagram.get_changing_lines()
|
||||
if changing_lines:
|
||||
guidance += f"\n**Lines in Transition:** {', '.join(str(i + 1) for i in changing_lines)}\n"
|
||||
guidance += "• Change is imminent in these areas. Prepare for transformation.\n"
|
||||
guidance += (
|
||||
"• Change is imminent in these areas. Prepare for transformation.\n"
|
||||
)
|
||||
|
||||
# Final synthesis
|
||||
guidance += "\n**The Way Forward:**\n"
|
||||
@@ -531,7 +547,9 @@ class IChing:
|
||||
|
||||
return guidance
|
||||
|
||||
def _synthesize_action_plan(self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str) -> str:
|
||||
def _synthesize_action_plan(
|
||||
self, hexagram: Hexagram, principles: List[HanzoPrinciple], challenge: str
|
||||
) -> str:
|
||||
"""Create a specific action plan based on the reading."""
|
||||
plan = ""
|
||||
|
||||
@@ -540,15 +558,21 @@ class IChing:
|
||||
plan += "1. **Diagnose systematically** - Use empirical debugging, not guesswork\n"
|
||||
plan += "2. **Fix root cause** - Address the source, not just symptoms\n"
|
||||
plan += "3. **Prevent recurrence** - Add tests and monitoring\n"
|
||||
elif any(word in challenge.lower() for word in ["scale", "performance", "slow"]):
|
||||
elif any(
|
||||
word in challenge.lower() for word in ["scale", "performance", "slow"]
|
||||
):
|
||||
plan += "1. **Measure first** - Profile to find actual bottlenecks\n"
|
||||
plan += "2. **Parallelize** - Use concurrency where possible\n"
|
||||
plan += "3. **Simplify** - Remove complexity before optimizing\n"
|
||||
elif any(word in challenge.lower() for word in ["design", "architect", "structure"]):
|
||||
elif any(
|
||||
word in challenge.lower() for word in ["design", "architect", "structure"]
|
||||
):
|
||||
plan += "1. **Start simple** - MVP first, elaborate later\n"
|
||||
plan += "2. **Stay flexible** - Design for change\n"
|
||||
plan += "3. **Think holistically** - Consider entire system\n"
|
||||
elif any(word in challenge.lower() for word in ["team", "collaborate", "people"]):
|
||||
elif any(
|
||||
word in challenge.lower() for word in ["team", "collaborate", "people"]
|
||||
):
|
||||
plan += "1. **Enable autonomy** - Trust your team\n"
|
||||
plan += "2. **Maintain balance** - Sustainable pace wins\n"
|
||||
plan += "3. **Share knowledge** - Elevate everyone\n"
|
||||
|
||||
@@ -77,7 +77,9 @@ class NetworkTool(BaseTool):
|
||||
"""
|
||||
self.permission_manager = permission_manager
|
||||
self.default_mode = default_mode
|
||||
self.cluster_endpoint = cluster_endpoint or os.environ.get("HANZO_CLUSTER_ENDPOINT", "http://localhost:8000")
|
||||
self.cluster_endpoint = cluster_endpoint or os.environ.get(
|
||||
"HANZO_CLUSTER_ENDPOINT", "http://localhost:8000"
|
||||
)
|
||||
self._cluster = None
|
||||
|
||||
async def _ensure_cluster(self):
|
||||
@@ -158,7 +160,9 @@ class NetworkTool(BaseTool):
|
||||
results["results"].append(
|
||||
{
|
||||
"agent": "local-cluster",
|
||||
"response": local_result.get("choices", [{}])[0].get("text", ""),
|
||||
"response": local_result.get("choices", [{}])[0].get(
|
||||
"text", ""
|
||||
),
|
||||
"local": True,
|
||||
}
|
||||
)
|
||||
@@ -173,33 +177,17 @@ class NetworkTool(BaseTool):
|
||||
results["error"] = f"Local execution failed: {str(e)}"
|
||||
return json.dumps(results, indent=2)
|
||||
|
||||
# Fallback to agent-based execution
|
||||
# This would use hanzo-agents or the existing swarm implementation
|
||||
if not results["success"] or mode in ["distributed", "hybrid"]:
|
||||
# Import swarm tool as fallback
|
||||
from hanzo_mcp.tools.agent.swarm_tool import SwarmTool
|
||||
|
||||
# Create temporary swarm tool
|
||||
swarm = SwarmTool(permission_manager=self.permission_manager, model=model_pref)
|
||||
|
||||
# Convert network params to swarm params
|
||||
swarm_params = {
|
||||
"prompts": [task] if not agents_list else agents_list,
|
||||
"consensus": routing == "consensus",
|
||||
"parallel": routing == "parallel",
|
||||
}
|
||||
|
||||
# Execute via swarm
|
||||
swarm_result = await swarm.call(ctx, **swarm_params)
|
||||
swarm_data = json.loads(swarm_result)
|
||||
|
||||
# Merge results
|
||||
if swarm_data.get("success"):
|
||||
results["agents_used"].extend([r["agent"] for r in swarm_data.get("results", [])])
|
||||
results["results"].extend(swarm_data.get("results", []))
|
||||
# Agent-based execution with concurrency
|
||||
if not results["success"] or mode in ["distributed", "hybrid"]:
|
||||
from hanzo_mcp.tools.agent.agent_tool import AgentTool
|
||||
agent = AgentTool(permission_manager=self.permission_manager, model=model_pref)
|
||||
concurrency = max(1, len(agents_list)) if agents_list else 5 if routing == "parallel" else 1
|
||||
agent_params = {"prompts": task, "concurrency": concurrency}
|
||||
agent_result = await agent.call(ctx, **agent_params)
|
||||
# Wrap agent_result as a simple result list
|
||||
results["agents_used"].append("agent")
|
||||
results["results"].append({"agent": "agent", "response": agent_result})
|
||||
results["success"] = True
|
||||
else:
|
||||
results["error"] = swarm_data.get("error", "Unknown error")
|
||||
|
||||
except Exception as e:
|
||||
results["error"] = str(e)
|
||||
@@ -218,17 +206,25 @@ class NetworkTool(BaseTool):
|
||||
async def network_handler(
|
||||
ctx: MCPContext,
|
||||
task: Annotated[str, Field(description="Task to execute on the network")],
|
||||
agents: Annotated[Optional[List[str]], Field(description="Specific agents to use")] = None,
|
||||
agents: Annotated[
|
||||
Optional[List[str]], Field(description="Specific agents to use")
|
||||
] = None,
|
||||
mode: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Execution mode: local, distributed, or hybrid"),
|
||||
] = None,
|
||||
model: Annotated[Optional[str], Field(description="Model preference")] = None,
|
||||
model: Annotated[
|
||||
Optional[str], Field(description="Model preference")
|
||||
] = None,
|
||||
routing: Annotated[
|
||||
Optional[str],
|
||||
Field(description="Routing strategy: sequential, parallel, or consensus"),
|
||||
Field(
|
||||
description="Routing strategy: sequential, parallel, or consensus"
|
||||
),
|
||||
] = None,
|
||||
require_local: Annotated[
|
||||
Optional[bool], Field(description="Require local-only execution")
|
||||
] = None,
|
||||
require_local: Annotated[Optional[bool], Field(description="Require local-only execution")] = None,
|
||||
) -> str:
|
||||
"""Dispatch work to agent networks."""
|
||||
params = NetworkToolParams(
|
||||
@@ -244,26 +240,4 @@ class NetworkTool(BaseTool):
|
||||
return tool
|
||||
|
||||
|
||||
# Alias swarm to use network tool with local-only mode
|
||||
@final
|
||||
class LocalSwarmTool(NetworkTool):
|
||||
"""Local-only version of the network tool (swarm compatibility).
|
||||
|
||||
This provides backward compatibility with the swarm tool
|
||||
while using local compute resources only.
|
||||
"""
|
||||
|
||||
name = "swarm"
|
||||
description = "Run agent swarms locally using hanzo-miner compute"
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, **kwargs):
|
||||
"""Initialize as local-only network."""
|
||||
super().__init__(permission_manager=permission_manager, default_mode="local", **kwargs)
|
||||
|
||||
@override
|
||||
async def call(self, ctx: MCPContext, **params: Unpack[NetworkToolParams]) -> str:
|
||||
"""Execute with local-only mode."""
|
||||
# Force local mode
|
||||
params["mode"] = "local"
|
||||
params["require_local"] = True
|
||||
return await super().call(ctx, **params)
|
||||
# Remove swarm compatibility tool; swarm is an alias of agent with concurrency
|
||||
|
||||
@@ -139,7 +139,11 @@ def get_default_model(model_override: str | None = None) -> str:
|
||||
model = os.environ.get("AGENT_MODEL", "claude-3-5-sonnet-20241022")
|
||||
|
||||
# Special cases for tests
|
||||
if model.startswith("test-model") or "TEST_MODE" in os.environ and model == "claude-3-5-sonnet-20241022":
|
||||
if (
|
||||
model.startswith("test-model")
|
||||
or "TEST_MODE" in os.environ
|
||||
and model == "claude-3-5-sonnet-20241022"
|
||||
):
|
||||
return model
|
||||
|
||||
provider = os.environ.get("AGENT_PROVIDER", "anthropic")
|
||||
|
||||
@@ -83,7 +83,9 @@ review(
|
||||
file_paths: Optional[List[str]] = None,
|
||||
context: Optional[str] = None,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, focus, work_description, code_snippets, file_paths, context)
|
||||
return await tool_self.call(
|
||||
ctx, focus, work_description, code_snippets, file_paths, context
|
||||
)
|
||||
|
||||
|
||||
class BalancedReviewer:
|
||||
@@ -208,18 +210,23 @@ class BalancedReviewer:
|
||||
|
||||
if code_snippets:
|
||||
total_lines = sum(snippet.count("\n") + 1 for snippet in code_snippets)
|
||||
avg_line_length = sum(len(line) for snippet in code_snippets for line in snippet.split("\n")) / max(
|
||||
total_lines, 1
|
||||
)
|
||||
avg_line_length = sum(
|
||||
len(line) for snippet in code_snippets for line in snippet.split("\n")
|
||||
) / max(total_lines, 1)
|
||||
|
||||
if avg_line_length < 80:
|
||||
response += "✓ Line lengths are reasonable\n"
|
||||
else:
|
||||
response += "• Some lines might be too long, consider breaking them up\n"
|
||||
response += (
|
||||
"• Some lines might be too long, consider breaking them up\n"
|
||||
)
|
||||
|
||||
# Check naming
|
||||
has_good_names = any(
|
||||
any(word in snippet for word in ["Add", "Get", "Set", "Create", "Update", "Delete"])
|
||||
any(
|
||||
word in snippet
|
||||
for word in ["Add", "Get", "Set", "Create", "Update", "Delete"]
|
||||
)
|
||||
for snippet in code_snippets
|
||||
)
|
||||
if has_good_names:
|
||||
@@ -257,7 +264,10 @@ class BalancedReviewer:
|
||||
# Check for modularity in code
|
||||
if code_snippets:
|
||||
function_count = sum(
|
||||
snippet.count("func ") + snippet.count("def ") + snippet.count("function ") for snippet in code_snippets
|
||||
snippet.count("func ")
|
||||
+ snippet.count("def ")
|
||||
+ snippet.count("function ")
|
||||
for snippet in code_snippets
|
||||
)
|
||||
if function_count > 0:
|
||||
response += "✓ Code is broken into functions/methods\n"
|
||||
@@ -324,7 +334,8 @@ class BalancedReviewer:
|
||||
has_comments = False
|
||||
if code_snippets:
|
||||
has_comments = any(
|
||||
"//" in snippet or "/*" in snippet or "#" in snippet or '"""' in snippet for snippet in code_snippets
|
||||
"//" in snippet or "/*" in snippet or "#" in snippet or '"""' in snippet
|
||||
for snippet in code_snippets
|
||||
)
|
||||
|
||||
if has_comments:
|
||||
@@ -361,7 +372,9 @@ class BalancedReviewer:
|
||||
# Analyze file structure
|
||||
if file_paths:
|
||||
# Check for separation of concerns
|
||||
has_separation = len(set(str(p).split("/")[-2] for p in file_paths if "/" in str(p))) > 1
|
||||
has_separation = (
|
||||
len(set(str(p).split("/")[-2] for p in file_paths if "/" in str(p))) > 1
|
||||
)
|
||||
if has_separation:
|
||||
response += "✓ Changes span multiple modules (good separation)\n"
|
||||
else:
|
||||
@@ -413,7 +426,9 @@ class ReviewProtocol:
|
||||
except KeyError:
|
||||
focus_enum = ReviewFocus.GENERAL
|
||||
|
||||
review = self.reviewer.review(focus_enum, work_description, code_snippets, file_paths, context)
|
||||
review = self.reviewer.review(
|
||||
focus_enum, work_description, code_snippets, file_paths, context
|
||||
)
|
||||
|
||||
header = f"Review {self.review_count}/{self.max_reviews} (Focus: {focus_enum.value}):\n\n"
|
||||
footer = "\n\n💡 This is a balanced review - consider both strengths and suggestions."
|
||||
|
||||
@@ -61,7 +61,9 @@ For new code, prefer using 'network' directly."""
|
||||
**kwargs: Additional arguments passed to NetworkTool
|
||||
"""
|
||||
# Just pass through to NetworkTool
|
||||
super().__init__(permission_manager=permission_manager, default_mode=default_mode, **kwargs)
|
||||
super().__init__(
|
||||
permission_manager=permission_manager, default_mode=default_mode, **kwargs
|
||||
)
|
||||
|
||||
async def call(self, **kwargs) -> str:
|
||||
"""Execute swarm via network tool.
|
||||
|
||||
@@ -191,7 +191,9 @@ class SwarmToolParams(TypedDict):
|
||||
class SwarmState(State):
|
||||
"""State for swarm execution."""
|
||||
|
||||
def __init__(self, config: SwarmConfig, initial_query: str, context: Optional[str] = None):
|
||||
def __init__(
|
||||
self, config: SwarmConfig, initial_query: str, context: Optional[str] = None
|
||||
):
|
||||
"""Initialize swarm state."""
|
||||
super().__init__()
|
||||
self.config = config
|
||||
@@ -292,7 +294,9 @@ class SwarmAgent(MCPAgent):
|
||||
# Default to anthropic
|
||||
return f"model://anthropic/{model}"
|
||||
|
||||
async def run(self, state: SwarmState, history: History, network: Network) -> InferenceResult:
|
||||
async def run(
|
||||
self, state: SwarmState, history: History, network: Network
|
||||
) -> InferenceResult:
|
||||
"""Execute the swarm agent."""
|
||||
# Build prompt with context
|
||||
prompt_parts = []
|
||||
@@ -414,7 +418,9 @@ class SwarmRouter(DeterministicRouter):
|
||||
# No more agents to execute
|
||||
return None
|
||||
|
||||
def _get_agent_class(self, agent_id: str, agent_stack: List[type[Agent]]) -> type[Agent]:
|
||||
def _get_agent_class(
|
||||
self, agent_id: str, agent_stack: List[type[Agent]]
|
||||
) -> type[Agent]:
|
||||
"""Get agent class for given agent ID."""
|
||||
# Find matching agent by name
|
||||
for agent_class in agent_stack:
|
||||
@@ -489,7 +495,11 @@ Models can be specified as:
|
||||
from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
|
||||
|
||||
self.model = model or f"anthropic/{get_latest_claude_model()}"
|
||||
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
||||
self.api_key = (
|
||||
api_key
|
||||
or os.environ.get("ANTHROPIC_API_KEY")
|
||||
or os.environ.get("CLAUDE_API_KEY")
|
||||
)
|
||||
self.base_url = base_url
|
||||
self.max_tokens = max_tokens
|
||||
self.agent_max_iterations = agent_max_iterations
|
||||
@@ -497,15 +507,21 @@ Models can be specified as:
|
||||
|
||||
# Set up available tools for agents
|
||||
self.available_tools: list[BaseTool] = []
|
||||
self.available_tools.extend(get_read_only_filesystem_tools(self.permission_manager))
|
||||
self.available_tools.extend(get_read_only_jupyter_tools(self.permission_manager))
|
||||
self.available_tools.extend(
|
||||
get_read_only_filesystem_tools(self.permission_manager)
|
||||
)
|
||||
self.available_tools.extend(
|
||||
get_read_only_jupyter_tools(self.permission_manager)
|
||||
)
|
||||
|
||||
# Add edit tools
|
||||
self.available_tools.append(Edit(self.permission_manager))
|
||||
self.available_tools.append(MultiEdit(self.permission_manager))
|
||||
|
||||
# Add batch tool
|
||||
self.available_tools.append(BatchTool({t.name: t for t in self.available_tools}))
|
||||
self.available_tools.append(
|
||||
BatchTool({t.name: t for t in self.available_tools})
|
||||
)
|
||||
|
||||
@override
|
||||
async def call(
|
||||
@@ -533,7 +549,9 @@ Models can be specified as:
|
||||
|
||||
# hanzo-agents SDK is required (already imported above)
|
||||
|
||||
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents using hanzo-agents SDK")
|
||||
await tool_ctx.info(
|
||||
f"Starting swarm execution with {len(agents_config)} agents using hanzo-agents SDK"
|
||||
)
|
||||
|
||||
# Create state
|
||||
state = SwarmState(config=config, initial_query=initial_query, context=context)
|
||||
@@ -630,7 +648,9 @@ Models can be specified as:
|
||||
output.append("=" * 80)
|
||||
output.append(f"Total agents: {len(agents_config)}")
|
||||
output.append(f"Completed: {len(results)}")
|
||||
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
|
||||
output.append(
|
||||
f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}"
|
||||
)
|
||||
|
||||
if entry_point:
|
||||
output.append(f"Entry point: {entry_point}")
|
||||
|
||||
@@ -196,7 +196,11 @@ Models can be specified as:
|
||||
from hanzo_mcp.tools.agent.code_auth import get_latest_claude_model
|
||||
|
||||
self.model = model or f"anthropic/{get_latest_claude_model()}"
|
||||
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
||||
self.api_key = (
|
||||
api_key
|
||||
or os.environ.get("ANTHROPIC_API_KEY")
|
||||
or os.environ.get("CLAUDE_API_KEY")
|
||||
)
|
||||
self.base_url = base_url
|
||||
self.max_tokens = max_tokens
|
||||
self.agent_max_iterations = agent_max_iterations
|
||||
@@ -239,7 +243,9 @@ Models can be specified as:
|
||||
agents_config = config.get("agents", {})
|
||||
entry_point = config.get("entry_point")
|
||||
|
||||
await tool_ctx.info(f"Starting swarm execution with {len(agents_config)} agents")
|
||||
await tool_ctx.info(
|
||||
f"Starting swarm execution with {len(agents_config)} agents"
|
||||
)
|
||||
|
||||
# Build agent network
|
||||
agent_instances = {}
|
||||
@@ -286,7 +292,10 @@ Models can be specified as:
|
||||
# Check if any other agent connects to this one
|
||||
has_incoming = False
|
||||
for other_config in agents_config.values():
|
||||
if other_config.get("connections") and agent_id in other_config["connections"]:
|
||||
if (
|
||||
other_config.get("connections")
|
||||
and agent_id in other_config["connections"]
|
||||
):
|
||||
has_incoming = True
|
||||
break
|
||||
if not has_incoming:
|
||||
@@ -300,14 +309,18 @@ Models can be specified as:
|
||||
await execution_queue.put((root, initial_query, {}))
|
||||
|
||||
# Execute agents in network order
|
||||
async def execute_agent(agent_id: str, query: str, inputs: Dict[str, str]) -> str:
|
||||
async def execute_agent(
|
||||
agent_id: str, query: str, inputs: Dict[str, str]
|
||||
) -> str:
|
||||
"""Execute a single agent in the network."""
|
||||
async with semaphore:
|
||||
try:
|
||||
agent_config = agents_config[agent_id]
|
||||
agent = agent_instances[agent_id]
|
||||
|
||||
await tool_ctx.info(f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})")
|
||||
await tool_ctx.info(
|
||||
f"Executing agent: {agent_id} ({agent_config.get('role', 'Agent')})"
|
||||
)
|
||||
|
||||
# Build prompt with context and inputs
|
||||
prompt_parts = []
|
||||
@@ -324,11 +337,15 @@ Models can be specified as:
|
||||
if inputs:
|
||||
prompt_parts.append("Input from previous agents:")
|
||||
for input_agent, input_result in inputs.items():
|
||||
prompt_parts.append(f"\n--- From {input_agent} ---\n{input_result}")
|
||||
prompt_parts.append(
|
||||
f"\n--- From {input_agent} ---\n{input_result}"
|
||||
)
|
||||
|
||||
# Add file context if specified
|
||||
if agent_config.get("file_path"):
|
||||
prompt_parts.append(f"\nFile to work on: {agent_config['file_path']}")
|
||||
prompt_parts.append(
|
||||
f"\nFile to work on: {agent_config['file_path']}"
|
||||
)
|
||||
|
||||
# Add the main query
|
||||
prompt_parts.append(f"\nTask: {agent_config['query']}")
|
||||
@@ -376,7 +393,9 @@ Models can be specified as:
|
||||
|
||||
if ready:
|
||||
# Execute agent
|
||||
task = asyncio.create_task(execute_agent(agent_id, query, inputs))
|
||||
task = asyncio.create_task(
|
||||
execute_agent(agent_id, query, inputs)
|
||||
)
|
||||
running_tasks.add(task)
|
||||
|
||||
async def handle_completion(task, agent_id=agent_id):
|
||||
@@ -390,7 +409,9 @@ Models can be specified as:
|
||||
connections = agent_config.get("connections", [])
|
||||
for next_agent in connections:
|
||||
if next_agent in agents_config:
|
||||
await execution_queue.put((next_agent, "", {agent_id: result}))
|
||||
await execution_queue.put(
|
||||
(next_agent, "", {agent_id: result})
|
||||
)
|
||||
|
||||
asyncio.create_task(handle_completion(task))
|
||||
|
||||
@@ -449,7 +470,9 @@ Models can be specified as:
|
||||
output.append("=" * 80)
|
||||
output.append(f"Total agents: {len(agents_config)}")
|
||||
output.append(f"Completed: {len(results)}")
|
||||
output.append(f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}")
|
||||
output.append(
|
||||
f"Failed: {len([r for r in results.values() if r.startswith('Error:')])}"
|
||||
)
|
||||
|
||||
if entry_point:
|
||||
output.append(f"Entry point: {entry_point}")
|
||||
@@ -467,7 +490,12 @@ Models can be specified as:
|
||||
role = config.get("role", "Agent")
|
||||
model = config.get("model", "default")
|
||||
|
||||
status = "✅" if agent_id in results and not results[agent_id].startswith("Error:") else "❌"
|
||||
status = (
|
||||
"✅"
|
||||
if agent_id in results
|
||||
and not results[agent_id].startswith("Error:")
|
||||
else "❌"
|
||||
)
|
||||
lines.append(f"{indent}{status} {agent_id} ({role}) [{model}]")
|
||||
|
||||
# Show connections
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
"""Unified CLI Tools - DRY implementation using base agent classes.
|
||||
|
||||
This module provides the single, clean implementation of all CLI tools
|
||||
following Python best practices and eliminating all duplication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context
|
||||
|
||||
from ..common.base import BaseTool
|
||||
from ...core.base_agent import CLIAgent, AgentConfig
|
||||
from ..common.permissions import PermissionManager
|
||||
from ...core.model_registry import registry
|
||||
|
||||
|
||||
class UnifiedCLITool(BaseTool, CLIAgent):
|
||||
"""Unified CLI tool that combines BaseTool and CLIAgent functionality.
|
||||
|
||||
MRO: BaseTool first for proper method resolution order.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
description: str,
|
||||
cli_command: str,
|
||||
default_model: str,
|
||||
permission_manager: Optional[PermissionManager] = None,
|
||||
):
|
||||
"""Initialize unified CLI tool.
|
||||
|
||||
Args:
|
||||
name: Tool name
|
||||
description: Tool description
|
||||
cli_command: CLI command to execute
|
||||
default_model: Default model to use
|
||||
permission_manager: Permission manager for access control
|
||||
"""
|
||||
# Initialize CLIAgent with config
|
||||
config = AgentConfig(model=default_model)
|
||||
CLIAgent.__init__(self, config)
|
||||
|
||||
# Store tool metadata
|
||||
self._name = name
|
||||
self._description = description
|
||||
self._cli_command = cli_command
|
||||
self.permission_manager = permission_manager
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return self._description
|
||||
|
||||
@property
|
||||
def cli_command(self) -> str:
|
||||
return self._cli_command
|
||||
|
||||
def build_command(self, prompt: str, **kwargs: Any) -> List[str]:
|
||||
"""Build the CLI command with model-specific formatting.
|
||||
|
||||
Args:
|
||||
prompt: The prompt
|
||||
**kwargs: Additional parameters
|
||||
|
||||
Returns:
|
||||
Command arguments list
|
||||
"""
|
||||
command = [self.cli_command]
|
||||
|
||||
# Get model config from registry
|
||||
model_config = registry.get(self.config.model)
|
||||
|
||||
# Handle different CLI tool formats
|
||||
if self.cli_command == "claude":
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
# Claude takes prompt via stdin
|
||||
return command
|
||||
|
||||
elif self.cli_command == "openai":
|
||||
# OpenAI CLI format
|
||||
command.extend(["api", "chat.completions.create"])
|
||||
if model_config:
|
||||
command.extend(["-m", model_config.full_name])
|
||||
command.extend(["-g", "user", prompt])
|
||||
return command
|
||||
|
||||
elif self.cli_command in ["gemini", "grok"]:
|
||||
# Simple format: command --model MODEL prompt
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
command.append(prompt)
|
||||
return command
|
||||
|
||||
elif self.cli_command == "openhands":
|
||||
# OpenHands format
|
||||
command.extend(["run", prompt])
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
if self.config.working_dir:
|
||||
command.extend(["--workspace", str(self.config.working_dir)])
|
||||
return command
|
||||
|
||||
elif self.cli_command == "hanzo":
|
||||
# Hanzo dev format
|
||||
command.append("dev")
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
command.extend(["--prompt", prompt])
|
||||
return command
|
||||
|
||||
elif self.cli_command == "cline":
|
||||
# Cline format
|
||||
command.append(prompt)
|
||||
command.append("--no-interactive")
|
||||
return command
|
||||
|
||||
elif self.cli_command == "aider":
|
||||
# Aider format
|
||||
if model_config:
|
||||
command.extend(["--model", model_config.full_name])
|
||||
command.extend(["--message", prompt])
|
||||
command.extend(["--yes", "--no-stream"])
|
||||
return command
|
||||
|
||||
elif self.cli_command == "ollama":
|
||||
# Ollama format for local models
|
||||
command.extend(["run", self.config.model.replace("ollama/", "")])
|
||||
command.append(prompt)
|
||||
return command
|
||||
|
||||
# Default format
|
||||
command.append(prompt)
|
||||
return command
|
||||
|
||||
async def call(self, ctx: Context[Any, Any, Any], **params: Any) -> str:
|
||||
"""Execute the CLI tool via MCP interface.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Execution result
|
||||
"""
|
||||
# Update config from params
|
||||
if params.get("model"):
|
||||
self.config.model = registry.resolve(params["model"])
|
||||
if params.get("working_dir"):
|
||||
self.config.working_dir = Path(params["working_dir"])
|
||||
if params.get("timeout"):
|
||||
self.config.timeout = params["timeout"]
|
||||
|
||||
# Execute using base agent
|
||||
result = await self.execute(
|
||||
params.get("prompt", ""),
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
return result.content
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register this tool with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
"""
|
||||
tool_self = self
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def tool_wrapper(
|
||||
prompt: str,
|
||||
ctx: Context[Any, Any, Any],
|
||||
model: Optional[str] = None,
|
||||
working_dir: Optional[str] = None,
|
||||
timeout: int = 300,
|
||||
) -> str:
|
||||
return await tool_self.call(
|
||||
ctx,
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
working_dir=working_dir,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
|
||||
def create_cli_tools(permission_manager: Optional[PermissionManager] = None) -> Dict[str, UnifiedCLITool]:
|
||||
"""Create all CLI tools with unified implementation.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
|
||||
Returns:
|
||||
Dictionary of tool name to tool instance
|
||||
"""
|
||||
tools = {}
|
||||
|
||||
# Define all tools with their configurations
|
||||
tool_configs = [
|
||||
("claude", "Execute Claude CLI for AI assistance", "claude", "claude"),
|
||||
("cc", "Claude Code CLI (alias for claude)", "claude", "claude"),
|
||||
("codex", "Execute OpenAI Codex/GPT-4 CLI", "openai", "gpt-4-turbo"),
|
||||
("gemini", "Execute Google Gemini CLI", "gemini", "gemini"),
|
||||
("grok", "Execute xAI Grok CLI", "grok", "grok"),
|
||||
("openhands", "Execute OpenHands for autonomous coding", "openhands", "claude"),
|
||||
("oh", "OpenHands CLI (alias)", "openhands", "claude"),
|
||||
("hanzo_dev", "Execute Hanzo Dev AI assistant", "hanzo", "claude"),
|
||||
("cline", "Execute Cline for autonomous coding", "cline", "claude"),
|
||||
("aider", "Execute Aider for AI pair programming", "aider", "gpt-4-turbo"),
|
||||
]
|
||||
|
||||
for name, description, cli_command, default_model in tool_configs:
|
||||
tools[name] = UnifiedCLITool(
|
||||
name=name,
|
||||
description=description,
|
||||
cli_command=cli_command,
|
||||
default_model=default_model,
|
||||
permission_manager=permission_manager,
|
||||
)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
def register_cli_tools(
|
||||
mcp_server: FastMCP,
|
||||
permission_manager: Optional[PermissionManager] = None,
|
||||
) -> List[BaseTool]:
|
||||
"""Register all CLI tools with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
permission_manager: Permission manager for access control
|
||||
|
||||
Returns:
|
||||
List of registered CLI tools
|
||||
"""
|
||||
tools = create_cli_tools(permission_manager)
|
||||
|
||||
# Register each tool
|
||||
for tool in tools.values():
|
||||
tool.register(mcp_server)
|
||||
|
||||
return list(tools.values())
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UnifiedCLITool",
|
||||
"create_cli_tools",
|
||||
"register_cli_tools",
|
||||
]
|
||||
@@ -140,6 +140,8 @@ To make a batch call, provide the following:
|
||||
|
||||
Available tools in batch call:
|
||||
Tool: dispatch_agent,read,directory_tree,grep,grep_ast,run_command,notebook_read
|
||||
CLI Tools: claude,cc,codex,gemini,grok,openhands,oh,hanzo_dev,cline,aider
|
||||
AST/Symbols: ast,symbols (tree-sitter based code analysis)
|
||||
Not available: think,write,edit,multi_edit,notebook_edit
|
||||
"""
|
||||
|
||||
@@ -175,22 +177,30 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
|
||||
# Validate required parameters
|
||||
if not description:
|
||||
await tool_ctx.error("Parameter 'description' is required but was None or empty")
|
||||
await tool_ctx.error(
|
||||
"Parameter 'description' is required but was None or empty"
|
||||
)
|
||||
return "Error: Parameter 'description' is required but was None or empty"
|
||||
|
||||
if not invocations:
|
||||
await tool_ctx.error("Parameter 'invocations' is required but was None or empty")
|
||||
await tool_ctx.error(
|
||||
"Parameter 'invocations' is required but was None or empty"
|
||||
)
|
||||
return "Error: Parameter 'invocations' is required but was None or empty"
|
||||
|
||||
if not isinstance(invocations, list) or len(invocations) == 0:
|
||||
await tool_ctx.error("Parameter 'invocations' must be a non-empty list")
|
||||
return "Error: Parameter 'invocations' must be a non-empty list"
|
||||
|
||||
await tool_ctx.info(f"Executing batch operation: {description} ({len(invocations)} invocations)")
|
||||
await tool_ctx.info(
|
||||
f"Executing batch operation: {description} ({len(invocations)} invocations)"
|
||||
)
|
||||
|
||||
# Execute all tool invocations in parallel
|
||||
tasks: list[asyncio.Future[dict[str, Any]]] = []
|
||||
invocation_map: dict[asyncio.Future[dict[str, Any]], dict[str, Any]] = {} # Map task Future to invocation
|
||||
invocation_map: dict[asyncio.Future[dict[str, Any]], dict[str, Any]] = (
|
||||
{}
|
||||
) # Map task Future to invocation
|
||||
|
||||
for i, invocation in enumerate(invocations):
|
||||
# Extract tool name and input from invocation
|
||||
@@ -203,7 +213,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
await tool_ctx.error(error_message)
|
||||
# Add direct result for this invocation
|
||||
tasks.append(asyncio.Future())
|
||||
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
|
||||
tasks[-1].set_result(
|
||||
{"invocation": invocation, "result": f"Error: {error_message}"}
|
||||
)
|
||||
invocation_map[tasks[-1]] = invocation
|
||||
continue
|
||||
|
||||
@@ -213,7 +225,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
await tool_ctx.error(error_message)
|
||||
# Add direct result for this invocation
|
||||
tasks.append(asyncio.Future())
|
||||
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
|
||||
tasks[-1].set_result(
|
||||
{"invocation": invocation, "result": f"Error: {error_message}"}
|
||||
)
|
||||
invocation_map[tasks[-1]] = invocation
|
||||
continue
|
||||
|
||||
@@ -223,7 +237,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
await tool_ctx.info(f"Creating task for tool: {tool_name}")
|
||||
|
||||
# Create coroutine for this tool execution
|
||||
async def execute_tool(tool_obj: BaseTool, tool_name: str, tool_input: dict[str, Any]):
|
||||
async def execute_tool(
|
||||
tool_obj: BaseTool, tool_name: str, tool_input: dict[str, Any]
|
||||
):
|
||||
try:
|
||||
await tool_ctx.info(f"Executing tool: {tool_name}")
|
||||
result = await tool_obj.call(ctx, **tool_input)
|
||||
@@ -249,7 +265,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
await tool_ctx.error(error_message)
|
||||
# Add direct result for this invocation
|
||||
tasks.append(asyncio.Future())
|
||||
tasks[-1].set_result({"invocation": invocation, "result": f"Error: {error_message}"})
|
||||
tasks[-1].set_result(
|
||||
{"invocation": invocation, "result": f"Error: {error_message}"}
|
||||
)
|
||||
invocation_map[tasks[-1]] = invocation
|
||||
|
||||
# Wait for all tasks to complete
|
||||
@@ -266,7 +284,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
tool_name: str = invocation.get("tool_name", "unknown")
|
||||
error_message = f"Unexpected error in tool '{tool_name}': {str(e)}"
|
||||
await tool_ctx.error(error_message)
|
||||
results.append({"invocation": invocation, "result": f"Error: {error_message}"})
|
||||
results.append(
|
||||
{"invocation": invocation, "result": f"Error: {error_message}"}
|
||||
)
|
||||
|
||||
# Extract cursor if provided
|
||||
cursor = params.get("cursor")
|
||||
@@ -294,7 +314,9 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
)
|
||||
|
||||
# Create paginated response with token awareness
|
||||
paginated_response = create_paginated_response(formatted_results, cursor=cursor, use_token_limit=True)
|
||||
paginated_response = create_paginated_response(
|
||||
formatted_results, cursor=cursor, use_token_limit=True
|
||||
)
|
||||
|
||||
# Convert paginated response to string format for MCP
|
||||
if isinstance(paginated_response, dict) and "items" in paginated_response:
|
||||
@@ -304,9 +326,13 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
# Add header
|
||||
result_parts.append(f"=== Batch operation: {description} ===")
|
||||
result_parts.append(f"Total invocations: {len(invocations)}")
|
||||
result_parts.append(f"Showing results: {len(paginated_response['items'])} of {len(results)}")
|
||||
result_parts.append(
|
||||
f"Showing results: {len(paginated_response['items'])} of {len(results)}"
|
||||
)
|
||||
if paginated_response.get("hasMore"):
|
||||
result_parts.append(f"More results available - use cursor: {paginated_response.get('nextCursor')}")
|
||||
result_parts.append(
|
||||
f"More results available - use cursor: {paginated_response.get('nextCursor')}"
|
||||
)
|
||||
result_parts.append("")
|
||||
|
||||
# Format each result
|
||||
@@ -326,8 +352,12 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
|
||||
# If there's a next cursor, we need to preserve it in the response
|
||||
# For now, append it as a note at the end
|
||||
if paginated_response.get("hasMore") and paginated_response.get("nextCursor"):
|
||||
formatted_output += f"\n\n[To continue, use cursor: {paginated_response['nextCursor']}]"
|
||||
if paginated_response.get("hasMore") and paginated_response.get(
|
||||
"nextCursor"
|
||||
):
|
||||
formatted_output += (
|
||||
f"\n\n[To continue, use cursor: {paginated_response['nextCursor']}]"
|
||||
)
|
||||
|
||||
await tool_ctx.info(
|
||||
f"Batch operation '{description}' completed with {len(paginated_response['items'])} results"
|
||||
@@ -394,4 +424,6 @@ Not available: think,write,edit,multi_edit,notebook_edit
|
||||
cursor: Cursor,
|
||||
ctx: MCPContext,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, description=description, invocations=invocations, cursor=cursor)
|
||||
return await tool_self.call(
|
||||
ctx, description=description, invocations=invocations, cursor=cursor
|
||||
)
|
||||
|
||||
@@ -87,11 +87,17 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
|
||||
try:
|
||||
if action == "get":
|
||||
return await self._get_config(scope, setting_path, project_name, project_path)
|
||||
return await self._get_config(
|
||||
scope, setting_path, project_name, project_path
|
||||
)
|
||||
elif action == "set":
|
||||
return await self._set_config(scope, setting_path, value, project_name, project_path)
|
||||
return await self._set_config(
|
||||
scope, setting_path, value, project_name, project_path
|
||||
)
|
||||
elif action == "add_server":
|
||||
return await self._add_mcp_server(server_name, server_config, scope, project_name)
|
||||
return await self._add_mcp_server(
|
||||
server_name, server_config, scope, project_name
|
||||
)
|
||||
elif action == "remove_server":
|
||||
return await self._remove_mcp_server(server_name, scope, project_name)
|
||||
elif action == "enable_server":
|
||||
@@ -152,7 +158,10 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
"agent": settings.agent.__dict__,
|
||||
"vector_store": settings.vector_store.__dict__,
|
||||
"hub_enabled": settings.hub_enabled,
|
||||
"mcp_servers": {name: server.__dict__ for name, server in settings.mcp_servers.items()},
|
||||
"mcp_servers": {
|
||||
name: server.__dict__
|
||||
for name, server in settings.mcp_servers.items()
|
||||
},
|
||||
"current_project": settings.current_project,
|
||||
"projects": list(settings.projects.keys()),
|
||||
}
|
||||
@@ -230,7 +239,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
else:
|
||||
return f"Error: MCP server '{server_name}' already exists"
|
||||
|
||||
async def _remove_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
|
||||
async def _remove_mcp_server(
|
||||
self, server_name: Optional[str], scope: str, project_name: Optional[str]
|
||||
) -> str:
|
||||
"""Remove an MCP server."""
|
||||
if not server_name:
|
||||
return "Error: server_name is required"
|
||||
@@ -243,7 +254,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
else:
|
||||
return f"Error: MCP server '{server_name}' not found"
|
||||
|
||||
async def _enable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
|
||||
async def _enable_mcp_server(
|
||||
self, server_name: Optional[str], scope: str, project_name: Optional[str]
|
||||
) -> str:
|
||||
"""Enable an MCP server."""
|
||||
if not server_name:
|
||||
return "Error: server_name is required"
|
||||
@@ -256,7 +269,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
else:
|
||||
return f"Error: MCP server '{server_name}' not found"
|
||||
|
||||
async def _disable_mcp_server(self, server_name: Optional[str], scope: str, project_name: Optional[str]) -> str:
|
||||
async def _disable_mcp_server(
|
||||
self, server_name: Optional[str], scope: str, project_name: Optional[str]
|
||||
) -> str:
|
||||
"""Disable an MCP server."""
|
||||
if not server_name:
|
||||
return "Error: server_name is required"
|
||||
@@ -282,7 +297,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
else:
|
||||
return f"Error: MCP server '{server_name}' not found"
|
||||
|
||||
async def _add_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
|
||||
async def _add_project(
|
||||
self, project_name: Optional[str], project_path: Optional[str]
|
||||
) -> str:
|
||||
"""Add a project configuration."""
|
||||
if not project_path:
|
||||
return "Error: project_path is required"
|
||||
@@ -308,7 +325,9 @@ Automatically detects projects based on LLM.md files and manages .hanzo/ directo
|
||||
else:
|
||||
return f"Error: Project '{project_name}' already exists"
|
||||
|
||||
async def _set_current_project(self, project_name: Optional[str], project_path: Optional[str]) -> str:
|
||||
async def _set_current_project(
|
||||
self, project_name: Optional[str], project_path: Optional[str]
|
||||
) -> str:
|
||||
"""Set the current active project."""
|
||||
settings = load_settings()
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class ToolContext:
|
||||
"""
|
||||
return self._mcp_context.client_id
|
||||
|
||||
def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
|
||||
async def set_tool_info(
|
||||
self, tool_name: str, execution_id: str | None = None
|
||||
) -> None:
|
||||
"""Set information about the currently executing tool.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -153,7 +153,9 @@ Recommendations:
|
||||
|
||||
# Validate required analysis parameter
|
||||
if not analysis:
|
||||
await tool_ctx.error("Parameter 'analysis' is required but was None or empty")
|
||||
await tool_ctx.error(
|
||||
"Parameter 'analysis' is required but was None or empty"
|
||||
)
|
||||
return "Error: Parameter 'analysis' is required but was None or empty"
|
||||
|
||||
if analysis.strip() == "":
|
||||
|
||||
@@ -120,7 +120,9 @@ def _is_valid_context(ctx: Any) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def mcp_tool(server: Any, name: str | None = None, description: str | None = None) -> Callable[[F], F]:
|
||||
def mcp_tool(
|
||||
server: Any, name: str | None = None, description: str | None = None
|
||||
) -> Callable[[F], F]:
|
||||
"""Enhanced MCP tool decorator that includes context normalization.
|
||||
|
||||
This decorator combines the standard MCP tool registration with
|
||||
@@ -190,7 +192,9 @@ def create_tool_handler(server: Any, tool: Any) -> Callable[[], None]:
|
||||
# Apply context normalization
|
||||
normalized = with_context_normalization(func)
|
||||
# Apply original decorator
|
||||
return original_tool_decorator(name=name, description=description)(normalized)
|
||||
return original_tool_decorator(name=name, description=description)(
|
||||
normalized
|
||||
)
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
@@ -85,7 +85,9 @@ class AutoRegisterTool(BaseTool, ABC):
|
||||
params = list(sig.parameters.items())
|
||||
|
||||
# Skip 'self' and 'ctx' parameters
|
||||
tool_params = [(name, param) for name, param in params if name not in ("self", "ctx")]
|
||||
tool_params = [
|
||||
(name, param) for name, param in params if name not in ("self", "ctx")
|
||||
]
|
||||
|
||||
# Create the handler function dynamically
|
||||
async def handler(ctx: MCPContext, **kwargs: Any) -> Any:
|
||||
@@ -96,4 +98,6 @@ class AutoRegisterTool(BaseTool, ABC):
|
||||
normalized_handler = with_context_normalization(handler)
|
||||
|
||||
# Register with the server
|
||||
mcp_server.tool(name=self.name, description=self.description)(normalized_handler)
|
||||
mcp_server.tool(name=self.name, description=self.description)(
|
||||
normalized_handler
|
||||
)
|
||||
|
||||
@@ -143,7 +143,9 @@ class FastMCPPaginator(Generic[T]):
|
||||
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
||||
|
||||
# Use provided page size or default
|
||||
actual_page_size = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
|
||||
actual_page_size = min(
|
||||
page_size or cursor_data.page_size or self.page_size, self.max_page_size
|
||||
)
|
||||
|
||||
# Get starting position
|
||||
start_idx = cursor_data.offset
|
||||
@@ -205,7 +207,9 @@ class FastMCPPaginator(Generic[T]):
|
||||
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
||||
|
||||
# Determine page size
|
||||
limit = min(page_size or cursor_data.page_size or self.page_size, self.max_page_size)
|
||||
limit = min(
|
||||
page_size or cursor_data.page_size or self.page_size, self.max_page_size
|
||||
)
|
||||
|
||||
# Execute query with cursor position
|
||||
results = query_func(
|
||||
@@ -254,7 +258,9 @@ class TokenAwarePaginator:
|
||||
self.max_tokens = max_tokens
|
||||
self.paginator = FastMCPPaginator()
|
||||
|
||||
def paginate_by_tokens(self, items: List[Any], cursor: Optional[str] = None, estimate_func=None) -> Dict[str, Any]:
|
||||
def paginate_by_tokens(
|
||||
self, items: List[Any], cursor: Optional[str] = None, estimate_func=None
|
||||
) -> Dict[str, Any]:
|
||||
"""Paginate items based on token count.
|
||||
|
||||
Args:
|
||||
@@ -269,7 +275,9 @@ class TokenAwarePaginator:
|
||||
|
||||
# Default token estimation
|
||||
if not estimate_func:
|
||||
estimate_func = lambda x: estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
|
||||
estimate_func = lambda x: estimate_tokens(
|
||||
json.dumps(x) if not isinstance(x, str) else x
|
||||
)
|
||||
|
||||
# Parse cursor
|
||||
cursor_data = CursorData.from_cursor(cursor) if cursor else CursorData()
|
||||
|
||||
@@ -42,7 +42,9 @@ class ForgivingEditHelper:
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def find_fuzzy_match(haystack: str, needle: str, threshold: float = 0.85) -> Optional[Tuple[int, int, str]]:
|
||||
def find_fuzzy_match(
|
||||
haystack: str, needle: str, threshold: float = 0.85
|
||||
) -> Optional[Tuple[int, int, str]]:
|
||||
"""Find a fuzzy match for the needle in the haystack.
|
||||
|
||||
Args:
|
||||
@@ -79,9 +81,13 @@ class ForgivingEditHelper:
|
||||
|
||||
# Find end position by counting lines in needle
|
||||
needle_lines = norm_needle.count("\n") + 1
|
||||
end_pos = sum(len(line) + 1 for line in original_lines[: lines_before + needle_lines])
|
||||
end_pos = sum(
|
||||
len(line) + 1 for line in original_lines[: lines_before + needle_lines]
|
||||
)
|
||||
|
||||
matched = "\n".join(original_lines[lines_before : lines_before + needle_lines])
|
||||
matched = "\n".join(
|
||||
original_lines[lines_before : lines_before + needle_lines]
|
||||
)
|
||||
return (start_pos, end_pos - 1, matched)
|
||||
|
||||
# Try fuzzy matching on lines
|
||||
@@ -113,7 +119,9 @@ class ForgivingEditHelper:
|
||||
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
||||
needle_norm = ForgivingEditHelper.normalize_whitespace(needle)
|
||||
|
||||
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
|
||||
ratio = difflib.SequenceMatcher(
|
||||
None, candidate_norm, needle_norm
|
||||
).ratio()
|
||||
|
||||
if ratio >= threshold:
|
||||
start_pos = sum(len(l) + 1 for l in haystack_lines[:i])
|
||||
@@ -122,7 +130,9 @@ class ForgivingEditHelper:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def suggest_matches(haystack: str, needle: str, max_suggestions: int = 3) -> List[Tuple[float, str]]:
|
||||
def suggest_matches(
|
||||
haystack: str, needle: str, max_suggestions: int = 3
|
||||
) -> List[Tuple[float, str]]:
|
||||
"""Suggest possible matches when exact match fails.
|
||||
|
||||
Args:
|
||||
@@ -144,7 +154,9 @@ class ForgivingEditHelper:
|
||||
for line in haystack.split("\n"):
|
||||
if line.strip(): # Skip empty lines
|
||||
line_norm = ForgivingEditHelper.normalize_whitespace(line)
|
||||
ratio = difflib.SequenceMatcher(None, line_norm, needle_norm).ratio()
|
||||
ratio = difflib.SequenceMatcher(
|
||||
None, line_norm, needle_norm
|
||||
).ratio()
|
||||
if ratio > 0.5: # Only reasonably similar lines
|
||||
suggestions.append((ratio, line))
|
||||
|
||||
@@ -158,7 +170,9 @@ class ForgivingEditHelper:
|
||||
candidate = "\n".join(candidate_lines)
|
||||
candidate_norm = ForgivingEditHelper.normalize_whitespace(candidate)
|
||||
|
||||
ratio = difflib.SequenceMatcher(None, candidate_norm, needle_norm).ratio()
|
||||
ratio = difflib.SequenceMatcher(
|
||||
None, candidate_norm, needle_norm
|
||||
).ratio()
|
||||
if ratio > 0.5:
|
||||
suggestions.append((ratio, candidate))
|
||||
|
||||
@@ -167,7 +181,9 @@ class ForgivingEditHelper:
|
||||
return suggestions[:max_suggestions]
|
||||
|
||||
@staticmethod
|
||||
def create_edit_suggestion(file_content: str, old_string: str, new_string: str) -> dict:
|
||||
def create_edit_suggestion(
|
||||
file_content: str, old_string: str, new_string: str
|
||||
) -> dict:
|
||||
"""Create a helpful edit suggestion when match fails.
|
||||
|
||||
Args:
|
||||
@@ -196,7 +212,10 @@ class ForgivingEditHelper:
|
||||
if suggestions:
|
||||
return {
|
||||
"error": "Could not find exact or fuzzy match",
|
||||
"suggestions": [{"similarity": f"{score:.0%}", "text": text} for score, text in suggestions],
|
||||
"suggestions": [
|
||||
{"similarity": f"{score:.0%}", "text": text}
|
||||
for score, text in suggestions
|
||||
],
|
||||
"hint": "Try using one of these suggestions as old_string",
|
||||
}
|
||||
|
||||
|
||||
@@ -94,7 +94,11 @@ def register_default_modes():
|
||||
def get_mode_from_env() -> Optional[str]:
|
||||
"""Get mode name from environment variables."""
|
||||
# Check for HANZO_MODE, PERSONALITY, or MODE env vars
|
||||
return os.environ.get("HANZO_MODE") or os.environ.get("PERSONALITY") or os.environ.get("MODE")
|
||||
return (
|
||||
os.environ.get("HANZO_MODE")
|
||||
or os.environ.get("PERSONALITY")
|
||||
or os.environ.get("MODE")
|
||||
)
|
||||
|
||||
|
||||
def activate_mode_from_env():
|
||||
|
||||
@@ -68,7 +68,11 @@ class PaginatedBaseTool(BaseTool):
|
||||
if cursor:
|
||||
# For continuation, check if we have cached results
|
||||
cursor_data = CursorManager.parse_cursor(cursor)
|
||||
if cursor_data and "tool" in cursor_data and cursor_data["tool"] != self.name:
|
||||
if (
|
||||
cursor_data
|
||||
and "tool" in cursor_data
|
||||
and cursor_data["tool"] != self.name
|
||||
):
|
||||
return {"error": "Cursor is for a different tool", "code": -32602}
|
||||
|
||||
# Execute the tool
|
||||
@@ -86,11 +90,15 @@ class PaginatedBaseTool(BaseTool):
|
||||
if isinstance(paginated_result, dict) and "nextCursor" in paginated_result:
|
||||
# Enhance the cursor with tool information
|
||||
if "nextCursor" in paginated_result:
|
||||
cursor_data = CursorManager.parse_cursor(paginated_result["nextCursor"])
|
||||
cursor_data = CursorManager.parse_cursor(
|
||||
paginated_result["nextCursor"]
|
||||
)
|
||||
if cursor_data:
|
||||
cursor_data["tool"] = self.name
|
||||
cursor_data["params"] = params # Store params for continuation
|
||||
paginated_result["nextCursor"] = CursorManager.create_cursor(cursor_data)
|
||||
paginated_result["nextCursor"] = CursorManager.create_cursor(
|
||||
cursor_data
|
||||
)
|
||||
|
||||
return paginated_result
|
||||
else:
|
||||
|
||||
@@ -35,21 +35,31 @@ class AutoPaginatedResponse:
|
||||
"""
|
||||
# Handle different content types
|
||||
if isinstance(content, str):
|
||||
return AutoPaginatedResponse._handle_string_response(content, cursor, max_tokens)
|
||||
return AutoPaginatedResponse._handle_string_response(
|
||||
content, cursor, max_tokens
|
||||
)
|
||||
elif isinstance(content, list):
|
||||
return AutoPaginatedResponse._handle_list_response(content, cursor, max_tokens)
|
||||
return AutoPaginatedResponse._handle_list_response(
|
||||
content, cursor, max_tokens
|
||||
)
|
||||
elif isinstance(content, dict):
|
||||
# If dict already has pagination info, return as-is
|
||||
if "nextCursor" in content or "cursor" in content:
|
||||
return content
|
||||
# Otherwise treat as single item
|
||||
return AutoPaginatedResponse._handle_dict_response(content, cursor, max_tokens)
|
||||
return AutoPaginatedResponse._handle_dict_response(
|
||||
content, cursor, max_tokens
|
||||
)
|
||||
else:
|
||||
# Convert to string for other types
|
||||
return AutoPaginatedResponse._handle_string_response(str(content), cursor, max_tokens)
|
||||
return AutoPaginatedResponse._handle_string_response(
|
||||
str(content), cursor, max_tokens
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _handle_string_response(content: str, cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
|
||||
def _handle_string_response(
|
||||
content: str, cursor: Optional[str], max_tokens: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle pagination for string responses."""
|
||||
# Parse cursor to get offset
|
||||
offset = 0
|
||||
@@ -114,7 +124,9 @@ class AutoPaginatedResponse:
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _handle_list_response(items: List[Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
|
||||
def _handle_list_response(
|
||||
items: List[Any], cursor: Optional[str], max_tokens: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle pagination for list responses."""
|
||||
# Parse cursor to get offset
|
||||
offset = 0
|
||||
@@ -152,7 +164,9 @@ class AutoPaginatedResponse:
|
||||
truncated = item[:5000] + "... [truncated]"
|
||||
result_items.append(truncated)
|
||||
else:
|
||||
result_items.append({"error": "Item too large", "index": item_index})
|
||||
result_items.append(
|
||||
{"error": "Item too large", "index": item_index}
|
||||
)
|
||||
item_index += 1
|
||||
break
|
||||
|
||||
@@ -185,7 +199,9 @@ class AutoPaginatedResponse:
|
||||
return response
|
||||
|
||||
@staticmethod
|
||||
def _handle_dict_response(content: Dict[str, Any], cursor: Optional[str], max_tokens: int) -> Dict[str, Any]:
|
||||
def _handle_dict_response(
|
||||
content: Dict[str, Any], cursor: Optional[str], max_tokens: int
|
||||
) -> Dict[str, Any]:
|
||||
"""Handle pagination for dict responses."""
|
||||
# For dicts, check if it's too large as-is
|
||||
content_str = json.dumps(content, indent=2)
|
||||
@@ -264,13 +280,17 @@ def paginate_if_needed(
|
||||
Original response if small enough, otherwise paginated dict
|
||||
"""
|
||||
# Quick check - if response is already paginated, return as-is
|
||||
if isinstance(response, dict) and ("nextCursor" in response or "pagination_info" in response):
|
||||
if isinstance(response, dict) and (
|
||||
"nextCursor" in response or "pagination_info" in response
|
||||
):
|
||||
return response
|
||||
|
||||
# For small responses, don't paginate unless forced
|
||||
if not force_pagination:
|
||||
try:
|
||||
response_str = json.dumps(response) if not isinstance(response, str) else response
|
||||
response_str = (
|
||||
json.dumps(response) if not isinstance(response, str) else response
|
||||
)
|
||||
if len(response_str) < 10000: # Quick heuristic
|
||||
return response
|
||||
except Exception:
|
||||
|
||||
@@ -154,7 +154,9 @@ class StreamPaginator(Generic[T]):
|
||||
"""
|
||||
self.page_size = page_size
|
||||
|
||||
def paginate_stream(self, stream_generator, cursor: Optional[str] = None) -> PaginatedResponse[T]:
|
||||
def paginate_stream(
|
||||
self, stream_generator, cursor: Optional[str] = None
|
||||
) -> PaginatedResponse[T]:
|
||||
"""Paginate results from a stream/generator.
|
||||
|
||||
Args:
|
||||
@@ -183,14 +185,18 @@ class StreamPaginator(Generic[T]):
|
||||
items.append(item)
|
||||
if len(items) >= self.page_size:
|
||||
# We have a full page, create cursor for next page
|
||||
next_cursor = CursorManager.create_cursor({"skip": skip_count + len(items)})
|
||||
next_cursor = CursorManager.create_cursor(
|
||||
{"skip": skip_count + len(items)}
|
||||
)
|
||||
return PaginatedResponse(items=items, next_cursor=next_cursor)
|
||||
|
||||
# No more items
|
||||
return PaginatedResponse(items=items, next_cursor=None)
|
||||
|
||||
|
||||
def paginate_list(items: List[T], cursor: Optional[str] = None, page_size: int = 100) -> PaginatedResponse[T]:
|
||||
def paginate_list(
|
||||
items: List[T], cursor: Optional[str] = None, page_size: int = 100
|
||||
) -> PaginatedResponse[T]:
|
||||
"""Convenience function to paginate a list.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -225,9 +225,13 @@ class PermissibleOperation:
|
||||
"""
|
||||
self.permission_manager: PermissionManager = permission_manager
|
||||
self.operation: str = operation
|
||||
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = get_path_fn
|
||||
self.get_path_fn: Callable[[list[Any], dict[str, Any]], str] | None = (
|
||||
get_path_fn
|
||||
)
|
||||
|
||||
def __call__(self, func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
|
||||
def __call__(
|
||||
self, func: Callable[..., Awaitable[T]]
|
||||
) -> Callable[..., Awaitable[T]]:
|
||||
"""Decorate the function.
|
||||
|
||||
Args:
|
||||
@@ -251,7 +255,9 @@ class PermissibleOperation:
|
||||
|
||||
# Check permission
|
||||
if not self.permission_manager.is_path_allowed(path):
|
||||
raise PermissionError(f"Operation '{self.operation}' not allowed for path: {path}")
|
||||
raise PermissionError(
|
||||
f"Operation '{self.operation}' not allowed for path: {path}"
|
||||
)
|
||||
|
||||
# Call the function
|
||||
return await func(*args, **kwargs)
|
||||
|
||||
@@ -88,7 +88,10 @@ personalities = [
|
||||
programmer="Guido van Rossum",
|
||||
description="Python's BDFL - readability counts",
|
||||
philosophy="There should be one-- and preferably only one --obvious way to do it.",
|
||||
tools=ESSENTIAL_TOOLS + ["uvx", "jupyter", "multi_edit", "symbols", "rules"] + AI_TOOLS + SEARCH_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["uvx", "jupyter", "multi_edit", "symbols", "rules"]
|
||||
+ AI_TOOLS
|
||||
+ SEARCH_TOOLS,
|
||||
environment={"PYTHONPATH": ".", "PYTEST_ARGS": "-xvs"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -104,7 +107,10 @@ personalities = [
|
||||
programmer="Brendan Eich",
|
||||
description="JavaScript creator - dynamic and flexible",
|
||||
philosophy="Always bet on JS.",
|
||||
tools=ESSENTIAL_TOOLS + ["npx", "watch", "symbols", "todo", "rules"] + BUILD_TOOLS + SEARCH_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["npx", "watch", "symbols", "todo", "rules"]
|
||||
+ BUILD_TOOLS
|
||||
+ SEARCH_TOOLS,
|
||||
environment={"NODE_ENV": "development", "NPM_CONFIG_LOGLEVEL": "warn"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -120,7 +126,10 @@ personalities = [
|
||||
programmer="Bjarne Stroustrup",
|
||||
description="C++ creator - zero-overhead abstractions",
|
||||
philosophy="C++ is designed to allow you to express ideas.",
|
||||
tools=ESSENTIAL_TOOLS + ["symbols", "multi_edit", "content_replace"] + UNIX_TOOLS + BUILD_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["symbols", "multi_edit", "content_replace"]
|
||||
+ UNIX_TOOLS
|
||||
+ BUILD_TOOLS,
|
||||
environment={"CXX": "g++", "CXXFLAGS": "-std=c++20 -Wall"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -136,7 +145,10 @@ personalities = [
|
||||
programmer="Anders Hejlsberg",
|
||||
description="TypeScript/C# creator - type safety matters",
|
||||
philosophy="TypeScript is JavaScript that scales.",
|
||||
tools=ESSENTIAL_TOOLS + ["npx", "symbols", "watch", "rules"] + BUILD_TOOLS + SEARCH_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["npx", "symbols", "watch", "rules"]
|
||||
+ BUILD_TOOLS
|
||||
+ SEARCH_TOOLS,
|
||||
environment={"TYPESCRIPT_VERSION": "5.0"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -169,7 +181,9 @@ personalities = [
|
||||
programmer="Linus Torvalds",
|
||||
description="Linux & Git creator - pragmatic excellence",
|
||||
philosophy="Talk is cheap. Show me the code.",
|
||||
tools=ESSENTIAL_TOOLS + ["git_search", "diff", "content_replace", "critic"] + UNIX_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["git_search", "diff", "content_replace", "critic"]
|
||||
+ UNIX_TOOLS,
|
||||
environment={"KERNEL_VERSION": "6.0", "GIT_AUTHOR_NAME": "Linus Torvalds"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -177,7 +191,10 @@ personalities = [
|
||||
programmer="Rob Pike",
|
||||
description="Go creator - simplicity and concurrency",
|
||||
philosophy="A little copying is better than a little dependency.",
|
||||
tools=ESSENTIAL_TOOLS + ["symbols", "batch", "process"] + UNIX_TOOLS + BUILD_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["symbols", "batch", "process"]
|
||||
+ UNIX_TOOLS
|
||||
+ BUILD_TOOLS,
|
||||
environment={"GOPATH": "~/go", "GO111MODULE": "on"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -225,7 +242,9 @@ personalities = [
|
||||
programmer="Graydon Hoare",
|
||||
description="Rust creator - memory safety without GC",
|
||||
philosophy="Memory safety without garbage collection, concurrency without data races.",
|
||||
tools=ESSENTIAL_TOOLS + ["symbols", "multi_edit", "critic", "todo"] + BUILD_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["symbols", "multi_edit", "critic", "todo"]
|
||||
+ BUILD_TOOLS,
|
||||
environment={"RUST_BACKTRACE": "1", "CARGO_HOME": "~/.cargo"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -852,7 +871,10 @@ personalities = [
|
||||
programmer="Data Scientist",
|
||||
description="Analyze all the things",
|
||||
philosophy="In God we trust. All others must bring data.",
|
||||
tools=ESSENTIAL_TOOLS + ["jupyter", "sql_query", "stats"] + VECTOR_TOOLS + AI_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["jupyter", "sql_query", "stats"]
|
||||
+ VECTOR_TOOLS
|
||||
+ AI_TOOLS,
|
||||
environment={"JUPYTER_THEME": "dark"},
|
||||
),
|
||||
ToolPersonality(
|
||||
@@ -884,7 +906,10 @@ personalities = [
|
||||
programmer="Startup Founder",
|
||||
description="Move fast and fix things",
|
||||
philosophy="Done is better than perfect.",
|
||||
tools=ESSENTIAL_TOOLS + ["todo", "agent", "consensus"] + BUILD_TOOLS + DATABASE_TOOLS,
|
||||
tools=ESSENTIAL_TOOLS
|
||||
+ ["todo", "agent", "consensus"]
|
||||
+ BUILD_TOOLS
|
||||
+ DATABASE_TOOLS,
|
||||
environment={"STARTUP_MODE": "hustle"},
|
||||
),
|
||||
ToolPersonality(
|
||||
|
||||
@@ -88,7 +88,12 @@ class PluginLoader:
|
||||
|
||||
# Find tool classes
|
||||
for _name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, BaseTool)
|
||||
and obj != BaseTool
|
||||
and hasattr(obj, "name")
|
||||
):
|
||||
# Load metadata if available
|
||||
metadata = None
|
||||
metadata_file = file_path.with_suffix(".json")
|
||||
@@ -129,8 +134,15 @@ class PluginLoader:
|
||||
else:
|
||||
# Search for tool classes
|
||||
for _name, obj in inspect.getmembers(module):
|
||||
if inspect.isclass(obj) and issubclass(obj, BaseTool) and obj != BaseTool and hasattr(obj, "name"):
|
||||
plugin = ToolPlugin(name=obj.name, tool_class=obj, source_path=package_dir)
|
||||
if (
|
||||
inspect.isclass(obj)
|
||||
and issubclass(obj, BaseTool)
|
||||
and obj != BaseTool
|
||||
and hasattr(obj, "name")
|
||||
):
|
||||
plugin = ToolPlugin(
|
||||
name=obj.name, tool_class=obj, source_path=package_dir
|
||||
)
|
||||
self.plugins[obj.name] = plugin
|
||||
finally:
|
||||
# Remove from path
|
||||
|
||||
@@ -97,7 +97,9 @@ Example:
|
||||
memory_used_gb = memory.used / (1024**3)
|
||||
memory_total_gb = memory.total / (1024**3)
|
||||
memory_percent = memory.percent
|
||||
output.append(f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)")
|
||||
output.append(
|
||||
f"Memory: {memory_used_gb:.1f}/{memory_total_gb:.1f} GB ({memory_percent}%)"
|
||||
)
|
||||
if memory_percent > 90:
|
||||
warnings.append(f"⚠️ HIGH MEMORY USAGE: {memory_percent}%")
|
||||
|
||||
@@ -107,10 +109,14 @@ Example:
|
||||
disk_total_gb = disk.total / (1024**3)
|
||||
disk_percent = disk.percent
|
||||
disk_free_gb = disk.free / (1024**3)
|
||||
output.append(f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)")
|
||||
output.append(
|
||||
f"Disk: {disk_used_gb:.1f}/{disk_total_gb:.1f} GB ({disk_percent}%)"
|
||||
)
|
||||
output.append(f"Free Space: {disk_free_gb:.1f} GB")
|
||||
if disk_percent > 90:
|
||||
warnings.append(f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)")
|
||||
warnings.append(
|
||||
f"⚠️ LOW DISK SPACE: Only {disk_free_gb:.1f} GB free ({100 - disk_percent:.1f}% remaining)"
|
||||
)
|
||||
|
||||
output.append("")
|
||||
|
||||
@@ -167,7 +173,9 @@ Example:
|
||||
size = db_file.stat().st_size
|
||||
total_db_size += size
|
||||
|
||||
output.append(f"Total Database Size: {total_db_size / (1024**2):.1f} MB")
|
||||
output.append(
|
||||
f"Total Database Size: {total_db_size / (1024**2):.1f} MB"
|
||||
)
|
||||
output.append(f"Active Projects: {len(self.db_manager.projects)}")
|
||||
|
||||
# List largest databases
|
||||
@@ -193,7 +201,9 @@ Example:
|
||||
output.append("=== MCP Servers ===")
|
||||
mcp_servers = McpAddTool.get_servers()
|
||||
if mcp_servers:
|
||||
running_mcp = sum(1 for s in mcp_servers.values() if s.get("status") == "running")
|
||||
running_mcp = sum(
|
||||
1 for s in mcp_servers.values() if s.get("status") == "running"
|
||||
)
|
||||
total_mcp_tools = sum(len(s.get("tools", [])) for s in mcp_servers.values())
|
||||
|
||||
output.append(f"Total Servers: {len(mcp_servers)}")
|
||||
@@ -215,7 +225,9 @@ Example:
|
||||
output.append(f"Log Files: {log_count} ({log_size / (1024**2):.1f} MB)")
|
||||
|
||||
if log_size > 100 * 1024**2: # > 100MB
|
||||
warnings.append(f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB")
|
||||
warnings.append(
|
||||
f"⚠️ Large log directory: {log_size / (1024**2):.1f} MB"
|
||||
)
|
||||
|
||||
# Config directory
|
||||
config_dir = Path.home() / ".hanzo" / "mcp"
|
||||
|
||||
@@ -115,7 +115,9 @@ Feature Implementation Planning
|
||||
|
||||
# Validate required thought parameter
|
||||
if not thought:
|
||||
await tool_ctx.error("Parameter 'thought' is required but was None or empty")
|
||||
await tool_ctx.error(
|
||||
"Parameter 'thought' is required but was None or empty"
|
||||
)
|
||||
return "Error: Parameter 'thought' is required but was None or empty"
|
||||
|
||||
if thought.strip() == "":
|
||||
|
||||
@@ -125,7 +125,9 @@ Use 'tool_enable' to re-enable disabled tools.
|
||||
]
|
||||
|
||||
if not persist:
|
||||
output.append("\nNote: This change is temporary and will be lost on restart.")
|
||||
output.append(
|
||||
"\nNote: This change is temporary and will be lost on restart."
|
||||
)
|
||||
|
||||
# Warn about commonly used tools
|
||||
common_tools = {"grep", "read", "write", "bash", "edit"}
|
||||
@@ -135,7 +137,9 @@ Use 'tool_enable' to re-enable disabled tools.
|
||||
)
|
||||
|
||||
# Count disabled tools
|
||||
disabled_count = sum(1 for enabled in ToolEnableTool._tool_states.values() if not enabled)
|
||||
disabled_count = sum(
|
||||
1 for enabled in ToolEnableTool._tool_states.values() if not enabled
|
||||
)
|
||||
output.append(f"\nTotal disabled tools: {disabled_count}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
@@ -205,7 +205,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
|
||||
|
||||
# Iterate through categories
|
||||
categories = (
|
||||
[category_filter] if category_filter and category_filter in self.TOOL_INFO else self.TOOL_INFO.keys()
|
||||
[category_filter]
|
||||
if category_filter and category_filter in self.TOOL_INFO
|
||||
else self.TOOL_INFO.keys()
|
||||
)
|
||||
|
||||
for category in categories:
|
||||
@@ -240,7 +242,9 @@ Use 'tool_enable' and 'tool_disable' to change tool status.
|
||||
max_name_len = max(len(name) for name, _, _ in category_shown)
|
||||
|
||||
for tool_name, description, status in category_shown:
|
||||
output.append(f"{status} {tool_name.ljust(max_name_len)} - {description}")
|
||||
output.append(
|
||||
f"{status} {tool_name.ljust(max_name_len)} - {description}"
|
||||
)
|
||||
|
||||
output.append("")
|
||||
|
||||
|
||||
@@ -32,7 +32,9 @@ class ValidationResult:
|
||||
return not self.is_valid
|
||||
|
||||
|
||||
def validate_path_parameter(path: str | None, parameter_name: str = "path") -> ValidationResult:
|
||||
def validate_path_parameter(
|
||||
path: str | None, parameter_name: str = "path"
|
||||
) -> ValidationResult:
|
||||
"""Validate a path parameter.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -4,10 +4,12 @@ Git-style config tool for managing settings.
|
||||
"""
|
||||
|
||||
from typing import Unpack, Optional, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_mcp.config import load_settings, save_settings
|
||||
from hanzo_mcp.tools.common.base import BaseTool
|
||||
from hanzo_mcp.tools.common.permissions import PermissionManager
|
||||
from hanzo_mcp.tools.config.index_config import IndexScope, IndexConfig
|
||||
@@ -24,7 +26,7 @@ Action = Annotated[
|
||||
Key = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Configuration key (e.g., index.scope, vector.enabled)",
|
||||
description="Configuration key (e.g., tools.write.enabled, enabled_tools.write, index.scope)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
@@ -64,6 +66,15 @@ class ConfigParams(TypedDict, total=False):
|
||||
path: Optional[str]
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> Optional[bool]:
|
||||
v = value.strip().lower()
|
||||
if v in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if v in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@final
|
||||
class ConfigTool(BaseTool):
|
||||
"""Git-style configuration management tool."""
|
||||
@@ -88,6 +99,7 @@ class ConfigTool(BaseTool):
|
||||
Usage:
|
||||
config index.scope
|
||||
config --action set index.scope project
|
||||
config --action set tools.write.enabled false
|
||||
config --action list
|
||||
config --action toggle index.scope --path ./project"""
|
||||
|
||||
@@ -119,7 +131,9 @@ config --action toggle index.scope --path ./project"""
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: get, set, list, toggle"
|
||||
|
||||
async def _handle_get(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
|
||||
async def _handle_get(
|
||||
self, key: Optional[str], scope: str, path: Optional[str], tool_ctx
|
||||
) -> str:
|
||||
"""Get configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for get action"
|
||||
@@ -129,7 +143,22 @@ config --action toggle index.scope --path ./project"""
|
||||
current_scope = self.index_config.get_scope(path)
|
||||
return f"index.scope={current_scope.value}"
|
||||
|
||||
# Handle tool-specific settings
|
||||
# tools.<name>.enabled → enabled_tools lookup
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
return f"{key}={settings.is_tool_enabled(tool_name)}"
|
||||
|
||||
# enabled_tools.<name>
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
val = settings.enabled_tools.get(tool_name)
|
||||
return f"{key}={val if val is not None else 'unset'}"
|
||||
|
||||
# Indexing (legacy) per-tool setting: <tool>.enabled
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
@@ -138,6 +167,17 @@ config --action toggle index.scope --path ./project"""
|
||||
|
||||
return f"Unknown key: {key}"
|
||||
|
||||
def _save_project_settings(self, settings, project_dir: Optional[str]) -> Path:
|
||||
"""Save to project config if path provided; else global."""
|
||||
if project_dir:
|
||||
project_path = Path(project_dir)
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
cfg = project_path / ".hanzo-mcp.json"
|
||||
cfg.write_text(__import__("json").dumps(settings.__dict__ if hasattr(settings, "__dict__") else {}, indent=2))
|
||||
return cfg
|
||||
# Fallback to global handler
|
||||
return save_settings(settings, global_config=True)
|
||||
|
||||
async def _handle_set(
|
||||
self,
|
||||
key: Optional[str],
|
||||
@@ -149,25 +189,68 @@ config --action toggle index.scope --path ./project"""
|
||||
"""Set configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for set action"
|
||||
if not value:
|
||||
if value is None:
|
||||
return "Error: value required for set action"
|
||||
|
||||
# Handle index scope
|
||||
if key == "index.scope":
|
||||
try:
|
||||
new_scope = IndexScope(value)
|
||||
self.index_config.set_scope(new_scope, path if scope == "local" else None)
|
||||
self.index_config.set_scope(
|
||||
new_scope, path if scope == "local" else None
|
||||
)
|
||||
return f"Set {key}={value} ({'project' if path else 'global'})"
|
||||
except ValueError:
|
||||
return f"Error: Invalid scope value '{value}'. Valid: project, global, auto"
|
||||
|
||||
# Handle tool-specific settings
|
||||
# tools.<name>.enabled → enabled_tools mapping
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
et = dict(settings.enabled_tools)
|
||||
et[tool_name] = parsed
|
||||
settings.enabled_tools = et
|
||||
# Save
|
||||
if scope == "local" and path:
|
||||
# Write a project .hanzo-mcp.json adjacent to the path
|
||||
# Note: save_settings(local) saves to CWD; we target specific path here
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Set {key}={parsed} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Set {key}={parsed} (global: {out})"
|
||||
|
||||
# enabled_tools.<name>
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
et = dict(settings.enabled_tools)
|
||||
et[tool_name] = parsed
|
||||
settings.enabled_tools = et
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Set {key}={parsed} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Set {key}={parsed} (global: {out})"
|
||||
|
||||
# Indexing (legacy) per-tool setting: <tool>.enabled (search indexers)
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
enabled = value.lower() in ["true", "yes", "1", "on"]
|
||||
self.index_config.set_indexing_enabled(tool, enabled)
|
||||
return f"Set {key}={enabled}"
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
self.index_config.set_indexing_enabled(tool, parsed)
|
||||
return f"Set {key}={parsed}"
|
||||
|
||||
return f"Unknown key: {key}"
|
||||
|
||||
@@ -184,25 +267,62 @@ config --action toggle index.scope --path ./project"""
|
||||
|
||||
output.append(f"\nProjects with custom config: {status['project_count']}")
|
||||
|
||||
output.append("\nTool settings:")
|
||||
output.append("\nTool settings (indexing):")
|
||||
for tool, settings in status["tools"].items():
|
||||
output.append(f" {tool}:")
|
||||
output.append(f" enabled: {settings['enabled']}")
|
||||
output.append(f" per_project: {settings['per_project']}")
|
||||
|
||||
# Also show enabled_tools snapshot
|
||||
settings_snapshot = load_settings(project_dir=path if scope == "local" else None)
|
||||
output.append("\nEnabled tools (execution):")
|
||||
for tool_name, enabled in sorted(settings_snapshot.enabled_tools.items()):
|
||||
output.append(f" {tool_name}: {enabled}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
async def _handle_toggle(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
|
||||
async def _handle_toggle(
|
||||
self, key: Optional[str], scope: str, path: Optional[str], tool_ctx
|
||||
) -> str:
|
||||
"""Toggle configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for toggle action"
|
||||
|
||||
# Handle index scope toggle
|
||||
if key == "index.scope":
|
||||
new_scope = self.index_config.toggle_scope(path if scope == "local" else None)
|
||||
new_scope = self.index_config.toggle_scope(
|
||||
path if scope == "local" else None
|
||||
)
|
||||
return f"Toggled index.scope to {new_scope.value}"
|
||||
|
||||
# Handle tool enable/disable toggle
|
||||
# Handle execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
current = bool(settings.enabled_tools.get(tool_name, True))
|
||||
settings.enabled_tools[tool_name] = not current
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Toggled {key} to {not current} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Toggled {key} to {not current} (global: {out})"
|
||||
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
current = bool(settings.enabled_tools.get(tool_name, True))
|
||||
settings.enabled_tools[tool_name] = not current
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Toggled {key} to {not current} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Toggled {key} to {not current} (global: {out})"
|
||||
|
||||
# Handle indexing toggles (legacy)
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
|
||||
@@ -99,7 +99,9 @@ class IndexConfig:
|
||||
if project_root:
|
||||
if str(project_root) not in self._config["project_configs"]:
|
||||
self._config["project_configs"][str(project_root)] = {}
|
||||
self._config["project_configs"][str(project_root)]["scope"] = scope.value
|
||||
self._config["project_configs"][str(project_root)][
|
||||
"scope"
|
||||
] = scope.value
|
||||
else:
|
||||
# Set global default
|
||||
self._config["default_scope"] = scope.value
|
||||
|
||||
@@ -187,8 +187,12 @@ mode --action current"""
|
||||
for mode_name in mode_names:
|
||||
mode = next((m for m in modes if m.name == mode_name), None)
|
||||
if mode:
|
||||
marker = " (active)" if active and active.name == mode.name else ""
|
||||
output.append(f" {mode.name}{marker}: {mode.programmer} - {mode.description}")
|
||||
marker = (
|
||||
" (active)" if active and active.name == mode.name else ""
|
||||
)
|
||||
output.append(
|
||||
f" {mode.name}{marker}: {mode.programmer} - {mode.description}"
|
||||
)
|
||||
|
||||
output.append("\nUse 'mode --action activate <name>' to activate a mode")
|
||||
|
||||
@@ -252,7 +256,9 @@ mode --action current"""
|
||||
for key, value in mode.environment.items():
|
||||
output.append(f" {key}={value}")
|
||||
|
||||
output.append("\nNote: Restart MCP session for changes to take full effect")
|
||||
output.append(
|
||||
"\nNote: Restart MCP session for changes to take full effect"
|
||||
)
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
@@ -306,13 +312,17 @@ mode --action current"""
|
||||
tool_self = self
|
||||
|
||||
@server.tool(name=self.name, description=self.description)
|
||||
async def mode_handler(ctx: MCPContext, action: str = "list", name: Optional[str] = None) -> str:
|
||||
async def mode_handler(
|
||||
ctx: MCPContext, action: str = "list", name: Optional[str] = None
|
||||
) -> str:
|
||||
"""Handle mode tool calls."""
|
||||
return await tool_self.run(ctx, action=action, name=name)
|
||||
|
||||
async def call(self, ctx: MCPContext, **params) -> str:
|
||||
"""Call the tool with arguments."""
|
||||
return await self.run(ctx, action=params.get("action", "list"), name=params.get("name"))
|
||||
return await self.run(
|
||||
ctx, action=params.get("action", "list"), name=params.get("name")
|
||||
)
|
||||
|
||||
|
||||
# Create tool instance
|
||||
|
||||
@@ -127,7 +127,9 @@ class ProjectDatabase:
|
||||
# Indexes for graph traversal
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_source ON edges(source)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_target ON edges(target)")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_edges_relationship ON edges(relationship)")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_edges_relationship ON edges(relationship)"
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_nodes_type ON nodes(type)")
|
||||
|
||||
def _load_graph_from_disk(self):
|
||||
@@ -136,11 +138,15 @@ class ProjectDatabase:
|
||||
try:
|
||||
# Copy nodes
|
||||
nodes = disk_conn.execute("SELECT * FROM nodes").fetchall()
|
||||
self.graph_conn.executemany("INSERT OR REPLACE INTO nodes VALUES (?, ?, ?, ?)", nodes)
|
||||
self.graph_conn.executemany(
|
||||
"INSERT OR REPLACE INTO nodes VALUES (?, ?, ?, ?)", nodes
|
||||
)
|
||||
|
||||
# Copy edges
|
||||
edges = disk_conn.execute("SELECT * FROM edges").fetchall()
|
||||
self.graph_conn.executemany("INSERT OR REPLACE INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges)
|
||||
self.graph_conn.executemany(
|
||||
"INSERT OR REPLACE INTO edges VALUES (?, ?, ?, ?, ?, ?)", edges
|
||||
)
|
||||
|
||||
self.graph_conn.commit()
|
||||
finally:
|
||||
|
||||
@@ -120,7 +120,9 @@ class GraphParams(TypedDict, total=False):
|
||||
class GraphTool(BaseTool):
|
||||
"""Unified graph database tool."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph tool."""
|
||||
super().__init__(permission_manager)
|
||||
self.db_manager = db_manager
|
||||
|
||||
@@ -93,7 +93,9 @@ class GraphAddParams(TypedDict, total=False):
|
||||
class GraphAddTool(BaseTool):
|
||||
"""Tool for adding nodes and edges to graph database."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph add tool.
|
||||
|
||||
Args:
|
||||
@@ -223,11 +225,15 @@ Examples:
|
||||
if not relationship:
|
||||
return "Error: relationship is required when adding an edge"
|
||||
|
||||
await tool_ctx.info(f"Adding edge: {source} --[{relationship}]--> {target}")
|
||||
await tool_ctx.info(
|
||||
f"Adding edge: {source} --[{relationship}]--> {target}"
|
||||
)
|
||||
|
||||
# Check if nodes exist
|
||||
cursor = graph_conn.cursor()
|
||||
cursor.execute("SELECT id FROM nodes WHERE id IN (?, ?)", (source, target))
|
||||
cursor.execute(
|
||||
"SELECT id FROM nodes WHERE id IN (?, ?)", (source, target)
|
||||
)
|
||||
existing = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
if source not in existing:
|
||||
|
||||
@@ -102,7 +102,9 @@ class GraphQueryParams(TypedDict, total=False):
|
||||
class GraphQueryTool(BaseTool):
|
||||
"""Tool for querying the graph database."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph query tool.
|
||||
|
||||
Args:
|
||||
@@ -190,7 +192,10 @@ Examples:
|
||||
return f"Error: Invalid query '{query}'. Must be one of: {', '.join(valid_queries)}"
|
||||
|
||||
# Validate required parameters
|
||||
if query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"] and not node_id:
|
||||
if (
|
||||
query in ["neighbors", "subgraph", "connected", "ancestors", "descendants"]
|
||||
and not node_id
|
||||
):
|
||||
return f"Error: node_id is required for '{query}' query"
|
||||
|
||||
if query == "path" and (not node_id or not target_id):
|
||||
@@ -220,17 +225,27 @@ Examples:
|
||||
|
||||
try:
|
||||
if query == "neighbors":
|
||||
return self._query_neighbors(graph_conn, node_id, relationship, node_type, direction)
|
||||
return self._query_neighbors(
|
||||
graph_conn, node_id, relationship, node_type, direction
|
||||
)
|
||||
elif query == "path":
|
||||
return self._query_path(graph_conn, node_id, target_id, relationship)
|
||||
elif query == "subgraph":
|
||||
return self._query_subgraph(graph_conn, node_id, depth, relationship, node_type, direction)
|
||||
return self._query_subgraph(
|
||||
graph_conn, node_id, depth, relationship, node_type, direction
|
||||
)
|
||||
elif query == "connected":
|
||||
return self._query_connected(graph_conn, node_id, relationship, node_type, direction)
|
||||
return self._query_connected(
|
||||
graph_conn, node_id, relationship, node_type, direction
|
||||
)
|
||||
elif query == "ancestors":
|
||||
return self._query_ancestors(graph_conn, node_id, depth, relationship, node_type)
|
||||
return self._query_ancestors(
|
||||
graph_conn, node_id, depth, relationship, node_type
|
||||
)
|
||||
elif query == "descendants":
|
||||
return self._query_descendants(graph_conn, node_id, depth, relationship, node_type)
|
||||
return self._query_descendants(
|
||||
graph_conn, node_id, depth, relationship, node_type
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Failed to execute query: {str(e)}")
|
||||
@@ -316,9 +331,13 @@ Examples:
|
||||
output = [f"Neighbors of '{node_id}' ({node_info[0]}):\n"]
|
||||
for n in neighbors:
|
||||
arrow = "<--" if n["direction"] == "incoming" else "-->"
|
||||
output.append(f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})")
|
||||
output.append(
|
||||
f" {node_id} {arrow}[{n['relationship']}]--> {n['node_id']} ({n['node_type']})"
|
||||
)
|
||||
if n["properties"]:
|
||||
output.append(f" Properties: {json.dumps(n['properties'], indent=6)[:100]}")
|
||||
output.append(
|
||||
f" Properties: {json.dumps(n['properties'], indent=6)[:100]}"
|
||||
)
|
||||
|
||||
output.append(f"\nTotal neighbors: {len(neighbors)}")
|
||||
return "\n".join(output)
|
||||
@@ -571,7 +590,9 @@ Examples:
|
||||
node_type: Optional[str],
|
||||
) -> str:
|
||||
"""Find nodes that point TO this node (incoming edges only)."""
|
||||
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "incoming")
|
||||
return self._query_subgraph(
|
||||
conn, node_id, depth, relationship, node_type, "incoming"
|
||||
)
|
||||
|
||||
def _query_descendants(
|
||||
self,
|
||||
@@ -582,7 +603,9 @@ Examples:
|
||||
node_type: Optional[str],
|
||||
) -> str:
|
||||
"""Find nodes that this node points TO (outgoing edges only)."""
|
||||
return self._query_subgraph(conn, node_id, depth, relationship, node_type, "outgoing")
|
||||
return self._query_subgraph(
|
||||
conn, node_id, depth, relationship, node_type, "outgoing"
|
||||
)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
|
||||
@@ -74,7 +74,9 @@ class GraphRemoveParams(TypedDict, total=False):
|
||||
class GraphRemoveTool(BaseTool):
|
||||
"""Tool for removing nodes and edges from graph database."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph remove tool.
|
||||
|
||||
Args:
|
||||
@@ -218,7 +220,9 @@ Examples:
|
||||
# Remove edge(s)
|
||||
if relationship:
|
||||
# Remove specific edge
|
||||
await tool_ctx.info(f"Removing edge: {source} --[{relationship}]--> {target}")
|
||||
await tool_ctx.info(
|
||||
f"Removing edge: {source} --[{relationship}]--> {target}"
|
||||
)
|
||||
|
||||
cursor = graph_conn.cursor()
|
||||
cursor.execute(
|
||||
@@ -238,7 +242,9 @@ Examples:
|
||||
return f"Successfully removed edge: {source} --[{relationship}]--> {target}"
|
||||
else:
|
||||
# Remove all edges between nodes
|
||||
await tool_ctx.info(f"Removing all edges between {source} and {target}")
|
||||
await tool_ctx.info(
|
||||
f"Removing all edges between {source} and {target}"
|
||||
)
|
||||
|
||||
cursor = graph_conn.cursor()
|
||||
cursor.execute(
|
||||
|
||||
@@ -76,7 +76,9 @@ class GraphSearchParams(TypedDict, total=False):
|
||||
class GraphSearchTool(BaseTool):
|
||||
"""Tool for searching nodes and edges in graph database."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph search tool.
|
||||
|
||||
Args:
|
||||
@@ -313,8 +315,13 @@ Examples:
|
||||
output.append(f"Nodes ({len(nodes)}):")
|
||||
for node in nodes[:20]: # Show first 20
|
||||
output.append(f" {node['id']} ({node['node_type']})")
|
||||
if node["match_field"] == "properties" and "matching_properties" in node:
|
||||
output.append(f" Matched in: {list(node['matching_properties'].keys())}")
|
||||
if (
|
||||
node["match_field"] == "properties"
|
||||
and "matching_properties" in node
|
||||
):
|
||||
output.append(
|
||||
f" Matched in: {list(node['matching_properties'].keys())}"
|
||||
)
|
||||
if node["properties"] and node["match_field"] != "properties":
|
||||
props_str = json.dumps(node["properties"], indent=6)[:100]
|
||||
if len(props_str) == 100:
|
||||
@@ -328,9 +335,16 @@ Examples:
|
||||
if edges:
|
||||
output.append(f"Edges ({len(edges)}):")
|
||||
for edge in edges[:20]: # Show first 20
|
||||
output.append(f" {edge['source']} --[{edge['relationship']}]--> {edge['target']}")
|
||||
if edge["match_field"] == "properties" and "matching_properties" in edge:
|
||||
output.append(f" Matched in: {list(edge['matching_properties'].keys())}")
|
||||
output.append(
|
||||
f" {edge['source']} --[{edge['relationship']}]--> {edge['target']}"
|
||||
)
|
||||
if (
|
||||
edge["match_field"] == "properties"
|
||||
and "matching_properties" in edge
|
||||
):
|
||||
output.append(
|
||||
f" Matched in: {list(edge['matching_properties'].keys())}"
|
||||
)
|
||||
if edge["weight"] != 1.0:
|
||||
output.append(f" Weight: {edge['weight']}")
|
||||
if edge["properties"]:
|
||||
|
||||
@@ -58,7 +58,9 @@ class GraphStatsParams(TypedDict, total=False):
|
||||
class GraphStatsTool(BaseTool):
|
||||
"""Tool for getting graph database statistics."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the graph stats tool.
|
||||
|
||||
Args:
|
||||
@@ -137,7 +139,9 @@ Examples:
|
||||
except Exception as e:
|
||||
return f"Error accessing project database: {str(e)}"
|
||||
|
||||
await tool_ctx.info(f"Getting graph statistics for project: {project_db.project_path}")
|
||||
await tool_ctx.info(
|
||||
f"Getting graph statistics for project: {project_db.project_path}"
|
||||
)
|
||||
|
||||
# Get graph connection
|
||||
graph_conn = project_db.get_graph_connection()
|
||||
@@ -152,7 +156,9 @@ Examples:
|
||||
|
||||
# Basic counts
|
||||
if node_type_filter:
|
||||
cursor.execute("SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,))
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM nodes WHERE type = ?", (node_type_filter,)
|
||||
)
|
||||
node_count = cursor.fetchone()[0]
|
||||
output.append(f"Nodes (type='{node_type_filter}'): {node_count:,}")
|
||||
else:
|
||||
@@ -166,7 +172,9 @@ Examples:
|
||||
(relationship_filter,),
|
||||
)
|
||||
edge_count = cursor.fetchone()[0]
|
||||
output.append(f"Edges (relationship='{relationship_filter}'): {edge_count:,}")
|
||||
output.append(
|
||||
f"Edges (relationship='{relationship_filter}'): {edge_count:,}"
|
||||
)
|
||||
else:
|
||||
cursor.execute("SELECT COUNT(*) FROM edges")
|
||||
edge_count = cursor.fetchone()[0]
|
||||
@@ -180,7 +188,9 @@ Examples:
|
||||
|
||||
# Node type distribution
|
||||
output.append("=== Node Types ===")
|
||||
cursor.execute("SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC")
|
||||
cursor.execute(
|
||||
"SELECT type, COUNT(*) as count FROM nodes GROUP BY type ORDER BY count DESC"
|
||||
)
|
||||
node_types = cursor.fetchall()
|
||||
|
||||
for n_type, count in node_types[:10]:
|
||||
@@ -275,21 +285,31 @@ Examples:
|
||||
output.append("\n=== Detailed Analysis ===")
|
||||
|
||||
# Node properties usage
|
||||
cursor.execute("SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL")
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM nodes WHERE properties IS NOT NULL"
|
||||
)
|
||||
nodes_with_props = cursor.fetchone()[0]
|
||||
if nodes_with_props > 0:
|
||||
props_pct = (nodes_with_props / node_count) * 100
|
||||
output.append(f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)")
|
||||
output.append(
|
||||
f"Nodes with properties: {nodes_with_props} ({props_pct:.1f}%)"
|
||||
)
|
||||
|
||||
# Edge properties usage
|
||||
cursor.execute("SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL")
|
||||
cursor.execute(
|
||||
"SELECT COUNT(*) FROM edges WHERE properties IS NOT NULL"
|
||||
)
|
||||
edges_with_props = cursor.fetchone()[0]
|
||||
if edges_with_props > 0 and edge_count > 0:
|
||||
props_pct = (edges_with_props / edge_count) * 100
|
||||
output.append(f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)")
|
||||
output.append(
|
||||
f"Edges with properties: {edges_with_props} ({props_pct:.1f}%)"
|
||||
)
|
||||
|
||||
# Weight distribution
|
||||
cursor.execute("SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges")
|
||||
cursor.execute(
|
||||
"SELECT MIN(weight), MAX(weight), AVG(weight) FROM edges"
|
||||
)
|
||||
weight_stats = cursor.fetchone()
|
||||
if weight_stats[0] is not None:
|
||||
output.append(f"\nEdge weights:")
|
||||
@@ -318,7 +338,9 @@ Examples:
|
||||
if patterns:
|
||||
output.append("Most common connections:")
|
||||
for src_type, rel, tgt_type, count in patterns:
|
||||
output.append(f" {src_type} --[{rel}]--> {tgt_type}: {count} times")
|
||||
output.append(
|
||||
f" {src_type} --[{rel}]--> {tgt_type}: {count} times"
|
||||
)
|
||||
|
||||
# Component analysis (simplified)
|
||||
output.append("\n=== Graph Structure ===")
|
||||
|
||||
@@ -75,7 +75,9 @@ class SQLParams(TypedDict, total=False):
|
||||
class SQLTool(BaseTool):
|
||||
"""Unified SQL database tool."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the SQL tool."""
|
||||
super().__init__(permission_manager)
|
||||
self.db_manager = db_manager
|
||||
@@ -294,7 +296,9 @@ sql --action stats --table users
|
||||
output.append("-" * 60)
|
||||
|
||||
for col in columns:
|
||||
output.append(f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}")
|
||||
output.append(
|
||||
f"{col[1]} | {col[2]} | {col[3]} | {col[4]} | {col[5]}"
|
||||
)
|
||||
|
||||
# Get indexes
|
||||
cursor = conn.execute(f"PRAGMA index_list({table})")
|
||||
@@ -332,7 +336,9 @@ sql --action stats --table users
|
||||
# Get columns
|
||||
cursor = conn.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = cursor.fetchall()
|
||||
output.append(f"Columns: {', '.join([col[1] for col in columns])}")
|
||||
output.append(
|
||||
f"Columns: {', '.join([col[1] for col in columns])}"
|
||||
)
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
@@ -396,7 +402,9 @@ sql --action stats --table users
|
||||
"""
|
||||
)
|
||||
stats = cursor.fetchone()
|
||||
output.append(f" {col_name}: distinct={stats[0]}, nulls={stats[1]}")
|
||||
output.append(
|
||||
f" {col_name}: distinct={stats[0]}, nulls={stats[1]}"
|
||||
)
|
||||
|
||||
else:
|
||||
# Overall database stats
|
||||
|
||||
@@ -48,7 +48,9 @@ class SqlQueryParams(TypedDict, total=False):
|
||||
class SqlQueryTool(BaseTool):
|
||||
"""Tool for executing SQL queries on project databases."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the SQL query tool.
|
||||
|
||||
Args:
|
||||
@@ -138,11 +140,11 @@ Note: Use sql_search for text search operations."""
|
||||
query_upper = query.upper()
|
||||
for keyword in write_keywords:
|
||||
if keyword in query_upper:
|
||||
return (
|
||||
f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
|
||||
)
|
||||
return f"Error: Query contains {keyword} operation. Set --read-only false to allow write operations."
|
||||
|
||||
await tool_ctx.info(f"Executing SQL query on project: {project_db.project_path}")
|
||||
await tool_ctx.info(
|
||||
f"Executing SQL query on project: {project_db.project_path}"
|
||||
)
|
||||
|
||||
# Execute query
|
||||
conn = None
|
||||
@@ -207,7 +209,9 @@ Note: Use sql_search for text search operations."""
|
||||
output_rows = []
|
||||
for row in rows[:1000]: # Limit to 1000 rows
|
||||
row_str = " | ".join(
|
||||
self._truncate(str(val) if val is not None else "NULL", width).ljust(width)
|
||||
self._truncate(str(val) if val is not None else "NULL", width).ljust(
|
||||
width
|
||||
)
|
||||
for val, width in zip(row, col_widths)
|
||||
)
|
||||
output_rows.append(row_str)
|
||||
|
||||
@@ -66,7 +66,9 @@ class SqlSearchParams(TypedDict, total=False):
|
||||
class SqlSearchTool(BaseTool):
|
||||
"""Tool for searching text in SQLite database."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the SQL search tool.
|
||||
|
||||
Args:
|
||||
@@ -251,7 +253,9 @@ Use sql_query for complex queries with joins, conditions, etc."""
|
||||
|
||||
return text_columns
|
||||
|
||||
def _format_results(self, table: str, results: list, pattern: str, search_columns: list[str]) -> str:
|
||||
def _format_results(
|
||||
self, table: str, results: list, pattern: str, search_columns: list[str]
|
||||
) -> str:
|
||||
"""Format search results based on table type."""
|
||||
output = []
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ class SqlStatsParams(TypedDict, total=False):
|
||||
class SqlStatsTool(BaseTool):
|
||||
"""Tool for getting SQLite database statistics."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager, db_manager: DatabaseManager):
|
||||
def __init__(
|
||||
self, permission_manager: PermissionManager, db_manager: DatabaseManager
|
||||
):
|
||||
"""Initialize the SQL stats tool.
|
||||
|
||||
Args:
|
||||
@@ -113,7 +115,9 @@ Examples:
|
||||
except Exception as e:
|
||||
return f"Error accessing project database: {str(e)}"
|
||||
|
||||
await tool_ctx.info(f"Getting statistics for project: {project_db.project_path}")
|
||||
await tool_ctx.info(
|
||||
f"Getting statistics for project: {project_db.project_path}"
|
||||
)
|
||||
|
||||
# Collect statistics
|
||||
conn = None
|
||||
@@ -132,7 +136,9 @@ Examples:
|
||||
output.append("")
|
||||
|
||||
# Get table statistics
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
tables = cursor.fetchall()
|
||||
|
||||
output.append("=== Tables ===")
|
||||
@@ -176,7 +182,9 @@ Examples:
|
||||
|
||||
# Show sample data for specific tables
|
||||
if table_name == "files" and row_count > 0:
|
||||
cursor.execute(f"SELECT COUNT(DISTINCT SUBSTR(path, -3)) as ext_count FROM {table_name}")
|
||||
cursor.execute(
|
||||
f"SELECT COUNT(DISTINCT SUBSTR(path, -3)) as ext_count FROM {table_name}"
|
||||
)
|
||||
ext_count = cursor.fetchone()[0]
|
||||
output.append(f" File types: ~{ext_count}")
|
||||
|
||||
@@ -198,7 +206,9 @@ Examples:
|
||||
output.append(f"\nTotal Rows: {total_rows:,}")
|
||||
|
||||
# Get index statistics
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name")
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND sql IS NOT NULL ORDER BY name"
|
||||
)
|
||||
indexes = cursor.fetchall()
|
||||
if indexes:
|
||||
output.append(f"\n=== Indexes ===")
|
||||
|
||||
@@ -158,7 +158,9 @@ Note: Requires Neovim to be installed.
|
||||
return "Error: Must provide either 'command', 'commands', or 'macro'"
|
||||
|
||||
if sum(bool(x) for x in [command, commands, macro]) > 1:
|
||||
return "Error: Can only use one of 'command', 'commands', or 'macro' at a time"
|
||||
return (
|
||||
"Error: Can only use one of 'command', 'commands', or 'macro' at a time"
|
||||
)
|
||||
|
||||
# Check if Neovim is available
|
||||
nvim_cmd = shutil.which("nvim")
|
||||
|
||||
@@ -168,7 +168,9 @@ Note: Requires Neovim to be installed.
|
||||
project_path,
|
||||
)
|
||||
elif action == "restore":
|
||||
return await self._restore_session(tool_ctx, session_name, project_session_dir)
|
||||
return await self._restore_session(
|
||||
tool_ctx, session_name, project_session_dir
|
||||
)
|
||||
elif action == "list":
|
||||
return self._list_sessions(project_session_dir, project_path)
|
||||
elif action == "delete":
|
||||
@@ -212,7 +214,9 @@ Note: Requires Neovim to be installed.
|
||||
# Run Neovim to save session
|
||||
# First, check if Neovim is already running
|
||||
# For now, we'll create a new instance
|
||||
result = subprocess.run(["nvim", "-c", vim_script.strip()], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
["nvim", "-c", vim_script.strip()], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0 and result.stderr:
|
||||
return f"Error saving session: {result.stderr}"
|
||||
@@ -242,15 +246,15 @@ Or manually in Neovim:
|
||||
except Exception as e:
|
||||
return f"Error saving session: {str(e)}"
|
||||
|
||||
async def _restore_session(self, tool_ctx, session_name: Optional[str], project_dir: Path) -> str:
|
||||
async def _restore_session(
|
||||
self, tool_ctx, session_name: Optional[str], project_dir: Path
|
||||
) -> str:
|
||||
"""Restore Neovim session."""
|
||||
if not session_name:
|
||||
# List available sessions
|
||||
sessions = list(project_dir.glob("*.vim"))
|
||||
if not sessions:
|
||||
return (
|
||||
"Error: No sessions found for this project. Use 'neovim_session --action list' to see all sessions."
|
||||
)
|
||||
return "Error: No sessions found for this project. Use 'neovim_session --action list' to see all sessions."
|
||||
|
||||
# Use most recent
|
||||
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
||||
@@ -326,7 +330,9 @@ Or manually in Neovim:
|
||||
if len(other_sessions) > 10:
|
||||
output.append(f" ... and {len(other_sessions) - 10} more")
|
||||
|
||||
output.append("\nUse 'neovim_session --action restore --session-name <name>' to restore a session.")
|
||||
output.append(
|
||||
"\nUse 'neovim_session --action restore --session-name <name>' to restore a session."
|
||||
)
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
@@ -93,7 +93,9 @@ def get_read_only_filesystem_tools(
|
||||
return tools
|
||||
|
||||
|
||||
def get_filesystem_tools(permission_manager: PermissionManager, project_manager=None) -> list[BaseTool]:
|
||||
def get_filesystem_tools(
|
||||
permission_manager: PermissionManager, project_manager=None
|
||||
) -> list[BaseTool]:
|
||||
"""Create instances of all filesystem tools.
|
||||
|
||||
Args:
|
||||
@@ -219,4 +221,22 @@ def register_filesystem_tools(
|
||||
tools = get_filesystem_tools(permission_manager, project_manager)
|
||||
|
||||
ToolRegistry.register_tools(mcp_server, tools)
|
||||
|
||||
# Register 'symbols' as an alias for 'ast' to match common terminology
|
||||
try:
|
||||
ast_tool = next((t for t in tools if getattr(t, "name", "") == "ast"), None)
|
||||
if ast_tool is not None:
|
||||
class _SymbolsAlias(ASTTool): # type: ignore[misc]
|
||||
@property
|
||||
def name(self) -> str: # type: ignore[override]
|
||||
return "symbols"
|
||||
|
||||
@property
|
||||
def description(self) -> str: # type: ignore[override]
|
||||
return f"Alias of 'ast' tool.\n\n{ast_tool.description}"
|
||||
|
||||
ToolRegistry.register_tool(mcp_server, _SymbolsAlias(permission_manager))
|
||||
except Exception:
|
||||
# Alias is best-effort; don't fail tool registration if aliasing fails
|
||||
pass
|
||||
return tools
|
||||
|
||||
@@ -129,7 +129,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return parser.parse(bytes(content, "utf-8"))
|
||||
|
||||
def _find_references(self, symbol: str, file_path: str, project_root: Optional[str] = None) -> List[ASTMatch]:
|
||||
def _find_references(
|
||||
self, symbol: str, file_path: str, project_root: Optional[str] = None
|
||||
) -> List[ASTMatch]:
|
||||
"""Find all references to a symbol across the project."""
|
||||
matches = []
|
||||
|
||||
@@ -147,7 +149,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return matches
|
||||
|
||||
def _get_reference_patterns(self, symbol: str, file_path: str) -> List[Dict[str, Any]]:
|
||||
def _get_reference_patterns(
|
||||
self, symbol: str, file_path: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Get language-specific patterns for finding references."""
|
||||
ext = Path(file_path).suffix.lower()
|
||||
lang = self.languages.get(ext, "generic")
|
||||
@@ -258,7 +262,9 @@ class ASTMultiEdit(BaseTool):
|
||||
matches.extend(self._query_ast(tree, pattern, file_path, content))
|
||||
else:
|
||||
# Fallback to text search
|
||||
matches.extend(self._text_search(content, pattern["query"], file_path))
|
||||
matches.extend(
|
||||
self._text_search(content, pattern["query"], file_path)
|
||||
)
|
||||
|
||||
except Exception:
|
||||
continue
|
||||
@@ -307,7 +313,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return matches
|
||||
|
||||
def _get_parent_context(self, node: tree_sitter.Node, content: str) -> Optional[str]:
|
||||
def _get_parent_context(
|
||||
self, node: tree_sitter.Node, content: str
|
||||
) -> Optional[str]:
|
||||
"""Get parent context for better understanding."""
|
||||
parent = node.parent
|
||||
if parent:
|
||||
@@ -327,7 +335,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return None
|
||||
|
||||
def _text_search(self, content: str, pattern: str, file_path: str) -> List[ASTMatch]:
|
||||
def _text_search(
|
||||
self, content: str, pattern: str, file_path: str
|
||||
) -> List[ASTMatch]:
|
||||
"""Fallback text search."""
|
||||
matches = []
|
||||
lines = content.split("\n")
|
||||
@@ -402,14 +412,18 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return str(path.parent)
|
||||
|
||||
def _group_matches_by_file(self, matches: List[ASTMatch]) -> Dict[str, List[ASTMatch]]:
|
||||
def _group_matches_by_file(
|
||||
self, matches: List[ASTMatch]
|
||||
) -> Dict[str, List[ASTMatch]]:
|
||||
"""Group matches by file for efficient editing."""
|
||||
grouped = defaultdict(list)
|
||||
for match in matches:
|
||||
grouped[match.file_path].append(match)
|
||||
return grouped
|
||||
|
||||
def _create_unique_context(self, content: str, match: ASTMatch, context_lines: int) -> str:
|
||||
def _create_unique_context(
|
||||
self, content: str, match: ASTMatch, context_lines: int
|
||||
) -> str:
|
||||
"""Create unique context for edit identification."""
|
||||
lines = content.split("\n")
|
||||
|
||||
@@ -485,20 +499,27 @@ class ASTMultiEdit(BaseTool):
|
||||
pattern = {"query": edit_op.old_string, "type": "text"}
|
||||
matches = self._query_ast(tree, pattern, file_path, content)
|
||||
else:
|
||||
matches = self._text_search(content, edit_op.old_string, file_path)
|
||||
matches = self._text_search(
|
||||
content, edit_op.old_string, file_path
|
||||
)
|
||||
|
||||
# Filter by node types if specified
|
||||
if edit_op.node_types:
|
||||
matches = [m for m in matches if m.node_type in edit_op.node_types]
|
||||
|
||||
# Check expected count
|
||||
if edit_op.expect_count is not None and len(matches) != edit_op.expect_count:
|
||||
if (
|
||||
edit_op.expect_count is not None
|
||||
and len(matches) != edit_op.expect_count
|
||||
):
|
||||
results["errors"].append(
|
||||
{
|
||||
"edit": edit_op.old_string,
|
||||
"expected": edit_op.expect_count,
|
||||
"found": len(matches),
|
||||
"locations": [f"{m.file_path}:{m.line_start}" for m in matches[:5]],
|
||||
"locations": [
|
||||
f"{m.file_path}:{m.line_start}" for m in matches[:5]
|
||||
],
|
||||
}
|
||||
)
|
||||
continue
|
||||
@@ -526,7 +547,9 @@ class ASTMultiEdit(BaseTool):
|
||||
success = await self._apply_file_changes(file_path, changes)
|
||||
if success:
|
||||
results["edits_applied"] += len(changes)
|
||||
results["changes"].append({"file": file_path, "edits": len(changes)})
|
||||
results["changes"].append(
|
||||
{"file": file_path, "edits": len(changes)}
|
||||
)
|
||||
except Exception as e:
|
||||
results["errors"].append({"file": file_path, "error": str(e)})
|
||||
|
||||
@@ -541,7 +564,9 @@ class ASTMultiEdit(BaseTool):
|
||||
grouped[match.file_path].append((edit_op, match))
|
||||
return grouped
|
||||
|
||||
async def _apply_file_changes(self, file_path: str, changes: List[Tuple[EditOperation, ASTMatch]]) -> bool:
|
||||
async def _apply_file_changes(
|
||||
self, file_path: str, changes: List[Tuple[EditOperation, ASTMatch]]
|
||||
) -> bool:
|
||||
"""Apply changes to a single file."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
@@ -575,7 +600,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return True
|
||||
|
||||
def _generate_preview(self, matches: List[Tuple[EditOperation, ASTMatch]], page_size: int) -> List[Dict[str, Any]]:
|
||||
def _generate_preview(
|
||||
self, matches: List[Tuple[EditOperation, ASTMatch]], page_size: int
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Generate preview of changes."""
|
||||
preview = []
|
||||
|
||||
@@ -598,7 +625,9 @@ class ASTMultiEdit(BaseTool):
|
||||
|
||||
return preview
|
||||
|
||||
def _fallback_to_basic_edit(self, file_path: str, edits: List[Dict[str, Any]]) -> MCPResourceDocument:
|
||||
def _fallback_to_basic_edit(
|
||||
self, file_path: str, edits: List[Dict[str, Any]]
|
||||
) -> MCPResourceDocument:
|
||||
"""Fallback to basic multi-edit when treesitter not available."""
|
||||
# Delegate to existing multi_edit tool
|
||||
from hanzo_mcp.tools.filesystem.multi_edit import MultiEdit
|
||||
|
||||
@@ -21,7 +21,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
|
||||
the base functionality in FileSystemTool.
|
||||
"""
|
||||
|
||||
async def check_path_allowed(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
||||
async def check_path_allowed(
|
||||
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
|
||||
) -> tuple[bool, str]:
|
||||
"""Check if a path is allowed and log an error if not.
|
||||
|
||||
Args:
|
||||
@@ -38,7 +40,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
|
||||
return False, f"{error_prefix}: {message}"
|
||||
return True, ""
|
||||
|
||||
async def check_path_exists(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
||||
async def check_path_exists(
|
||||
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
|
||||
) -> tuple[bool, str]:
|
||||
"""Check if a path exists and log an error if not.
|
||||
|
||||
Args:
|
||||
@@ -56,7 +60,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
|
||||
return False, f"{error_prefix}: {message}"
|
||||
return True, ""
|
||||
|
||||
async def check_is_file(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
||||
async def check_is_file(
|
||||
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
|
||||
) -> tuple[bool, str]:
|
||||
"""Check if a path is a file and log an error if not.
|
||||
|
||||
Args:
|
||||
@@ -74,7 +80,9 @@ class FilesystemBaseTool(FileSystemTool, ABC):
|
||||
return False, f"{error_prefix}: {message}"
|
||||
return True, ""
|
||||
|
||||
async def check_is_directory(self, path: str, tool_ctx: Any, error_prefix: str = "Error") -> tuple[bool, str]:
|
||||
async def check_is_directory(
|
||||
self, path: str, tool_ctx: Any, error_prefix: str = "Error"
|
||||
) -> tuple[bool, str]:
|
||||
"""Check if a path is a directory and log an error if not.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -80,7 +80,10 @@ class BatchSearchResults:
|
||||
return {
|
||||
"query": self.query,
|
||||
"total_results": self.total_results,
|
||||
"results_by_type": {k.value: [r.to_dict() for r in v] for k, v in self.results_by_type.items()},
|
||||
"results_by_type": {
|
||||
k.value: [r.to_dict() for r in v]
|
||||
for k, v in self.results_by_type.items()
|
||||
},
|
||||
"combined_results": [r.to_dict() for r in self.combined_results],
|
||||
"search_time_ms": self.search_time_ms,
|
||||
}
|
||||
@@ -93,8 +96,12 @@ Queries = Annotated[
|
||||
SearchPath = Annotated[str, Field(description="Path to search in", default=".")]
|
||||
Include = Annotated[str, Field(description="File pattern to include", default="*")]
|
||||
MaxResults = Annotated[int, Field(description="Maximum results per query", default=20)]
|
||||
IncludeContext = Annotated[bool, Field(description="Include function/method context", default=True)]
|
||||
CombineResults = Annotated[bool, Field(description="Combine and deduplicate results", default=True)]
|
||||
IncludeContext = Annotated[
|
||||
bool, Field(description="Include function/method context", default=True)
|
||||
]
|
||||
CombineResults = Annotated[
|
||||
bool, Field(description="Combine and deduplicate results", default=True)
|
||||
]
|
||||
|
||||
|
||||
class BatchSearchParams(TypedDict):
|
||||
@@ -205,7 +212,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
|
||||
# If pattern contains natural language, prioritize vector search
|
||||
words = pattern.split()
|
||||
if len(words) > 2 and not any(c in pattern for c in ["(", ")", "{", "}", "[", "]"]):
|
||||
if len(words) > 2 and not any(
|
||||
c in pattern for c in ["(", ")", "{", "}", "[", "]"]
|
||||
):
|
||||
use_vector = True
|
||||
|
||||
return use_vector, use_ast, use_symbol
|
||||
@@ -218,7 +227,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
|
||||
try:
|
||||
# Use the existing grep tool
|
||||
grep_result = await self.grep_tool.call(tool_ctx.mcp_context, pattern=pattern, path=path, include=include)
|
||||
grep_result = await self.grep_tool.call(
|
||||
tool_ctx.mcp_context, pattern=pattern, path=path, include=include
|
||||
)
|
||||
|
||||
results = []
|
||||
if "Found" in grep_result and "matches" in grep_result:
|
||||
@@ -254,7 +265,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
await tool_ctx.error(f"Grep search failed: {str(e)}")
|
||||
return []
|
||||
|
||||
async def _run_vector_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_vector_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Run vector search and convert results."""
|
||||
if not self.vector_tool:
|
||||
return []
|
||||
@@ -360,7 +373,11 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
content=content,
|
||||
search_type=SearchType.AST,
|
||||
score=0.9, # High score for AST matches
|
||||
context=("\n".join(context_lines) if context_lines else None),
|
||||
context=(
|
||||
"\n".join(context_lines)
|
||||
if context_lines
|
||||
else None
|
||||
),
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
@@ -379,7 +396,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
await tool_ctx.error(f"AST search failed: {str(e)}")
|
||||
return []
|
||||
|
||||
async def _run_symbol_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_symbol_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Run symbol search using AST analysis."""
|
||||
await tool_ctx.info(f"Running symbol search for: {pattern}")
|
||||
|
||||
@@ -395,7 +414,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
# Look for source files
|
||||
for ext in [".py", ".js", ".ts", ".java", ".cpp", ".c"]:
|
||||
files_to_check.extend(path_obj.rglob(f"*{ext}"))
|
||||
files_to_check = [str(f) for f in files_to_check[:50]] # Limit for performance
|
||||
files_to_check = [
|
||||
str(f) for f in files_to_check[:50]
|
||||
] # Limit for performance
|
||||
|
||||
# Analyze files for symbols
|
||||
for file_path in files_to_check:
|
||||
@@ -418,7 +439,11 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
file_path=symbol.file_path,
|
||||
line_number=symbol.line_start,
|
||||
content=f"{symbol.type} {symbol.name}"
|
||||
+ (f" - {symbol.docstring[:100]}..." if symbol.docstring else ""),
|
||||
+ (
|
||||
f" - {symbol.docstring[:100]}..."
|
||||
if symbol.docstring
|
||||
else ""
|
||||
),
|
||||
search_type=SearchType.SYMBOL,
|
||||
score=0.95, # Very high score for symbol matches
|
||||
symbol_info=symbol,
|
||||
@@ -439,7 +464,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
await tool_ctx.error(f"Symbol search failed: {str(e)}")
|
||||
return []
|
||||
|
||||
async def _add_function_context(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
|
||||
async def _add_function_context(
|
||||
self, results: List[SearchResult], tool_ctx
|
||||
) -> List[SearchResult]:
|
||||
"""Add function/method context to results where relevant."""
|
||||
enhanced_results = []
|
||||
|
||||
@@ -461,10 +488,16 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
if file_ast:
|
||||
# Find symbol containing this line
|
||||
for symbol in file_ast.symbols:
|
||||
if symbol.line_start <= result.line_number <= symbol.line_end and symbol.type in [
|
||||
"function",
|
||||
"method",
|
||||
]:
|
||||
if (
|
||||
symbol.line_start
|
||||
<= result.line_number
|
||||
<= symbol.line_end
|
||||
and symbol.type
|
||||
in [
|
||||
"function",
|
||||
"method",
|
||||
]
|
||||
):
|
||||
enhanced_result = SearchResult(
|
||||
file_path=result.file_path,
|
||||
line_number=result.line_number,
|
||||
@@ -477,13 +510,17 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
)
|
||||
break
|
||||
except Exception as e:
|
||||
await tool_ctx.warning(f"Could not add context for {result.file_path}: {str(e)}")
|
||||
await tool_ctx.warning(
|
||||
f"Could not add context for {result.file_path}: {str(e)}"
|
||||
)
|
||||
|
||||
enhanced_results.append(enhanced_result)
|
||||
|
||||
return enhanced_results
|
||||
|
||||
def _combine_and_rank_results(self, results_by_type: Dict[SearchType, List[SearchResult]]) -> List[SearchResult]:
|
||||
def _combine_and_rank_results(
|
||||
self, results_by_type: Dict[SearchType, List[SearchResult]]
|
||||
) -> List[SearchResult]:
|
||||
"""Combine results from different search types and rank by relevance."""
|
||||
all_results = []
|
||||
seen_combinations = set()
|
||||
@@ -515,7 +552,8 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
|
||||
# Replace existing if: higher priority type, or same priority but higher score
|
||||
if result_priority > existing_priority or (
|
||||
result_priority == existing_priority and result.score > existing.score
|
||||
result_priority == existing_priority
|
||||
and result.score > existing.score
|
||||
):
|
||||
# Replace the entire result to preserve type
|
||||
idx = all_results.index(existing)
|
||||
@@ -523,7 +561,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
else:
|
||||
# Still merge useful information
|
||||
existing.context = existing.context or result.context
|
||||
existing.symbol_info = existing.symbol_info or result.symbol_info
|
||||
existing.symbol_info = (
|
||||
existing.symbol_info or result.symbol_info
|
||||
)
|
||||
break
|
||||
|
||||
# Sort by score (descending) then by search type priority
|
||||
@@ -534,7 +574,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
SearchType.VECTOR: 1,
|
||||
}
|
||||
|
||||
all_results.sort(key=lambda r: (r.score, type_priority[r.search_type]), reverse=True)
|
||||
all_results.sort(
|
||||
key=lambda r: (r.score, type_priority[r.search_type]), reverse=True
|
||||
)
|
||||
|
||||
return all_results
|
||||
|
||||
@@ -570,7 +612,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
if not exists:
|
||||
return error_msg
|
||||
|
||||
await tool_ctx.info(f"Starting batch search with {len(queries)} queries in {path}")
|
||||
await tool_ctx.info(
|
||||
f"Starting batch search with {len(queries)} queries in {path}"
|
||||
)
|
||||
|
||||
# Run all queries in parallel
|
||||
search_tasks = []
|
||||
@@ -583,26 +627,44 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
if query_type == "grep":
|
||||
pattern = query.get("pattern")
|
||||
if pattern:
|
||||
search_tasks.append(self._run_grep_search(pattern, path, include, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_grep_search(
|
||||
pattern, path, include, tool_ctx, max_results
|
||||
)
|
||||
)
|
||||
|
||||
elif query_type == "grep_ast":
|
||||
pattern = query.get("pattern")
|
||||
if pattern:
|
||||
search_tasks.append(self._run_ast_search(pattern, path, include, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_ast_search(
|
||||
pattern, path, include, tool_ctx, max_results
|
||||
)
|
||||
)
|
||||
|
||||
elif query_type == "vector_search" and self.vector_tool:
|
||||
search_query = query.get("query") or query.get("pattern")
|
||||
if search_query:
|
||||
search_tasks.append(self._run_vector_search(search_query, path, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_vector_search(
|
||||
search_query, path, tool_ctx, max_results
|
||||
)
|
||||
)
|
||||
|
||||
elif query_type == "git_search":
|
||||
pattern = query.get("pattern")
|
||||
search_type = query.get("search_type", "content")
|
||||
if pattern:
|
||||
search_tasks.append(self._run_git_search(pattern, path, search_type, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_git_search(
|
||||
pattern, path, search_type, tool_ctx, max_results
|
||||
)
|
||||
)
|
||||
|
||||
else:
|
||||
await tool_ctx.warning(f"Unknown or unavailable search type: {query_type}")
|
||||
await tool_ctx.warning(
|
||||
f"Unknown or unavailable search type: {query_type}"
|
||||
)
|
||||
|
||||
# Execute all searches in parallel
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
@@ -627,7 +689,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
|
||||
# Add context if requested
|
||||
if include_context:
|
||||
combined_results = await self._add_context_to_results(combined_results, tool_ctx)
|
||||
combined_results = await self._add_context_to_results(
|
||||
combined_results, tool_ctx
|
||||
)
|
||||
|
||||
end_time = time.time()
|
||||
search_time_ms = (end_time - start_time) * 1000
|
||||
@@ -636,17 +700,27 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
combined_results.sort(key=lambda r: r.score, reverse=True)
|
||||
|
||||
# Limit total results
|
||||
combined_results = combined_results[: max_results * 2] # Allow more when combining
|
||||
combined_results = combined_results[
|
||||
: max_results * 2
|
||||
] # Allow more when combining
|
||||
|
||||
# Create batch results object
|
||||
batch_results = BatchSearchResults(
|
||||
query=f"Batch search with {len(queries)} queries",
|
||||
total_results=len(combined_results),
|
||||
results_by_type={
|
||||
SearchType.GREP: [r for r in combined_results if r.search_type == SearchType.GREP],
|
||||
SearchType.VECTOR: [r for r in combined_results if r.search_type == SearchType.VECTOR],
|
||||
SearchType.AST: [r for r in combined_results if r.search_type == SearchType.AST],
|
||||
SearchType.GIT: [r for r in combined_results if r.search_type == SearchType.GIT],
|
||||
SearchType.GREP: [
|
||||
r for r in combined_results if r.search_type == SearchType.GREP
|
||||
],
|
||||
SearchType.VECTOR: [
|
||||
r for r in combined_results if r.search_type == SearchType.VECTOR
|
||||
],
|
||||
SearchType.AST: [
|
||||
r for r in combined_results if r.search_type == SearchType.AST
|
||||
],
|
||||
SearchType.GIT: [
|
||||
r for r in combined_results if r.search_type == SearchType.GIT
|
||||
],
|
||||
},
|
||||
combined_results=combined_results,
|
||||
search_time_ms=search_time_ms,
|
||||
@@ -728,12 +802,16 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
|
||||
return combined
|
||||
|
||||
async def _add_context_to_results(self, results: List[SearchResult], tool_ctx) -> List[SearchResult]:
|
||||
async def _add_context_to_results(
|
||||
self, results: List[SearchResult], tool_ctx
|
||||
) -> List[SearchResult]:
|
||||
"""Add function/method context to results."""
|
||||
# This is a simplified version - you could enhance with full AST context
|
||||
return await self._add_function_context(results, tool_ctx)
|
||||
|
||||
def _format_batch_results(self, results: BatchSearchResults, query_info: List[Dict]) -> str:
|
||||
def _format_batch_results(
|
||||
self, results: BatchSearchResults, query_info: List[Dict]
|
||||
) -> str:
|
||||
"""Format batch search results for display."""
|
||||
output = []
|
||||
|
||||
@@ -781,7 +859,9 @@ Perfect for comprehensive code analysis and refactoring tasks."""
|
||||
score_str = f"[{result.search_type.value} {result.score:.2f}]"
|
||||
|
||||
if result.line_number:
|
||||
output.append(f" {result.line_number:>4}: {score_str} {result.content}")
|
||||
output.append(
|
||||
f" {result.line_number:>4}: {score_str} {result.content}"
|
||||
)
|
||||
else:
|
||||
output.append(f" {score_str} {result.content}")
|
||||
|
||||
|
||||
@@ -159,11 +159,15 @@ Only works within allowed directories."""
|
||||
# Process based on whether path is a file or directory
|
||||
if input_path.is_file():
|
||||
# Single file search
|
||||
if file_pattern == "*" or fnmatch.fnmatch(input_path.name, file_pattern):
|
||||
if file_pattern == "*" or fnmatch.fnmatch(
|
||||
input_path.name, file_pattern
|
||||
):
|
||||
matching_files.append(input_path)
|
||||
await tool_ctx.info(f"Searching single file: {path}")
|
||||
else:
|
||||
await tool_ctx.info(f"File does not match pattern '{file_pattern}': {path}")
|
||||
await tool_ctx.info(
|
||||
f"File does not match pattern '{file_pattern}': {path}"
|
||||
)
|
||||
return f"File does not match pattern '{file_pattern}': {path}"
|
||||
elif input_path.is_dir():
|
||||
# Directory search - optimized file finding
|
||||
@@ -182,7 +186,9 @@ Only works within allowed directories."""
|
||||
for entry in input_path.rglob("*"):
|
||||
entry_path = str(entry)
|
||||
if entry_path in allowed_paths and entry.is_file():
|
||||
if file_pattern == "*" or fnmatch.fnmatch(entry.name, file_pattern):
|
||||
if file_pattern == "*" or fnmatch.fnmatch(
|
||||
entry.name, file_pattern
|
||||
):
|
||||
matching_files.append(entry)
|
||||
|
||||
await tool_ctx.info(f"Found {len(matching_files)} matching files")
|
||||
@@ -245,7 +251,9 @@ Only works within allowed directories."""
|
||||
)
|
||||
message = f"Dry run: {replacements_made} replacements of '{pattern}' with '{replacement}' would be made in {files_modified} files:"
|
||||
else:
|
||||
await tool_ctx.info(f"Made {replacements_made} replacements in {files_modified} files")
|
||||
await tool_ctx.info(
|
||||
f"Made {replacements_made} replacements in {files_modified} files"
|
||||
)
|
||||
message = f"Made {replacements_made} replacements of '{pattern}' with '{replacement}' in {files_modified} files:"
|
||||
|
||||
return message + "\n\n" + "\n".join(results)
|
||||
|
||||
@@ -166,8 +166,16 @@ diff a.json b.json --ignore-whitespace"""
|
||||
output.extend(diff_lines)
|
||||
|
||||
# Add summary
|
||||
additions = sum(1 for line in diff_lines if line.startswith("+") and not line.startswith("+++"))
|
||||
deletions = sum(1 for line in diff_lines if line.startswith("-") and not line.startswith("---"))
|
||||
additions = sum(
|
||||
1
|
||||
for line in diff_lines
|
||||
if line.startswith("+") and not line.startswith("+++")
|
||||
)
|
||||
deletions = sum(
|
||||
1
|
||||
for line in diff_lines
|
||||
if line.startswith("-") and not line.startswith("---")
|
||||
)
|
||||
|
||||
output.append("")
|
||||
output.append("=" * 60)
|
||||
|
||||
@@ -112,7 +112,9 @@ requested. Only works within allowed directories."""
|
||||
await tool_ctx.error(path_validation.error_message)
|
||||
return f"Error: {path_validation.error_message}"
|
||||
|
||||
await tool_ctx.info(f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})")
|
||||
await tool_ctx.info(
|
||||
f"Getting directory tree: {path} (depth: {depth}, include_filtered: {include_filtered})"
|
||||
)
|
||||
|
||||
# Check if path is allowed
|
||||
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
||||
@@ -151,7 +153,9 @@ requested. Only works within allowed directories."""
|
||||
}
|
||||
|
||||
# Log filtering settings
|
||||
await tool_ctx.info(f"Directory tree filtering: include_filtered={include_filtered}")
|
||||
await tool_ctx.info(
|
||||
f"Directory tree filtering: include_filtered={include_filtered}"
|
||||
)
|
||||
|
||||
# Check if a directory should be filtered
|
||||
def should_filter(current_path: Path) -> bool:
|
||||
@@ -161,7 +165,9 @@ requested. Only works within allowed directories."""
|
||||
return False
|
||||
|
||||
# Filter based on directory name if filtering is enabled
|
||||
return current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
||||
return (
|
||||
current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
||||
)
|
||||
|
||||
# Track stats for summary
|
||||
stats = {
|
||||
@@ -172,7 +178,9 @@ requested. Only works within allowed directories."""
|
||||
}
|
||||
|
||||
# Build the tree recursively
|
||||
async def build_tree(current_path: Path, current_depth: int = 0) -> list[dict[str, Any]]:
|
||||
async def build_tree(
|
||||
current_path: Path, current_depth: int = 0
|
||||
) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
# Skip processing if path isn't allowed
|
||||
@@ -181,7 +189,9 @@ requested. Only works within allowed directories."""
|
||||
|
||||
try:
|
||||
# Sort entries: directories first, then files alphabetically
|
||||
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
||||
entries = sorted(
|
||||
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
# Skip entries that aren't allowed
|
||||
@@ -210,7 +220,9 @@ requested. Only works within allowed directories."""
|
||||
continue
|
||||
|
||||
# Process children recursively with depth increment
|
||||
entry_data["children"] = await build_tree(entry, current_depth + 1)
|
||||
entry_data["children"] = await build_tree(
|
||||
entry, current_depth + 1
|
||||
)
|
||||
result.append(entry_data)
|
||||
else:
|
||||
# Files should be at the same level check as directories
|
||||
@@ -225,7 +237,9 @@ requested. Only works within allowed directories."""
|
||||
return result
|
||||
|
||||
# Format the tree as a simple indented structure
|
||||
def format_tree(tree_data: list[dict[str, Any]], level: int = 0) -> list[str]:
|
||||
def format_tree(
|
||||
tree_data: list[dict[str, Any]], level: int = 0
|
||||
) -> list[str]:
|
||||
lines = []
|
||||
|
||||
for item in tree_data:
|
||||
@@ -235,7 +249,9 @@ requested. Only works within allowed directories."""
|
||||
# Format based on type
|
||||
if item["type"] == "directory":
|
||||
if "skipped" in item:
|
||||
lines.append(f"{indent}{item['name']}/ [skipped - {item['skipped']}]")
|
||||
lines.append(
|
||||
f"{indent}{item['name']}/ [skipped - {item['skipped']}]"
|
||||
)
|
||||
else:
|
||||
lines.append(f"{indent}{item['name']}/")
|
||||
# Add children with increased indentation if present
|
||||
@@ -294,4 +310,6 @@ requested. Only works within allowed directories."""
|
||||
depth: Depth = 3,
|
||||
include_filtered: IncludeFiltered = False,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, path=path, depth=depth, include_filtered=include_filtered)
|
||||
return await tool_self.call(
|
||||
ctx, path=path, depth=depth, include_filtered=include_filtered
|
||||
)
|
||||
|
||||
@@ -152,7 +152,9 @@ Returns nextCursor if more entries are available."""
|
||||
await tool_ctx.error(path_validation.error_message)
|
||||
return {"error": path_validation.error_message}
|
||||
|
||||
await tool_ctx.info(f"Getting paginated directory tree: {path} (depth: {depth}, page_size: {page_size})")
|
||||
await tool_ctx.info(
|
||||
f"Getting paginated directory tree: {path} (depth: {depth}, page_size: {page_size})"
|
||||
)
|
||||
|
||||
# Check if path is allowed
|
||||
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
||||
@@ -194,27 +196,35 @@ Returns nextCursor if more entries are available."""
|
||||
def should_filter(current_path: Path) -> bool:
|
||||
if str(current_path.absolute()) == str(dir_path.absolute()):
|
||||
return False
|
||||
return current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
||||
return (
|
||||
current_path.name in FILTERED_DIRECTORIES and not include_filtered
|
||||
)
|
||||
|
||||
# Collect all entries in a flat list for pagination
|
||||
all_entries: List[Dict[str, Any]] = []
|
||||
|
||||
# Build the tree and collect entries
|
||||
def collect_entries(current_path: Path, current_depth: int = 0, parent_path: str = "") -> None:
|
||||
def collect_entries(
|
||||
current_path: Path, current_depth: int = 0, parent_path: str = ""
|
||||
) -> None:
|
||||
"""Collect entries in a flat list for pagination."""
|
||||
if not self.is_path_allowed(str(current_path)):
|
||||
return
|
||||
|
||||
try:
|
||||
# Sort entries: directories first, then files alphabetically
|
||||
entries = sorted(current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name))
|
||||
entries = sorted(
|
||||
current_path.iterdir(), key=lambda x: (not x.is_dir(), x.name)
|
||||
)
|
||||
|
||||
for entry in entries:
|
||||
if not self.is_path_allowed(str(entry)):
|
||||
continue
|
||||
|
||||
# Calculate relative path for display
|
||||
relative_path = f"{parent_path}/{entry.name}" if parent_path else entry.name
|
||||
relative_path = (
|
||||
f"{parent_path}/{entry.name}" if parent_path else entry.name
|
||||
)
|
||||
|
||||
if entry.is_dir():
|
||||
entry_data: Dict[str, Any] = {
|
||||
|
||||
@@ -121,7 +121,9 @@ Usage:
|
||||
# Empty old_string is valid when creating a new file
|
||||
file_exists = Path(file_path).exists()
|
||||
if file_exists and old_string.strip() == "":
|
||||
await tool_ctx.error("Parameter 'old_string' cannot be empty for existing files")
|
||||
await tool_ctx.error(
|
||||
"Parameter 'old_string' cannot be empty for existing files"
|
||||
)
|
||||
return "Error: Parameter 'old_string' cannot be empty for existing files"
|
||||
|
||||
if (
|
||||
@@ -129,8 +131,12 @@ Usage:
|
||||
or not isinstance(expected_replacements, (int, float))
|
||||
or expected_replacements < 0
|
||||
):
|
||||
await tool_ctx.error("Parameter 'expected_replacements' must be a non-negative number")
|
||||
return "Error: Parameter 'expected_replacements' must be a non-negative number"
|
||||
await tool_ctx.error(
|
||||
"Parameter 'expected_replacements' must be a non-negative number"
|
||||
)
|
||||
return (
|
||||
"Error: Parameter 'expected_replacements' must be a non-negative number"
|
||||
)
|
||||
|
||||
await tool_ctx.info(f"Editing file: {file_path}")
|
||||
|
||||
@@ -158,7 +164,9 @@ Usage:
|
||||
f.write(new_string)
|
||||
|
||||
await tool_ctx.info(f"Successfully created file: {file_path}")
|
||||
return f"Successfully created file: {file_path} ({len(new_string)} bytes)"
|
||||
return (
|
||||
f"Successfully created file: {file_path} ({len(new_string)} bytes)"
|
||||
)
|
||||
|
||||
# Check file exists for non-creation operations
|
||||
exists, error_msg = await self.check_path_exists(file_path, tool_ctx)
|
||||
@@ -191,7 +199,9 @@ Usage:
|
||||
modified_content = original_content.replace(old_string, new_string)
|
||||
else:
|
||||
# If we can't find the exact string, report an error
|
||||
await tool_ctx.error("The specified old_string was not found in the file content")
|
||||
await tool_ctx.error(
|
||||
"The specified old_string was not found in the file content"
|
||||
)
|
||||
return "Error: The specified old_string was not found in the file content. Please check that it matches exactly, including all whitespace and indentation."
|
||||
|
||||
# Generate diff
|
||||
@@ -216,7 +226,9 @@ Usage:
|
||||
num_backticks += 1
|
||||
|
||||
# Format diff with appropriate number of backticks
|
||||
formatted_diff = f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
||||
formatted_diff = (
|
||||
f"```{num_backticks}diff\n{diff_text}```{num_backticks}\n"
|
||||
)
|
||||
|
||||
# Write the file if there are changes
|
||||
if diff_text:
|
||||
|
||||
@@ -199,7 +199,9 @@ Fast, intuitive file content search."""
|
||||
# Fallback
|
||||
selected_backend = "grep"
|
||||
|
||||
await tool_ctx.info(f"Using {selected_backend} to search for '{pattern}' in {path}")
|
||||
await tool_ctx.info(
|
||||
f"Using {selected_backend} to search for '{pattern}' in {path}"
|
||||
)
|
||||
|
||||
# Execute search
|
||||
if selected_backend == "rg":
|
||||
@@ -457,7 +459,9 @@ Fast, intuitive file content search."""
|
||||
context_lines = []
|
||||
for j in range(start, end):
|
||||
prefix = ":" if j + 1 == i else "-"
|
||||
context_lines.append(f"{file_path}:{j + 1}{prefix}{lines[j].rstrip()}")
|
||||
context_lines.append(
|
||||
f"{file_path}:{j + 1}{prefix}{lines[j].rstrip()}"
|
||||
)
|
||||
results.extend(context_lines)
|
||||
results.append("") # Separator
|
||||
else:
|
||||
@@ -478,7 +482,9 @@ Fast, intuitive file content search."""
|
||||
await tool_ctx.error(f"Error in fallback grep: {str(e)}")
|
||||
return f"Error in fallback grep: {str(e)}"
|
||||
|
||||
def _match_file_pattern(self, filename: str, include: Optional[str], exclude: Optional[str]) -> bool:
|
||||
def _match_file_pattern(
|
||||
self, filename: str, include: Optional[str], exclude: Optional[str]
|
||||
) -> bool:
|
||||
"""Check if filename matches include/exclude patterns."""
|
||||
if include and not fnmatch.fnmatch(filename, include):
|
||||
return False
|
||||
|
||||
@@ -335,7 +335,9 @@ For database search, use 'sql_search' or 'vector_search'.
|
||||
return f"No files found matching '{pattern}' (using fallback search)"
|
||||
|
||||
# Format output
|
||||
output = [f"Found {len(results)} file(s) matching '{pattern}' (using fallback search):"]
|
||||
output = [
|
||||
f"Found {len(results)} file(s) matching '{pattern}' (using fallback search):"
|
||||
]
|
||||
output.append("")
|
||||
|
||||
for filepath in sorted(results):
|
||||
@@ -344,7 +346,9 @@ For database search, use 'sql_search' or 'vector_search'.
|
||||
if count >= max_results:
|
||||
output.append(f"\n... (showing first {max_results} results)")
|
||||
|
||||
output.append("\nNote: Install 'ffind' for faster searching: pip install ffind")
|
||||
output.append(
|
||||
"\nNote: Install 'ffind' for faster searching: pip install ffind"
|
||||
)
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
@@ -256,7 +256,9 @@ Examples:
|
||||
tool_ctx,
|
||||
)
|
||||
elif search_type == "blame":
|
||||
return await self._search_blame(abs_path, pattern, case_sensitive, file_pattern, tool_ctx)
|
||||
return await self._search_blame(
|
||||
abs_path, pattern, case_sensitive, file_pattern, tool_ctx
|
||||
)
|
||||
else:
|
||||
return f"Unknown search type: {search_type}"
|
||||
|
||||
@@ -305,7 +307,9 @@ Examples:
|
||||
elif result.returncode == 1:
|
||||
return f"No matches found for pattern: {pattern}"
|
||||
else:
|
||||
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode, cmd, result.stdout, result.stderr
|
||||
)
|
||||
|
||||
async def _search_commits(
|
||||
self,
|
||||
@@ -349,11 +353,16 @@ Examples:
|
||||
lines = result.stdout.strip().split("\n")
|
||||
if lines and lines[0]:
|
||||
await tool_ctx.info(f"Found {len(lines)} commits")
|
||||
return f"Found {len(lines)} commits matching '{pattern}':\n\n" + result.stdout
|
||||
return (
|
||||
f"Found {len(lines)} commits matching '{pattern}':\n\n"
|
||||
+ result.stdout
|
||||
)
|
||||
else:
|
||||
return f"No commits found matching: {pattern}"
|
||||
else:
|
||||
raise subprocess.CalledProcessError(result.returncode, cmd, result.stdout, result.stderr)
|
||||
raise subprocess.CalledProcessError(
|
||||
result.returncode, cmd, result.stdout, result.stderr
|
||||
)
|
||||
|
||||
async def _search_diff(
|
||||
self,
|
||||
@@ -378,7 +387,8 @@ Examples:
|
||||
import re
|
||||
|
||||
case_insensitive_pattern = "".join(
|
||||
f"[{c.upper()}{c.lower()}]" if c.isalpha() else re.escape(c) for c in pattern
|
||||
f"[{c.upper()}{c.lower()}]" if c.isalpha() else re.escape(c)
|
||||
for c in pattern
|
||||
)
|
||||
search_flag = f"-G{case_insensitive_pattern}"
|
||||
|
||||
@@ -405,7 +415,9 @@ Examples:
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
# Parse and highlight matching lines
|
||||
output = self._highlight_diff_matches(result.stdout, pattern, case_sensitive)
|
||||
output = self._highlight_diff_matches(
|
||||
result.stdout, pattern, case_sensitive
|
||||
)
|
||||
matches = output.count("commit ")
|
||||
await tool_ctx.info(f"Found {matches} commits with changes")
|
||||
return f"Found {matches} commits with changes matching '{pattern}':\n\n{output}"
|
||||
@@ -493,8 +505,9 @@ Examples:
|
||||
|
||||
if all_matches:
|
||||
await tool_ctx.info(f"Found {len(all_matches)} matching lines")
|
||||
return f"Found {len(all_matches)} lines matching '{pattern}':\n\n" + "\n".join(
|
||||
all_matches[:50]
|
||||
return (
|
||||
f"Found {len(all_matches)} lines matching '{pattern}':\n\n"
|
||||
+ "\n".join(all_matches[:50])
|
||||
) # Limit output
|
||||
else:
|
||||
return f"No lines found matching: {pattern}"
|
||||
@@ -521,7 +534,9 @@ Examples:
|
||||
|
||||
return f"Found matches for '{pattern}':\n" + "\n".join(output)
|
||||
|
||||
def _highlight_diff_matches(self, diff_output: str, pattern: str, case_sensitive: bool) -> str:
|
||||
def _highlight_diff_matches(
|
||||
self, diff_output: str, pattern: str, case_sensitive: bool
|
||||
) -> str:
|
||||
"""Highlight matching lines in diff output."""
|
||||
lines = diff_output.split("\n")
|
||||
output = []
|
||||
|
||||
@@ -118,7 +118,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
# Special case for tests: direct file path with include pattern that doesn't match
|
||||
if Path(path).is_file() and include_pattern and include_pattern != "*":
|
||||
if not fnmatch.fnmatch(Path(path).name, include_pattern):
|
||||
await tool_ctx.info(f"File does not match pattern '{include_pattern}': {path}")
|
||||
await tool_ctx.info(
|
||||
f"File does not match pattern '{include_pattern}': {path}"
|
||||
)
|
||||
return f"File does not match pattern '{include_pattern}': {path}"
|
||||
|
||||
cmd = ["rg", "--json", pattern]
|
||||
@@ -142,7 +144,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
|
||||
if process.returncode != 0 and process.returncode != 1:
|
||||
# rg returns 1 when no matches are found, which is not an error
|
||||
await tool_ctx.error(f"ripgrep failed with exit code {process.returncode}: {stderr.decode()}")
|
||||
await tool_ctx.error(
|
||||
f"ripgrep failed with exit code {process.returncode}: {stderr.decode()}"
|
||||
)
|
||||
return f"Error executing ripgrep: {stderr.decode()}"
|
||||
|
||||
# Parse the JSON output
|
||||
@@ -178,7 +182,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
if data.get("type") == "match":
|
||||
path = data.get("data", {}).get("path", {}).get("text", "")
|
||||
line_number = data.get("data", {}).get("line_number", 0)
|
||||
line_text = data.get("data", {}).get("lines", {}).get("text", "").rstrip()
|
||||
line_text = (
|
||||
data.get("data", {}).get("lines", {}).get("text", "").rstrip()
|
||||
)
|
||||
|
||||
if path not in file_results:
|
||||
file_results[path] = []
|
||||
@@ -245,7 +251,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
await tool_ctx.info(f"Searching single file: {path}")
|
||||
else:
|
||||
# File doesn't match the pattern, return immediately
|
||||
await tool_ctx.info(f"File does not match pattern '{include_pattern}': {path}")
|
||||
await tool_ctx.info(
|
||||
f"File does not match pattern '{include_pattern}': {path}"
|
||||
)
|
||||
return f"File does not match pattern '{include_pattern}': {path}"
|
||||
elif input_path.is_dir():
|
||||
# Directory search - find all files
|
||||
@@ -282,7 +290,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
if input_path.is_file():
|
||||
await tool_ctx.info(f"Searching file: {path}")
|
||||
else:
|
||||
await tool_ctx.info(f"Searching through {total_files} files in directory")
|
||||
await tool_ctx.info(
|
||||
f"Searching through {total_files} files in directory"
|
||||
)
|
||||
|
||||
# Set up for parallel processing
|
||||
results: list[str] = []
|
||||
@@ -304,14 +314,18 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
if re.search(pattern, line):
|
||||
file_results.append(f"{file_path}:{line_num}: {line.rstrip()}")
|
||||
file_results.append(
|
||||
f"{file_path}:{line_num}: {line.rstrip()}"
|
||||
)
|
||||
matches_found += 1
|
||||
files_processed += 1
|
||||
except UnicodeDecodeError:
|
||||
# Skip binary files
|
||||
files_processed += 1
|
||||
except Exception as e:
|
||||
await tool_ctx.warning(f"Error reading {file_path}: {str(e)}")
|
||||
await tool_ctx.warning(
|
||||
f"Error reading {file_path}: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
await tool_ctx.warning(f"Error processing {file_path}: {str(e)}")
|
||||
|
||||
@@ -415,7 +429,9 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
truncation_message="\n\n[Grep results truncated due to token limit. Use more specific patterns or paths to reduce output.]",
|
||||
)
|
||||
else:
|
||||
await tool_ctx.info("ripgrep is not installed, using fallback implementation")
|
||||
await tool_ctx.info(
|
||||
"ripgrep is not installed, using fallback implementation"
|
||||
)
|
||||
result = await self.fallback_grep(pattern, path, tool_ctx, include)
|
||||
return truncate_response(
|
||||
result,
|
||||
@@ -446,4 +462,6 @@ When you are doing an open ended search that may require multiple rounds of glob
|
||||
include: Include,
|
||||
) -> str:
|
||||
# Use 'include' parameter if provided, otherwise fall back to 'file_pattern'
|
||||
return await tool_self.call(ctx, pattern=pattern, path=path, include=include)
|
||||
return await tool_self.call(
|
||||
ctx, pattern=pattern, path=path, include=include
|
||||
)
|
||||
|
||||
@@ -170,11 +170,15 @@ If you want to create a new file, use:
|
||||
expected_replacements = edit.get("expected_replacements", 1)
|
||||
|
||||
if old_string is None:
|
||||
await tool_ctx.error(f"Parameter 'old_string' in edit at index {i} is required but was None")
|
||||
await tool_ctx.error(
|
||||
f"Parameter 'old_string' in edit at index {i} is required but was None"
|
||||
)
|
||||
return f"Error: Parameter 'old_string' in edit at index {i} is required but was None"
|
||||
|
||||
if new_string is None:
|
||||
await tool_ctx.error(f"Parameter 'new_string' in edit at index {i} is required but was None")
|
||||
await tool_ctx.error(
|
||||
f"Parameter 'new_string' in edit at index {i} is required but was None"
|
||||
)
|
||||
return f"Error: Parameter 'new_string' in edit at index {i} is required but was None"
|
||||
|
||||
if (
|
||||
@@ -188,8 +192,12 @@ If you want to create a new file, use:
|
||||
return f"Error: Parameter 'expected_replacements' in edit at index {i} must be a non-negative number"
|
||||
|
||||
if old_string == new_string:
|
||||
await tool_ctx.error(f"Edit at index {i}: old_string and new_string are identical")
|
||||
return f"Error: Edit at index {i}: old_string and new_string are identical"
|
||||
await tool_ctx.error(
|
||||
f"Edit at index {i}: old_string and new_string are identical"
|
||||
)
|
||||
return (
|
||||
f"Error: Edit at index {i}: old_string and new_string are identical"
|
||||
)
|
||||
|
||||
await tool_ctx.info(f"Applying {len(edits)} edits to file: {file_path}")
|
||||
|
||||
@@ -253,7 +261,9 @@ If you want to create a new file, use:
|
||||
|
||||
# Check if old_string exists in current content
|
||||
if old_string not in current_content:
|
||||
edit_index = i + 1 if not creation_mode else i + 2 # Adjust for display
|
||||
edit_index = (
|
||||
i + 1 if not creation_mode else i + 2
|
||||
) # Adjust for display
|
||||
await tool_ctx.error(
|
||||
f"Edit {edit_index}: The specified old_string was not found in the file content"
|
||||
)
|
||||
@@ -264,7 +274,9 @@ If you want to create a new file, use:
|
||||
|
||||
# Check if the number of occurrences matches expected_replacements
|
||||
if occurrences != expected_replacements:
|
||||
edit_index = i + 1 if not creation_mode else i + 2 # Adjust for display
|
||||
edit_index = (
|
||||
i + 1 if not creation_mode else i + 2
|
||||
) # Adjust for display
|
||||
await tool_ctx.error(
|
||||
f"Edit {edit_index}: Found {occurrences} occurrences of the specified old_string, but expected {expected_replacements}"
|
||||
)
|
||||
|
||||
@@ -118,12 +118,18 @@ Usage:
|
||||
await tool_ctx.error("Parameter 'file_path' is required but was None")
|
||||
return "Error: Parameter 'file_path' is required but was None"
|
||||
|
||||
await tool_ctx.info(f"Reading file: {file_path} (offset: {offset}, limit: {limit})")
|
||||
await tool_ctx.info(
|
||||
f"Reading file: {file_path} (offset: {offset}, limit: {limit})"
|
||||
)
|
||||
|
||||
# Check if path is allowed
|
||||
if not self.is_path_allowed(file_path):
|
||||
await tool_ctx.error(f"Access denied - path outside allowed directories: {file_path}")
|
||||
return f"Error: Access denied - path outside allowed directories: {file_path}"
|
||||
await tool_ctx.error(
|
||||
f"Access denied - path outside allowed directories: {file_path}"
|
||||
)
|
||||
return (
|
||||
f"Error: Access denied - path outside allowed directories: {file_path}"
|
||||
)
|
||||
|
||||
try:
|
||||
file_path_obj = Path(file_path)
|
||||
@@ -160,7 +166,10 @@ Usage:
|
||||
|
||||
# Truncate long lines
|
||||
if len(line) > self.MAX_LINE_LENGTH:
|
||||
line = line[: self.MAX_LINE_LENGTH] + self.LINE_TRUNCATION_INDICATOR
|
||||
line = (
|
||||
line[: self.MAX_LINE_LENGTH]
|
||||
+ self.LINE_TRUNCATION_INDICATOR
|
||||
)
|
||||
|
||||
# Add line with line number (1-based)
|
||||
lines.append(f"{i + 1:6d} {line.rstrip()}")
|
||||
@@ -187,12 +196,17 @@ Usage:
|
||||
|
||||
# Truncate long lines
|
||||
if len(line) > self.MAX_LINE_LENGTH:
|
||||
line = line[: self.MAX_LINE_LENGTH] + self.LINE_TRUNCATION_INDICATOR
|
||||
line = (
|
||||
line[: self.MAX_LINE_LENGTH]
|
||||
+ self.LINE_TRUNCATION_INDICATOR
|
||||
)
|
||||
|
||||
# Add line with line number (1-based)
|
||||
lines.append(f"{i + 1:6d} {line.rstrip()}")
|
||||
|
||||
await tool_ctx.warning(f"File read with latin-1 encoding: {file_path}")
|
||||
await tool_ctx.warning(
|
||||
f"File read with latin-1 encoding: {file_path}"
|
||||
)
|
||||
|
||||
except Exception:
|
||||
await tool_ctx.error(f"Cannot read binary file: {file_path}")
|
||||
@@ -222,7 +236,9 @@ Usage:
|
||||
await tool_ctx.error(f"Error reading file: {str(e)}")
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
async def run(self, ctx: MCPContext, file_path: str, offset: int = 0, limit: int = 2000) -> str:
|
||||
async def run(
|
||||
self, ctx: MCPContext, file_path: str, offset: int = 0, limit: int = 2000
|
||||
) -> str:
|
||||
"""Run method for backwards compatibility with test scripts.
|
||||
|
||||
Args:
|
||||
@@ -255,4 +271,6 @@ Usage:
|
||||
offset: Offset = 0,
|
||||
limit: Limit = 2000,
|
||||
) -> str:
|
||||
return await tool_self.call(ctx, file_path=file_path, offset=offset, limit=limit)
|
||||
return await tool_self.call(
|
||||
ctx, file_path=file_path, offset=offset, limit=limit
|
||||
)
|
||||
|
||||
@@ -142,7 +142,9 @@ understand project-specific requirements and preferences."""
|
||||
found_configs.append(
|
||||
{
|
||||
"path": str(config_path),
|
||||
"relative_path": str(config_path.relative_to(start_path)),
|
||||
"relative_path": str(
|
||||
config_path.relative_to(start_path)
|
||||
),
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
}
|
||||
@@ -150,7 +152,9 @@ understand project-specific requirements and preferences."""
|
||||
|
||||
await tool_ctx.info(f"Found configuration: {config_path}")
|
||||
except Exception as e:
|
||||
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
|
||||
await tool_ctx.warning(
|
||||
f"Could not read {config_path}: {str(e)}"
|
||||
)
|
||||
|
||||
# Check if we've reached the root or a git repository root
|
||||
if current_path.parent == current_path:
|
||||
@@ -165,7 +169,8 @@ understand project-specific requirements and preferences."""
|
||||
if (
|
||||
config_path.exists()
|
||||
and config_path.is_file()
|
||||
and str(config_path) not in [c["path"] for c in found_configs]
|
||||
and str(config_path)
|
||||
not in [c["path"] for c in found_configs]
|
||||
):
|
||||
try:
|
||||
if self.is_path_allowed(str(config_path)):
|
||||
@@ -175,15 +180,21 @@ understand project-specific requirements and preferences."""
|
||||
found_configs.append(
|
||||
{
|
||||
"path": str(config_path),
|
||||
"relative_path": str(config_path.relative_to(start_path)),
|
||||
"relative_path": str(
|
||||
config_path.relative_to(start_path)
|
||||
),
|
||||
"content": content,
|
||||
"size": len(content),
|
||||
}
|
||||
)
|
||||
|
||||
await tool_ctx.info(f"Found configuration: {config_path}")
|
||||
await tool_ctx.info(
|
||||
f"Found configuration: {config_path}"
|
||||
)
|
||||
except Exception as e:
|
||||
await tool_ctx.warning(f"Could not read {config_path}: {str(e)}")
|
||||
await tool_ctx.warning(
|
||||
f"Could not read {config_path}: {str(e)}"
|
||||
)
|
||||
break
|
||||
|
||||
# Move to parent directory
|
||||
|
||||
@@ -248,7 +248,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
) -> List[SearchResult]:
|
||||
"""Run grep search and parse results."""
|
||||
try:
|
||||
result = await self.grep_tool.call(tool_ctx.mcp_context, pattern=pattern, path=path, include=include)
|
||||
result = await self.grep_tool.call(
|
||||
tool_ctx.mcp_context, pattern=pattern, path=path, include=include
|
||||
)
|
||||
|
||||
results = []
|
||||
if "Found" in result and "matches" in result:
|
||||
@@ -279,7 +281,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
await tool_ctx.error(f"Grep search failed: {e}")
|
||||
return []
|
||||
|
||||
async def _run_grep_ast_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_grep_ast_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Run AST-aware search and parse results."""
|
||||
try:
|
||||
result = await self.grep_ast_tool.call(
|
||||
@@ -313,7 +317,11 @@ This is the recommended search tool for comprehensive results."""
|
||||
content=content,
|
||||
search_type=SearchType.GREP_AST,
|
||||
score=0.95, # High score for AST matches
|
||||
context=(" > ".join(current_context) if current_context else None),
|
||||
context=(
|
||||
" > ".join(current_context)
|
||||
if current_context
|
||||
else None
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -331,7 +339,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
await tool_ctx.error(f"AST search failed: {e}")
|
||||
return []
|
||||
|
||||
async def _run_vector_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_vector_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Run semantic vector search."""
|
||||
if not self.vector_tool:
|
||||
return []
|
||||
@@ -389,7 +399,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
await tool_ctx.error(f"Vector search failed: {e}")
|
||||
return []
|
||||
|
||||
async def _run_git_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_git_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Run git history search."""
|
||||
try:
|
||||
# Search in both content and commits
|
||||
@@ -428,7 +440,11 @@ This is the recommended search tool for comprehensive results."""
|
||||
SearchResult(
|
||||
file_path=parts[0].strip(),
|
||||
line_number=None,
|
||||
content=(parts[-1].strip() if len(parts) > 2 else line),
|
||||
content=(
|
||||
parts[-1].strip()
|
||||
if len(parts) > 2
|
||||
else line
|
||||
),
|
||||
search_type=SearchType.GIT,
|
||||
score=0.8, # Good score for git matches
|
||||
)
|
||||
@@ -444,7 +460,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
await tool_ctx.error(f"Git search failed: {e}")
|
||||
return []
|
||||
|
||||
async def _run_symbol_search(self, pattern: str, path: str, tool_ctx, max_results: int) -> List[SearchResult]:
|
||||
async def _run_symbol_search(
|
||||
self, pattern: str, path: str, tool_ctx, max_results: int
|
||||
) -> List[SearchResult]:
|
||||
"""Search for symbol definitions using grep with specific patterns."""
|
||||
try:
|
||||
# Create patterns for common symbol definitions
|
||||
@@ -459,7 +477,11 @@ This is the recommended search tool for comprehensive results."""
|
||||
# Run grep searches in parallel for each pattern
|
||||
tasks = []
|
||||
for sp in symbol_patterns:
|
||||
tasks.append(self.grep_tool.call(tool_ctx.mcp_context, pattern=sp, path=path, include="*"))
|
||||
tasks.append(
|
||||
self.grep_tool.call(
|
||||
tool_ctx.mcp_context, pattern=sp, path=path, include="*"
|
||||
)
|
||||
)
|
||||
|
||||
grep_results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
@@ -496,7 +518,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
await tool_ctx.error(f"Symbol search failed: {e}")
|
||||
return []
|
||||
|
||||
def _deduplicate_results(self, all_results: List[SearchResult]) -> List[SearchResult]:
|
||||
def _deduplicate_results(
|
||||
self, all_results: List[SearchResult]
|
||||
) -> List[SearchResult]:
|
||||
"""Deduplicate results, keeping the highest scoring version."""
|
||||
seen = {}
|
||||
|
||||
@@ -523,7 +547,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
}
|
||||
|
||||
# Sort by score (descending) and then by type priority
|
||||
results.sort(key=lambda r: (r.score, type_priority.get(r.search_type, 0)), reverse=True)
|
||||
results.sort(
|
||||
key=lambda r: (r.score, type_priority.get(r.search_type, 0)), reverse=True
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -573,26 +599,42 @@ This is the recommended search tool for comprehensive results."""
|
||||
search_names = []
|
||||
|
||||
if params.get("enable_grep", True) and pattern_analysis["use_grep"]:
|
||||
search_tasks.append(self._run_grep_search(pattern, path, include, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_grep_search(pattern, path, include, tool_ctx, max_results)
|
||||
)
|
||||
search_names.append("grep")
|
||||
|
||||
if params.get("enable_grep_ast", True) and pattern_analysis["use_grep_ast"]:
|
||||
search_tasks.append(self._run_grep_ast_search(pattern, path, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_grep_ast_search(pattern, path, tool_ctx, max_results)
|
||||
)
|
||||
search_names.append("grep_ast")
|
||||
|
||||
if params.get("enable_vector", True) and self.vector_tool and pattern_analysis["use_vector"]:
|
||||
search_tasks.append(self._run_vector_search(pattern, path, tool_ctx, max_results))
|
||||
if (
|
||||
params.get("enable_vector", True)
|
||||
and self.vector_tool
|
||||
and pattern_analysis["use_vector"]
|
||||
):
|
||||
search_tasks.append(
|
||||
self._run_vector_search(pattern, path, tool_ctx, max_results)
|
||||
)
|
||||
search_names.append("vector")
|
||||
|
||||
if params.get("enable_git", True) and pattern_analysis["use_git"]:
|
||||
search_tasks.append(self._run_git_search(pattern, path, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_git_search(pattern, path, tool_ctx, max_results)
|
||||
)
|
||||
search_names.append("git")
|
||||
|
||||
if params.get("enable_symbol", True) and pattern_analysis["use_symbol"]:
|
||||
search_tasks.append(self._run_symbol_search(pattern, path, tool_ctx, max_results))
|
||||
search_tasks.append(
|
||||
self._run_symbol_search(pattern, path, tool_ctx, max_results)
|
||||
)
|
||||
search_names.append("symbol")
|
||||
|
||||
await tool_ctx.info(f"Running {len(search_tasks)} search types in parallel: {', '.join(search_names)}")
|
||||
await tool_ctx.info(
|
||||
f"Running {len(search_tasks)} search types in parallel: {', '.join(search_names)}"
|
||||
)
|
||||
|
||||
# Run all searches in parallel
|
||||
search_results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
@@ -677,7 +719,9 @@ This is the recommended search tool for comprehensive results."""
|
||||
score_str = f"[{result.search_type.value} {result.score:.2f}]"
|
||||
|
||||
if result.line_number:
|
||||
output.append(f" {result.line_number:>4}: {score_str} {result.content}")
|
||||
output.append(
|
||||
f" {result.line_number:>4}: {score_str} {result.content}"
|
||||
)
|
||||
else:
|
||||
output.append(f" {score_str} {result.content}")
|
||||
|
||||
|
||||
@@ -141,7 +141,9 @@ Finds code structures (functions, classes, methods) with full context."""
|
||||
# Route to appropriate handler
|
||||
if action == "search":
|
||||
return await self._handle_search(params, tool_ctx)
|
||||
elif action == "ast" or action == "grep_ast": # Support both for backward compatibility
|
||||
elif (
|
||||
action == "ast" or action == "grep_ast"
|
||||
): # Support both for backward compatibility
|
||||
return await self._handle_ast(params, tool_ctx)
|
||||
elif action == "index":
|
||||
return await self._handle_index(params, tool_ctx)
|
||||
@@ -216,7 +218,9 @@ Finds code structures (functions, classes, methods) with full context."""
|
||||
output = tc.format()
|
||||
else:
|
||||
# Just show matching lines
|
||||
output = "\n".join([f"{line}: {code.splitlines()[line - 1]}" for line in loi])
|
||||
output = "\n".join(
|
||||
[f"{line}: {code.splitlines()[line - 1]}" for line in loi]
|
||||
)
|
||||
|
||||
results.append(f"\n{file_path}:\n{output}\n")
|
||||
match_count += len(loi)
|
||||
@@ -321,7 +325,9 @@ Finds code structures (functions, classes, methods) with full context."""
|
||||
return f"No matches found for '{pattern}' in {path}"
|
||||
|
||||
output = [f"=== AST-aware Grep Results for '{pattern}' ==="]
|
||||
output.append(f"Total matches: {match_count} in {len([r for r in results if '===' in str(r)]) // 4} files\n")
|
||||
output.append(
|
||||
f"Total matches: {match_count} in {len([r for r in results if '===' in str(r)]) // 4} files\n"
|
||||
)
|
||||
output.extend(results)
|
||||
|
||||
if match_count >= limit:
|
||||
@@ -484,12 +490,16 @@ Finds code structures (functions, classes, methods) with full context."""
|
||||
for root, _, files in os.walk(path_obj):
|
||||
for file in files:
|
||||
file_path = Path(root) / file
|
||||
if file_path.suffix in extensions and self.is_path_allowed(str(file_path)):
|
||||
if file_path.suffix in extensions and self.is_path_allowed(
|
||||
str(file_path)
|
||||
):
|
||||
files_to_process.append(str(file_path))
|
||||
|
||||
return files_to_process
|
||||
|
||||
def _extract_symbols(self, tc: TreeContext, file_path: str) -> Dict[str, List[Dict[str, Any]]]:
|
||||
def _extract_symbols(
|
||||
self, tc: TreeContext, file_path: str
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Extract symbols from a TreeContext (placeholder implementation)."""
|
||||
# This would need proper tree-sitter queries to extract symbols
|
||||
# For now, return empty structure
|
||||
|
||||
@@ -188,7 +188,9 @@ tree --pattern "*.py" --show-size"""
|
||||
if pattern:
|
||||
import fnmatch
|
||||
|
||||
entries = [e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()]
|
||||
entries = [
|
||||
e for e in entries if fnmatch.fnmatch(e.name, pattern) or e.is_dir()
|
||||
]
|
||||
|
||||
# Filter dirs only
|
||||
if dirs_only:
|
||||
|
||||
@@ -191,7 +191,9 @@ watch . --recursive --exclude "__pycache__"
|
||||
output.append("\nWatch cancelled")
|
||||
|
||||
# Summary
|
||||
output.append(f"\nWatch completed after {int(time.time() - start_time)} seconds")
|
||||
output.append(
|
||||
f"\nWatch completed after {int(time.time() - start_time)} seconds"
|
||||
)
|
||||
output.append(f"Total changes detected: {len(changes)}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
@@ -125,7 +125,9 @@ Usage:
|
||||
with open(path_obj, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
|
||||
await tool_ctx.info(f"Successfully wrote file: {file_path} ({len(content)} bytes)")
|
||||
await tool_ctx.info(
|
||||
f"Successfully wrote file: {file_path} ({len(content)} bytes)"
|
||||
)
|
||||
return f"Successfully wrote file: {file_path} ({len(content)} bytes)"
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Error writing file: {str(e)}")
|
||||
|
||||
@@ -127,7 +127,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
|
||||
"""
|
||||
tool_ctx.set_tool_info(self.name)
|
||||
|
||||
async def parse_notebook(self, file_path: Path) -> tuple[dict[str, Any], list[NotebookCellSource]]:
|
||||
async def parse_notebook(
|
||||
self, file_path: Path
|
||||
) -> tuple[dict[str, Any], list[NotebookCellSource]]:
|
||||
"""Parse a Jupyter notebook file.
|
||||
|
||||
Args:
|
||||
@@ -141,7 +143,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
|
||||
notebook = json.loads(content)
|
||||
|
||||
# Get notebook language
|
||||
language = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
|
||||
language = (
|
||||
notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
|
||||
)
|
||||
cells = notebook.get("cells", [])
|
||||
processed_cells = []
|
||||
|
||||
@@ -173,7 +177,9 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
|
||||
text = output.get("text", "")
|
||||
if isinstance(text, list):
|
||||
text = "".join(text)
|
||||
outputs.append(NotebookCellOutput(output_type="stream", text=text))
|
||||
outputs.append(
|
||||
NotebookCellOutput(output_type="stream", text=text)
|
||||
)
|
||||
|
||||
elif output_type in ["execute_result", "display_data"]:
|
||||
# Process text output
|
||||
@@ -199,7 +205,11 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
|
||||
media_type="image/jpeg",
|
||||
)
|
||||
|
||||
outputs.append(NotebookCellOutput(output_type=output_type, text=text, image=image))
|
||||
outputs.append(
|
||||
NotebookCellOutput(
|
||||
output_type=output_type, text=text, image=image
|
||||
)
|
||||
)
|
||||
|
||||
elif output_type == "error":
|
||||
# Format error traceback
|
||||
@@ -210,13 +220,17 @@ class JupyterBaseTool(FilesystemBaseTool, ABC):
|
||||
# Handle raw text strings and lists of strings
|
||||
if isinstance(traceback, list):
|
||||
# Clean ANSI escape codes and join the list but preserve the formatting
|
||||
clean_traceback = [clean_ansi_escapes(line) for line in traceback]
|
||||
clean_traceback = [
|
||||
clean_ansi_escapes(line) for line in traceback
|
||||
]
|
||||
traceback_text = "\n".join(clean_traceback)
|
||||
else:
|
||||
traceback_text = clean_ansi_escapes(str(traceback))
|
||||
|
||||
error_text = f"{ename}: {evalue}\n{traceback_text}"
|
||||
outputs.append(NotebookCellOutput(output_type="error", text=error_text))
|
||||
outputs.append(
|
||||
NotebookCellOutput(output_type="error", text=error_text)
|
||||
)
|
||||
|
||||
# Create cell object
|
||||
processed_cell = NotebookCellSource(
|
||||
|
||||
@@ -151,7 +151,9 @@ jupyter --action create "new.ipynb"
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: read, edit, create, delete, execute"
|
||||
|
||||
async def _handle_read(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
||||
async def _handle_read(
|
||||
self, notebook_path: str, params: Dict[str, Any], tool_ctx
|
||||
) -> str:
|
||||
"""Read notebook or specific cell."""
|
||||
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
|
||||
if not exists:
|
||||
@@ -186,7 +188,9 @@ jupyter --action create "new.ipynb"
|
||||
await tool_ctx.error(f"Failed to read notebook: {str(e)}")
|
||||
return f"Error reading notebook: {str(e)}"
|
||||
|
||||
async def _handle_edit(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
||||
async def _handle_edit(
|
||||
self, notebook_path: str, params: Dict[str, Any], tool_ctx
|
||||
) -> str:
|
||||
"""Edit notebook cell."""
|
||||
exists, error_msg = await self.check_path_exists(notebook_path, tool_ctx)
|
||||
if not exists:
|
||||
@@ -291,7 +295,9 @@ jupyter --action create "new.ipynb"
|
||||
await tool_ctx.error(f"Failed to create notebook: {str(e)}")
|
||||
return f"Error creating notebook: {str(e)}"
|
||||
|
||||
async def _handle_delete(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
||||
async def _handle_delete(
|
||||
self, notebook_path: str, params: Dict[str, Any], tool_ctx
|
||||
) -> str:
|
||||
"""Delete notebook or cell."""
|
||||
# If cell specified, delegate to edit with delete mode
|
||||
if params.get("cell_id") or params.get("cell_index") is not None:
|
||||
@@ -310,7 +316,9 @@ jupyter --action create "new.ipynb"
|
||||
await tool_ctx.error(f"Failed to delete notebook: {str(e)}")
|
||||
return f"Error deleting notebook: {str(e)}"
|
||||
|
||||
async def _handle_execute(self, notebook_path: str, params: Dict[str, Any], tool_ctx) -> str:
|
||||
async def _handle_execute(
|
||||
self, notebook_path: str, params: Dict[str, Any], tool_ctx
|
||||
) -> str:
|
||||
"""Execute notebook cells (placeholder for future implementation)."""
|
||||
return "Error: Cell execution not yet implemented. Use a Jupyter kernel or server for execution."
|
||||
|
||||
|
||||
@@ -137,14 +137,20 @@ class NoteBookEditTool(JupyterBaseTool):
|
||||
|
||||
# Don't validate new_source for delete mode
|
||||
if edit_mode != "delete" and not new_source:
|
||||
await tool_ctx.error("New source is required for replace or insert operations")
|
||||
await tool_ctx.error(
|
||||
"New source is required for replace or insert operations"
|
||||
)
|
||||
return "Error: New source is required for replace or insert operations"
|
||||
|
||||
await tool_ctx.info(f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})")
|
||||
await tool_ctx.info(
|
||||
f"Editing notebook: {notebook_path} (cell: {cell_number}, mode: {edit_mode})"
|
||||
)
|
||||
|
||||
# Check if path is allowed
|
||||
if not self.is_path_allowed(notebook_path):
|
||||
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
|
||||
await tool_ctx.error(
|
||||
f"Access denied - path outside allowed directories: {notebook_path}"
|
||||
)
|
||||
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
|
||||
|
||||
try:
|
||||
@@ -180,15 +186,23 @@ class NoteBookEditTool(JupyterBaseTool):
|
||||
|
||||
if edit_mode == "insert":
|
||||
if cell_number > len(cells):
|
||||
await tool_ctx.error(f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})")
|
||||
await tool_ctx.error(
|
||||
f"Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
|
||||
)
|
||||
return f"Error: Cell number {cell_number} is out of bounds for insert (max: {len(cells)})"
|
||||
else: # replace or delete
|
||||
if cell_number >= len(cells):
|
||||
await tool_ctx.error(f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})")
|
||||
await tool_ctx.error(
|
||||
f"Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
|
||||
)
|
||||
return f"Error: Cell number {cell_number} is out of bounds (max: {len(cells) - 1})"
|
||||
|
||||
# Get notebook language (needed for context but not directly used in this block)
|
||||
_ = notebook.get("metadata", {}).get("language_info", {}).get("name", "python")
|
||||
_ = (
|
||||
notebook.get("metadata", {})
|
||||
.get("language_info", {})
|
||||
.get("name", "python")
|
||||
)
|
||||
|
||||
# Perform the requested operation
|
||||
if edit_mode == "replace":
|
||||
@@ -224,7 +238,9 @@ class NoteBookEditTool(JupyterBaseTool):
|
||||
|
||||
change_description = f"Replaced cell {cell_number}"
|
||||
if cell_type is not None and cell_type != old_type:
|
||||
change_description += f" (changed type from {old_type} to {cell_type})"
|
||||
change_description += (
|
||||
f" (changed type from {old_type} to {cell_type})"
|
||||
)
|
||||
|
||||
elif edit_mode == "insert":
|
||||
# Create new cell
|
||||
@@ -241,7 +257,9 @@ class NoteBookEditTool(JupyterBaseTool):
|
||||
|
||||
# Insert the cell
|
||||
cells.insert(cell_number, new_cell)
|
||||
change_description = f"Inserted new {cell_type} cell at position {cell_number}"
|
||||
change_description = (
|
||||
f"Inserted new {cell_type} cell at position {cell_number}"
|
||||
)
|
||||
|
||||
else: # delete
|
||||
# Store deleted cell info for reporting
|
||||
@@ -250,14 +268,20 @@ class NoteBookEditTool(JupyterBaseTool):
|
||||
|
||||
# Remove the cell
|
||||
del cells[cell_number]
|
||||
change_description = f"Deleted {deleted_type} cell at position {cell_number}"
|
||||
change_description = (
|
||||
f"Deleted {deleted_type} cell at position {cell_number}"
|
||||
)
|
||||
|
||||
# Write the updated notebook back to file
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(notebook, f, indent=1)
|
||||
|
||||
await tool_ctx.info(f"Successfully edited notebook: {notebook_path} - {change_description}")
|
||||
return f"Successfully edited notebook: {notebook_path} - {change_description}"
|
||||
await tool_ctx.info(
|
||||
f"Successfully edited notebook: {notebook_path} - {change_description}"
|
||||
)
|
||||
return (
|
||||
f"Successfully edited notebook: {notebook_path} - {change_description}"
|
||||
)
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Error editing notebook: {str(e)}")
|
||||
return f"Error editing notebook: {str(e)}"
|
||||
|
||||
@@ -86,7 +86,9 @@ class NotebookReadTool(JupyterBaseTool):
|
||||
|
||||
# Check if path is allowed
|
||||
if not self.is_path_allowed(notebook_path):
|
||||
await tool_ctx.error(f"Access denied - path outside allowed directories: {notebook_path}")
|
||||
await tool_ctx.error(
|
||||
f"Access denied - path outside allowed directories: {notebook_path}"
|
||||
)
|
||||
return f"Error: Access denied - path outside allowed directories: {notebook_path}"
|
||||
|
||||
try:
|
||||
@@ -113,7 +115,9 @@ class NotebookReadTool(JupyterBaseTool):
|
||||
# Format the notebook content as a readable string
|
||||
result = self.format_notebook_cells(processed_cells)
|
||||
|
||||
await tool_ctx.info(f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)")
|
||||
await tool_ctx.info(
|
||||
f"Successfully read notebook: {notebook_path} ({len(processed_cells)} cells)"
|
||||
)
|
||||
return result
|
||||
except json.JSONDecodeError:
|
||||
await tool_ctx.error(f"Invalid notebook format: {notebook_path}")
|
||||
|
||||
@@ -1,22 +1,50 @@
|
||||
"""LLM tools for Hanzo AI."""
|
||||
"""LLM tools for Hanzo AI.
|
||||
|
||||
This package exposes LLM-related tools. Imports are guarded to keep the
|
||||
package importable on environments lacking optional dependencies or newer
|
||||
typing features.
|
||||
"""
|
||||
|
||||
try: # pragma: no cover - guard heavy imports
|
||||
from hanzo_mcp.tools.llm.llm_tool import LLMTool
|
||||
except Exception: # pragma: no cover
|
||||
LLMTool = None # type: ignore
|
||||
|
||||
try: # pragma: no cover
|
||||
from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
|
||||
except Exception:
|
||||
LLMManageTool = None # type: ignore
|
||||
|
||||
try: # pragma: no cover
|
||||
from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
|
||||
except Exception:
|
||||
ConsensusTool = None # type: ignore
|
||||
|
||||
try: # pragma: no cover
|
||||
from hanzo_mcp.tools.llm.llm_unified import UnifiedLLMTool
|
||||
except Exception:
|
||||
UnifiedLLMTool = None # type: ignore
|
||||
|
||||
try: # pragma: no cover
|
||||
from hanzo_mcp.tools.llm.provider_tools import (
|
||||
GroqTool,
|
||||
GeminiTool,
|
||||
OpenAITool,
|
||||
MistralTool,
|
||||
AnthropicTool,
|
||||
PerplexityTool,
|
||||
create_provider_tools,
|
||||
)
|
||||
except Exception: # pragma: no cover
|
||||
GroqTool = GeminiTool = OpenAITool = MistralTool = AnthropicTool = PerplexityTool = None # type: ignore
|
||||
|
||||
def create_provider_tools(*args, **kwargs): # type: ignore
|
||||
return []
|
||||
|
||||
# Legacy imports for backwards compatibility
|
||||
from hanzo_mcp.tools.llm.llm_tool import LLMTool
|
||||
from hanzo_mcp.tools.llm.llm_manage import LLMManageTool
|
||||
from hanzo_mcp.tools.llm.consensus_tool import ConsensusTool
|
||||
from hanzo_mcp.tools.llm.provider_tools import (
|
||||
GroqTool,
|
||||
GeminiTool,
|
||||
OpenAITool,
|
||||
MistralTool,
|
||||
AnthropicTool,
|
||||
PerplexityTool,
|
||||
create_provider_tools,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"LLMTool",
|
||||
"LLMTool",
|
||||
"UnifiedLLMTool",
|
||||
"ConsensusTool",
|
||||
"LLMManageTool",
|
||||
"create_provider_tools",
|
||||
|
||||
@@ -211,14 +211,22 @@ The tool will:
|
||||
)
|
||||
|
||||
# Prepare summary of results
|
||||
successful_responses = [(m, r) for m, r in results.items() if not r.startswith("Error:")]
|
||||
failed_responses = [(m, r) for m, r in results.items() if r.startswith("Error:")]
|
||||
successful_responses = [
|
||||
(m, r) for m, r in results.items() if not r.startswith("Error:")
|
||||
]
|
||||
failed_responses = [
|
||||
(m, r) for m, r in results.items() if r.startswith("Error:")
|
||||
]
|
||||
|
||||
if not successful_responses:
|
||||
return "Error: All model queries failed:\n\n" + "\n".join([f"{m}: {r}" for m, r in failed_responses])
|
||||
return "Error: All model queries failed:\n\n" + "\n".join(
|
||||
[f"{m}: {r}" for m, r in failed_responses]
|
||||
)
|
||||
|
||||
# Use aggregation model to synthesize responses
|
||||
consensus = await self._aggregate_responses(successful_responses, prompt, aggregation_model)
|
||||
consensus = await self._aggregate_responses(
|
||||
successful_responses, prompt, aggregation_model
|
||||
)
|
||||
|
||||
# Format output
|
||||
output = ["=== LLM Consensus Analysis ==="]
|
||||
@@ -237,7 +245,9 @@ The tool will:
|
||||
output.append("\n=== Individual Responses ===")
|
||||
for model, response in successful_responses:
|
||||
output.append(f"\n--- {model} ---")
|
||||
output.append(response[:500] + "..." if len(response) > 500 else response)
|
||||
output.append(
|
||||
response[:500] + "..." if len(response) > 500 else response
|
||||
)
|
||||
|
||||
if failed_responses:
|
||||
output.append("\n=== Failed Queries ===")
|
||||
@@ -272,7 +282,9 @@ The tool will:
|
||||
# Create a mock context for the LLM tool
|
||||
mock_ctx = type("MockContext", (), {"client": None})()
|
||||
|
||||
result = await asyncio.wait_for(self.llm_tool.call(mock_ctx, **params), timeout=timeout)
|
||||
result = await asyncio.wait_for(
|
||||
self.llm_tool.call(mock_ctx, **params), timeout=timeout
|
||||
)
|
||||
return (model, result)
|
||||
except asyncio.TimeoutError:
|
||||
return (model, f"Error: Timeout after {timeout} seconds")
|
||||
@@ -293,7 +305,9 @@ The tool will:
|
||||
) -> str:
|
||||
"""Use an LLM to aggregate and analyze responses."""
|
||||
# Prepare the aggregation prompt
|
||||
response_summary = "\n\n".join([f"Model: {model}\nResponse: {response}" for model, response in responses])
|
||||
response_summary = "\n\n".join(
|
||||
[f"Model: {model}\nResponse: {response}" for model, response in responses]
|
||||
)
|
||||
|
||||
aggregation_prompt = f"""You are analyzing responses from multiple AI models to the following prompt:
|
||||
|
||||
@@ -346,7 +360,9 @@ Be concise but thorough. Focus on providing actionable insights."""
|
||||
for model, response in responses:
|
||||
output.append(f"- {model}: {len(response)} characters")
|
||||
|
||||
output.append("\nNote: Advanced consensus analysis unavailable. Showing basic summary only.")
|
||||
output.append(
|
||||
"\nNote: Advanced consensus analysis unavailable. Showing basic summary only."
|
||||
)
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
|
||||
@@ -192,7 +192,9 @@ Providers are automatically detected based on environment variables:
|
||||
for provider in available_but_disabled:
|
||||
env_vars = available_providers.get(provider, [])
|
||||
output.append(f" - {provider}: {', '.join(env_vars)}")
|
||||
output.append(f" Use: llm_manage --action enable --provider {provider}")
|
||||
output.append(
|
||||
f" Use: llm_manage --action enable --provider {provider}"
|
||||
)
|
||||
output.append("")
|
||||
|
||||
# Show providers without API keys
|
||||
@@ -354,7 +356,9 @@ Providers are automatically detected based on environment variables:
|
||||
except Exception as e:
|
||||
return f"Error listing models: {str(e)}"
|
||||
|
||||
async def _test_model(self, ctx: MCPContext, provider: Optional[str], model: Optional[str]) -> str:
|
||||
async def _test_model(
|
||||
self, ctx: MCPContext, provider: Optional[str], model: Optional[str]
|
||||
) -> str:
|
||||
"""Test a model to verify it works."""
|
||||
if not model and not provider:
|
||||
return "Error: Either model or provider is required for test action"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user