mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-26 11:49:36 +00:00
Apply ruff format to hanzo_mcp and tests
This commit is contained in:
@@ -16,7 +16,9 @@ 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:
|
||||
|
||||
@@ -138,7 +138,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
|
||||
@@ -158,7 +160,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
|
||||
|
||||
@@ -168,7 +172,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
|
||||
|
||||
|
||||
@@ -49,7 +49,11 @@ class SQLiteBackendPlugin:
|
||||
self._initialized = False
|
||||
|
||||
async def store_memory(
|
||||
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
|
||||
self,
|
||||
content: str,
|
||||
metadata: Dict[str, Any],
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> str:
|
||||
"""Store a memory and return its ID."""
|
||||
if not self._client:
|
||||
@@ -84,7 +88,11 @@ class SQLiteBackendPlugin:
|
||||
return result["memory_id"]
|
||||
|
||||
async def retrieve_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Retrieve memories based on query."""
|
||||
if not self._client:
|
||||
@@ -100,7 +108,10 @@ class SQLiteBackendPlugin:
|
||||
query_embedding = embedding_service.embed_single(query)
|
||||
|
||||
results = self._client.search_memories(
|
||||
user_id=user_id, query_embedding=query_embedding, project_id=project_id, limit=limit
|
||||
user_id=user_id,
|
||||
query_embedding=query_embedding,
|
||||
project_id=project_id,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return [
|
||||
@@ -132,12 +143,18 @@ class SQLiteBackendPlugin:
|
||||
]
|
||||
|
||||
async def search_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search memories based on query with scoring."""
|
||||
return await self.retrieve_memory(query, user_id, project_id, limit)
|
||||
|
||||
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
|
||||
async def delete_memory(
|
||||
self, memory_id: str, user_id: str = "default", project_id: str = "default"
|
||||
) -> bool:
|
||||
"""Delete a memory by ID."""
|
||||
if not self._client:
|
||||
raise RuntimeError("Plugin not initialized")
|
||||
|
||||
@@ -129,7 +129,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 {
|
||||
@@ -171,7 +173,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:
|
||||
@@ -254,7 +258,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:
|
||||
@@ -282,7 +288,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:
|
||||
@@ -307,7 +315,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:
|
||||
@@ -336,7 +346,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.
|
||||
@@ -348,7 +360,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:
|
||||
@@ -421,7 +435,9 @@ async def run_bridge_server(config: BridgeConfig):
|
||||
|
||||
def main():
|
||||
"""Main entry point for the bridge."""
|
||||
parser = argparse.ArgumentParser(description="MCP Bridge for Claude-to-Claude communication")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP Bridge for Claude-to-Claude communication"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target-port",
|
||||
type=int,
|
||||
|
||||
@@ -113,7 +113,9 @@ def main() -> None:
|
||||
|
||||
_version_info = f"hanzo-mcp {_version} (async: {_async_backend})"
|
||||
|
||||
parser = argparse.ArgumentParser(description="MCP server implementing Hanzo AI capabilities")
|
||||
parser = argparse.ArgumentParser(
|
||||
description="MCP server implementing Hanzo AI capabilities"
|
||||
)
|
||||
|
||||
parser.add_argument("--version", action="version", version=_version_info)
|
||||
|
||||
@@ -405,8 +407,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:
|
||||
@@ -416,7 +422,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
|
||||
@@ -582,7 +590,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("\n✅ Plugin template created successfully!")
|
||||
print("\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("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)
|
||||
|
||||
@@ -52,7 +52,10 @@ def get_project_config_path() -> Optional[Path]:
|
||||
return None
|
||||
|
||||
|
||||
def load_config(global_config_path: Optional[Path] = None, project_config_path: Optional[Path] = None) -> PluginConfig:
|
||||
def load_config(
|
||||
global_config_path: Optional[Path] = None,
|
||||
project_config_path: Optional[Path] = None,
|
||||
) -> PluginConfig:
|
||||
"""Load configuration from global and project files, with project overriding global."""
|
||||
global_path = global_config_path or get_global_config_path()
|
||||
project_path = project_config_path or get_project_config_path()
|
||||
@@ -61,7 +64,11 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
|
||||
config_data = {
|
||||
"enabled_backends": ["sqlite"],
|
||||
"backend_configs": {
|
||||
"sqlite": {"enabled": True, "path": str(Path.home() / ".hanzo" / "memory.db"), "settings": {}}
|
||||
"sqlite": {
|
||||
"enabled": True,
|
||||
"path": str(Path.home() / ".hanzo" / "memory.db"),
|
||||
"settings": {},
|
||||
}
|
||||
},
|
||||
"default_user_id": "default",
|
||||
"default_project_id": "default",
|
||||
@@ -75,7 +82,9 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
|
||||
# Update config with global settings
|
||||
for key, value in global_data.items():
|
||||
if key in config_data:
|
||||
if isinstance(config_data[key], dict) and isinstance(value, dict):
|
||||
if isinstance(config_data[key], dict) and isinstance(
|
||||
value, dict
|
||||
):
|
||||
# Deep merge dictionaries
|
||||
config_data[key].update(value)
|
||||
else:
|
||||
@@ -91,7 +100,9 @@ def load_config(global_config_path: Optional[Path] = None, project_config_path:
|
||||
# Update config with project settings
|
||||
for key, value in project_data.items():
|
||||
if key in config_data:
|
||||
if isinstance(config_data[key], dict) and isinstance(value, dict):
|
||||
if isinstance(config_data[key], dict) and isinstance(
|
||||
value, dict
|
||||
):
|
||||
# Deep merge dictionaries
|
||||
config_data[key].update(value)
|
||||
else:
|
||||
@@ -115,6 +126,10 @@ def get_default_config() -> PluginConfig:
|
||||
return PluginConfig(
|
||||
enabled_backends=["sqlite"],
|
||||
backend_configs={
|
||||
"sqlite": BackendConfig(enabled=True, path=str(Path.home() / ".hanzo" / "memory.db"), settings={})
|
||||
"sqlite": BackendConfig(
|
||||
enabled=True,
|
||||
path=str(Path.home() / ".hanzo" / "memory.db"),
|
||||
settings={},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -135,7 +135,9 @@ class BaseAgent(ABC, Generic[TContext]):
|
||||
|
||||
# Log start
|
||||
if context and isinstance(context, AgentContext):
|
||||
await context.log(f"Starting {self.name} with model {self.config.model}")
|
||||
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)
|
||||
@@ -153,7 +155,11 @@ class BaseAgent(ABC, Generic[TContext]):
|
||||
|
||||
except Exception as e:
|
||||
self._end_time = datetime.now()
|
||||
duration = (self._end_time - self._start_time).total_seconds() if self._start_time else None
|
||||
duration = (
|
||||
(self._end_time - self._start_time).total_seconds()
|
||||
if self._start_time
|
||||
else None
|
||||
)
|
||||
|
||||
logger.error(f"Agent {self.name} failed: {e}")
|
||||
|
||||
@@ -235,7 +241,9 @@ class BaseAgent(ABC, Generic[TContext]):
|
||||
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}")
|
||||
raise Exception(
|
||||
f"All {self.config.max_retries} attempts failed. Last error: {last_error}"
|
||||
)
|
||||
|
||||
@abstractmethod
|
||||
async def _execute_impl(
|
||||
@@ -331,7 +339,9 @@ class CLIAgent(BaseAgent[TContext]):
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
raise asyncio.TimeoutError(f"Command timed out after {self.config.timeout} seconds")
|
||||
raise asyncio.TimeoutError(
|
||||
f"Command timed out after {self.config.timeout} seconds"
|
||||
)
|
||||
|
||||
# Check for errors
|
||||
if process.returncode != 0:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -36,7 +36,9 @@ class DevToolsCore:
|
||||
async def edit(
|
||||
self,
|
||||
target: str,
|
||||
op: Literal["rename", "code_action", "organize_imports", "apply_workspace_edit"],
|
||||
op: Literal[
|
||||
"rename", "code_action", "organize_imports", "apply_workspace_edit"
|
||||
],
|
||||
file: Optional[str] = None,
|
||||
pos: Optional[Dict[str, int]] = None,
|
||||
range_: Optional[Dict[str, Dict[str, int]]] = None,
|
||||
@@ -64,7 +66,9 @@ class DevToolsCore:
|
||||
await self.backend.log_execution("edit", args, result)
|
||||
return result
|
||||
|
||||
async def fmt(self, target: str, local_prefix: Optional[str] = None, **target_opts) -> ToolResult:
|
||||
async def fmt(
|
||||
self, target: str, local_prefix: Optional[str] = None, **target_opts
|
||||
) -> ToolResult:
|
||||
"""Format code and organize imports"""
|
||||
target_spec = TargetSpec(target=target, **target_opts)
|
||||
|
||||
@@ -90,7 +94,13 @@ class DevToolsCore:
|
||||
args = {
|
||||
"opts": {
|
||||
k: v
|
||||
for k, v in {"run": run, "count": count, "race": race, "filter": filter_, "watch": watch}.items()
|
||||
for k, v in {
|
||||
"run": run,
|
||||
"count": count,
|
||||
"race": race,
|
||||
"filter": filter_,
|
||||
"watch": watch,
|
||||
}.items()
|
||||
if v is not None
|
||||
}
|
||||
}
|
||||
@@ -100,18 +110,30 @@ class DevToolsCore:
|
||||
return result
|
||||
|
||||
async def build(
|
||||
self, target: str, release: Optional[bool] = None, features: Optional[List[str]] = None, **target_opts
|
||||
self,
|
||||
target: str,
|
||||
release: Optional[bool] = None,
|
||||
features: Optional[List[str]] = None,
|
||||
**target_opts,
|
||||
) -> ToolResult:
|
||||
"""Compile/build artifacts narrowly by default"""
|
||||
target_spec = TargetSpec(target=target, **target_opts)
|
||||
|
||||
args = {"opts": {k: v for k, v in {"release": release, "features": features}.items() if v is not None}}
|
||||
args = {
|
||||
"opts": {
|
||||
k: v
|
||||
for k, v in {"release": release, "features": features}.items()
|
||||
if v is not None
|
||||
}
|
||||
}
|
||||
|
||||
result = await self.backend.execute_build(target_spec, args)
|
||||
await self.backend.log_execution("build", args, result)
|
||||
return result
|
||||
|
||||
async def lint(self, target: str, fix: Optional[bool] = None, **target_opts) -> ToolResult:
|
||||
async def lint(
|
||||
self, target: str, fix: Optional[bool] = None, **target_opts
|
||||
) -> ToolResult:
|
||||
"""Lint and typecheck code"""
|
||||
target_spec = TargetSpec(target=target, **target_opts)
|
||||
|
||||
@@ -121,7 +143,9 @@ class DevToolsCore:
|
||||
await self.backend.log_execution("lint", args, result)
|
||||
return result
|
||||
|
||||
async def guard(self, target: str, rules: Optional[List[Dict[str, Any]]] = None, **target_opts) -> ToolResult:
|
||||
async def guard(
|
||||
self, target: str, rules: Optional[List[Dict[str, Any]]] = None, **target_opts
|
||||
) -> ToolResult:
|
||||
"""Check repository invariants and boundaries"""
|
||||
target_spec = TargetSpec(target=target, **target_opts)
|
||||
|
||||
@@ -133,13 +157,19 @@ class DevToolsCore:
|
||||
|
||||
# Composition patterns
|
||||
async def multi_language_rename(
|
||||
self, symbol_name: str, new_name: str, languages: List[str], workspace: str = "ws"
|
||||
self,
|
||||
symbol_name: str,
|
||||
new_name: str,
|
||||
languages: List[str],
|
||||
workspace: str = "ws",
|
||||
) -> List[ToolResult]:
|
||||
"""Multi-language rename operation"""
|
||||
results = []
|
||||
|
||||
for lang in languages:
|
||||
result = await self.edit(target=workspace, op="rename", new_name=new_name, language=lang)
|
||||
result = await self.edit(
|
||||
target=workspace, op="rename", new_name=new_name, language=lang
|
||||
)
|
||||
results.append(result)
|
||||
|
||||
# Format changed files
|
||||
@@ -156,13 +186,18 @@ class DevToolsCore:
|
||||
|
||||
return results
|
||||
|
||||
async def wide_refactor_go_workspace(self, workspace: str = "ws") -> List[ToolResult]:
|
||||
async def wide_refactor_go_workspace(
|
||||
self, workspace: str = "ws"
|
||||
) -> List[ToolResult]:
|
||||
"""Wide refactor in Go workspace"""
|
||||
results = []
|
||||
|
||||
# Fix all and organize imports
|
||||
edit_result = await self.edit(
|
||||
target="pkg:./...", op="code_action", only=["source.fixAll", "source.organizeImports"], language="go"
|
||||
target="pkg:./...",
|
||||
op="code_action",
|
||||
only=["source.fixAll", "source.organizeImports"],
|
||||
language="go",
|
||||
)
|
||||
results.append(edit_result)
|
||||
|
||||
@@ -184,7 +219,12 @@ class DevToolsCore:
|
||||
"glob": "sdk/**",
|
||||
"forbid_import_prefix": "github.com/luxfi/node/",
|
||||
},
|
||||
{"id": "no_generated_edits", "type": "generated", "glob": "api/pb/**", "forbid_writes": True},
|
||||
{
|
||||
"id": "no_generated_edits",
|
||||
"type": "generated",
|
||||
"glob": "api/pb/**",
|
||||
"forbid_writes": True,
|
||||
},
|
||||
],
|
||||
)
|
||||
results.append(guard_result)
|
||||
@@ -321,7 +361,13 @@ async def mcp_lint(
|
||||
"""MCP wrapper for lint tool"""
|
||||
tools = DevToolsCore()
|
||||
result = await tools.lint(
|
||||
target=target, fix=fix, language=language, backend=backend, root=root, env=env or {}, dry_run=dry_run
|
||||
target=target,
|
||||
fix=fix,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env or {},
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return asdict(result)
|
||||
|
||||
@@ -338,7 +384,13 @@ async def mcp_guard(
|
||||
"""MCP wrapper for guard tool"""
|
||||
tools = DevToolsCore()
|
||||
result = await tools.guard(
|
||||
target=target, rules=rules, language=language, backend=backend, root=root, env=env or {}, dry_run=dry_run
|
||||
target=target,
|
||||
rules=rules,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env or {},
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return asdict(result)
|
||||
|
||||
|
||||
@@ -55,12 +55,28 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Explicit workspace root override"},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Explicit workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -73,13 +89,24 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
|
||||
"enum": [
|
||||
"rename",
|
||||
"code_action",
|
||||
"organize_imports",
|
||||
"apply_workspace_edit",
|
||||
],
|
||||
"description": "Operation to perform",
|
||||
},
|
||||
"file": {"type": "string", "description": "File path for rename/code_action operations"},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "File path for rename/code_action operations",
|
||||
},
|
||||
"pos": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
"description": "Position for rename/code_action",
|
||||
},
|
||||
"range": {
|
||||
@@ -87,22 +114,35 @@ class ExactHanzoMCPServer:
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Range for code actions",
|
||||
},
|
||||
"new_name": {"type": "string", "description": "New name for rename operation"},
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "New name for rename operation",
|
||||
},
|
||||
"only": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "LSP codeAction kinds filter",
|
||||
},
|
||||
"apply": {"type": "boolean", "default": True, "description": "Apply edits to disk"},
|
||||
"apply": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Apply edits to disk",
|
||||
},
|
||||
"workspace_edit": {
|
||||
"type": "object",
|
||||
"description": "WorkspaceEdit payload for apply_workspace_edit",
|
||||
@@ -124,7 +164,16 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
},
|
||||
"backend": {
|
||||
@@ -132,7 +181,10 @@ class ExactHanzoMCPServer:
|
||||
"default": "auto",
|
||||
"description": "Backend override (goimports, prettier, ruff, etc.)",
|
||||
},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -166,7 +218,16 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
},
|
||||
"backend": {
|
||||
@@ -174,20 +235,50 @@ class ExactHanzoMCPServer:
|
||||
"default": "auto",
|
||||
"description": "Backend override (go, npm, pytest, cargo, etc.)",
|
||||
},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False},
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run": {"type": "string", "description": "Go test filter"},
|
||||
"count": {"type": "integer", "description": "Go test count"},
|
||||
"race": {"type": "boolean", "description": "Go race detection"},
|
||||
"filter": {"type": "string", "description": "TS test filter"},
|
||||
"watch": {"type": "boolean", "description": "TS watch mode"},
|
||||
"k": {"type": "string", "description": "Python test filter"},
|
||||
"m": {"type": "string", "description": "Python test marker"},
|
||||
"p": {"type": "string", "description": "Rust package"},
|
||||
"run": {
|
||||
"type": "string",
|
||||
"description": "Go test filter",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Go test count",
|
||||
},
|
||||
"race": {
|
||||
"type": "boolean",
|
||||
"description": "Go race detection",
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "TS test filter",
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"description": "TS watch mode",
|
||||
},
|
||||
"k": {
|
||||
"type": "string",
|
||||
"description": "Python test filter",
|
||||
},
|
||||
"m": {
|
||||
"type": "string",
|
||||
"description": "Python test marker",
|
||||
},
|
||||
"p": {
|
||||
"type": "string",
|
||||
"description": "Rust package",
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -213,18 +304,43 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False},
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release": {"type": "boolean", "description": "Release build"},
|
||||
"features": {"type": "array", "items": {"type": "string"}},
|
||||
"release": {
|
||||
"type": "boolean",
|
||||
"description": "Release build",
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
},
|
||||
},
|
||||
"description": "Build-specific options",
|
||||
},
|
||||
@@ -245,17 +361,39 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False},
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fix": {"type": "boolean", "description": "Auto-fix issues where possible"}
|
||||
"fix": {
|
||||
"type": "boolean",
|
||||
"description": "Auto-fix issues where possible",
|
||||
}
|
||||
},
|
||||
"description": "Lint-specific options",
|
||||
},
|
||||
@@ -276,12 +414,27 @@ class ExactHanzoMCPServer:
|
||||
},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["auto", "go", "ts", "py", "rs", "cc", "sol", "schema"],
|
||||
"enum": [
|
||||
"auto",
|
||||
"go",
|
||||
"ts",
|
||||
"py",
|
||||
"rs",
|
||||
"cc",
|
||||
"sol",
|
||||
"schema",
|
||||
],
|
||||
"default": "auto",
|
||||
},
|
||||
"backend": {"type": "string", "default": "auto"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"env": {"type": "object", "additionalProperties": {"type": "string"}},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
@@ -289,7 +442,10 @@ class ExactHanzoMCPServer:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["regex", "import", "generated"]},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["regex", "import", "generated"],
|
||||
},
|
||||
"glob": {"type": "string"},
|
||||
"pattern": {"type": "string"},
|
||||
"forbid_import_prefix": {"type": "string"},
|
||||
@@ -357,10 +513,16 @@ class ExactHanzoMCPServer:
|
||||
else:
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
return [TextContent(type="text", text=self._format_exact_result(result, name))]
|
||||
return [
|
||||
TextContent(
|
||||
type="text", text=self._format_exact_result(result, name)
|
||||
)
|
||||
]
|
||||
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error executing {name}: {str(e)}")]
|
||||
return [
|
||||
TextContent(type="text", text=f"Error executing {name}: {str(e)}")
|
||||
]
|
||||
|
||||
def _format_exact_result(self, result, tool_name: str) -> str:
|
||||
"""Format tool result according to exact specification"""
|
||||
@@ -407,7 +569,8 @@ class ExactHanzoMCPServer:
|
||||
server_name="hanzo-mcp-exact",
|
||||
server_version="1.0.0",
|
||||
capabilities=self.server.get_capabilities(
|
||||
notification_options=NotificationOptions(), experimental_capabilities={}
|
||||
notification_options=NotificationOptions(),
|
||||
experimental_capabilities={},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -55,11 +55,15 @@ class ToolResult:
|
||||
|
||||
# Shared Input Schema
|
||||
class TargetSpec(StrictModel):
|
||||
target: str = Field(..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed")
|
||||
target: str = Field(
|
||||
..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed"
|
||||
)
|
||||
language: str = Field(default="auto", description="auto|go|ts|py|rs|cc|sol|schema")
|
||||
backend: str = Field(default="auto", description="Backend override")
|
||||
root: Optional[str] = Field(default=None, description="Workspace root override")
|
||||
env: Dict[str, str] = Field(default_factory=dict, description="Environment variables")
|
||||
env: Dict[str, str] = Field(
|
||||
default_factory=dict, description="Environment variables"
|
||||
)
|
||||
dry_run: bool = Field(default=False, description="Preview mode")
|
||||
|
||||
|
||||
@@ -68,7 +72,9 @@ class EditArgs(StrictModel):
|
||||
op: Literal["rename", "code_action", "organize_imports", "apply_workspace_edit"]
|
||||
file: Optional[str] = None
|
||||
pos: Optional[Dict[str, int]] = None # {line, character}
|
||||
range: Optional[Dict[str, Dict[str, int]]] = None # {start: {line, ch}, end: {line, ch}}
|
||||
range: Optional[Dict[str, Dict[str, int]]] = (
|
||||
None # {start: {line, ch}, end: {line, ch}}
|
||||
)
|
||||
new_name: Optional[str] = None
|
||||
only: List[str] = Field(default_factory=list) # LSP codeAction kinds
|
||||
apply: bool = True
|
||||
@@ -80,7 +86,9 @@ class FmtArgs(StrictModel):
|
||||
|
||||
|
||||
class TestArgs(StrictModel):
|
||||
opts: Dict[str, Any] = Field(default_factory=dict) # go: {run?, count?, race?}, ts: {filter?, watch?}, etc.
|
||||
opts: Dict[str, Any] = Field(
|
||||
default_factory=dict
|
||||
) # go: {run?, count?, race?}, ts: {filter?, watch?}, etc.
|
||||
|
||||
|
||||
class BuildArgs(StrictModel):
|
||||
@@ -150,7 +158,12 @@ class WorkspaceDetector:
|
||||
# Then check other workspace types
|
||||
for current in parents:
|
||||
if (current / "go.mod").is_file():
|
||||
return {"root": str(current), "type": "go_module", "config": current / "go.mod", "language": "go"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "go_module",
|
||||
"config": current / "go.mod",
|
||||
"language": "go",
|
||||
}
|
||||
elif (current / "package.json").is_file():
|
||||
with open(current / "package.json") as f:
|
||||
pkg_data = json.load(f)
|
||||
@@ -183,13 +196,32 @@ class WorkspaceDetector:
|
||||
"language": "cc",
|
||||
}
|
||||
elif (current / "buf.yaml").is_file() or (current / "buf.yml").is_file():
|
||||
config_file = current / "buf.yaml" if (current / "buf.yaml").is_file() else current / "buf.yml"
|
||||
return {"root": str(current), "type": "buf_workspace", "config": config_file, "language": "schema"}
|
||||
config_file = (
|
||||
current / "buf.yaml"
|
||||
if (current / "buf.yaml").is_file()
|
||||
else current / "buf.yml"
|
||||
)
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "buf_workspace",
|
||||
"config": config_file,
|
||||
"language": "schema",
|
||||
}
|
||||
elif (current / ".git").exists():
|
||||
return {"root": str(current), "type": "git_repository", "config": current / ".git", "language": "auto"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "git_repository",
|
||||
"config": current / ".git",
|
||||
"language": "auto",
|
||||
}
|
||||
|
||||
# Default to current directory
|
||||
return {"root": str(path), "type": "directory", "config": None, "language": "auto"}
|
||||
return {
|
||||
"root": str(path),
|
||||
"type": "directory",
|
||||
"config": None,
|
||||
"language": "auto",
|
||||
}
|
||||
|
||||
|
||||
class TargetResolver:
|
||||
@@ -218,7 +250,9 @@ class TargetResolver:
|
||||
def _resolve_file(self, file_path: str, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
"""Resolve single file target"""
|
||||
path = Path(file_path).absolute()
|
||||
workspace = self.workspace_detector.detect(str(path), root_hint=target_spec.root)
|
||||
workspace = self.workspace_detector.detect(
|
||||
str(path), root_hint=target_spec.root
|
||||
)
|
||||
|
||||
return {
|
||||
"type": "file",
|
||||
@@ -230,7 +264,9 @@ class TargetResolver:
|
||||
def _resolve_dir(self, dir_path: str, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
"""Resolve directory subtree target"""
|
||||
path = Path(dir_path).absolute()
|
||||
workspace = self.workspace_detector.detect(str(path), root_hint=target_spec.root)
|
||||
workspace = self.workspace_detector.detect(
|
||||
str(path), root_hint=target_spec.root
|
||||
)
|
||||
language = self._infer_language(path, workspace, target_spec.language)
|
||||
|
||||
# Find relevant files based on language
|
||||
@@ -240,12 +276,25 @@ class TargetResolver:
|
||||
for ext in extensions:
|
||||
files.extend(path.rglob(f"*{ext}"))
|
||||
|
||||
return {"type": "directory", "paths": [str(f) for f in files], "workspace": workspace, "language": language}
|
||||
return {
|
||||
"type": "directory",
|
||||
"paths": [str(f) for f in files],
|
||||
"workspace": workspace,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
def _resolve_package(self, pkg_spec: str, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
def _resolve_package(
|
||||
self, pkg_spec: str, target_spec: TargetSpec
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve package specification"""
|
||||
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
|
||||
language = workspace["language"] if target_spec.language == "auto" else target_spec.language
|
||||
workspace = self.workspace_detector.detect(
|
||||
target_spec.root or ".", root_hint=target_spec.root
|
||||
)
|
||||
language = (
|
||||
workspace["language"]
|
||||
if target_spec.language == "auto"
|
||||
else target_spec.language
|
||||
)
|
||||
|
||||
if language == "go":
|
||||
return self._resolve_go_package(pkg_spec, workspace, target_spec)
|
||||
@@ -256,12 +305,20 @@ class TargetResolver:
|
||||
elif language == "rs":
|
||||
return self._resolve_rust_package(pkg_spec, workspace, target_spec)
|
||||
else:
|
||||
raise ValueError(f"Package resolution not supported for language: {language}")
|
||||
raise ValueError(
|
||||
f"Package resolution not supported for language: {language}"
|
||||
)
|
||||
|
||||
def _resolve_workspace(self, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
"""Resolve workspace root"""
|
||||
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
|
||||
language = workspace["language"] if target_spec.language == "auto" else target_spec.language
|
||||
workspace = self.workspace_detector.detect(
|
||||
target_spec.root or ".", root_hint=target_spec.root
|
||||
)
|
||||
language = (
|
||||
workspace["language"]
|
||||
if target_spec.language == "auto"
|
||||
else target_spec.language
|
||||
)
|
||||
|
||||
root = Path(workspace["root"])
|
||||
extensions = self._get_extensions_for_language(language)
|
||||
@@ -270,11 +327,18 @@ class TargetResolver:
|
||||
for ext in extensions:
|
||||
files.extend(root.rglob(f"*{ext}"))
|
||||
|
||||
return {"type": "workspace", "paths": [str(f) for f in files], "workspace": workspace, "language": language}
|
||||
return {
|
||||
"type": "workspace",
|
||||
"paths": [str(f) for f in files],
|
||||
"workspace": workspace,
|
||||
"language": language,
|
||||
}
|
||||
|
||||
def _resolve_changed(self, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
"""Resolve git changed files"""
|
||||
workspace = self.workspace_detector.detect(target_spec.root or ".", root_hint=target_spec.root)
|
||||
workspace = self.workspace_detector.detect(
|
||||
target_spec.root or ".", root_hint=target_spec.root
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
@@ -284,7 +348,9 @@ class TargetResolver:
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
changed_files = (
|
||||
result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
)
|
||||
|
||||
# Make paths absolute and filter existing files
|
||||
root_path = Path(workspace["root"])
|
||||
@@ -309,7 +375,9 @@ class TargetResolver:
|
||||
"error": f"Git command failed: {e}",
|
||||
}
|
||||
|
||||
def _resolve_go_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
def _resolve_go_package(
|
||||
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve Go package specification"""
|
||||
Path(workspace["root"])
|
||||
|
||||
@@ -327,7 +395,14 @@ class TargetResolver:
|
||||
env = os.environ.copy()
|
||||
env["GOWORK"] = "auto" # Always use go.work if available
|
||||
|
||||
result = subprocess.run(cmd, cwd=workspace["root"], capture_output=True, text=True, env=env, check=True)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=workspace["root"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
packages = result.stdout.strip().split("\n")
|
||||
|
||||
@@ -369,7 +444,9 @@ class TargetResolver:
|
||||
"error": f"Go list failed: {e}",
|
||||
}
|
||||
|
||||
def _resolve_ts_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
def _resolve_ts_package(
|
||||
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve TypeScript/Node package specification"""
|
||||
# Handle npm/pnpm workspace filters
|
||||
if pkg_spec.startswith("--filter "):
|
||||
@@ -386,12 +463,16 @@ class TargetResolver:
|
||||
# Default to directory-based resolution
|
||||
return self._resolve_dir(pkg_spec, target_spec)
|
||||
|
||||
def _resolve_py_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
def _resolve_py_package(
|
||||
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve Python package specification"""
|
||||
# Could use importlib or package discovery here
|
||||
return self._resolve_dir(pkg_spec, target_spec)
|
||||
|
||||
def _resolve_rust_package(self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec) -> Dict[str, Any]:
|
||||
def _resolve_rust_package(
|
||||
self, pkg_spec: str, workspace: Dict, target_spec: TargetSpec
|
||||
) -> Dict[str, Any]:
|
||||
"""Resolve Rust package specification"""
|
||||
if pkg_spec.startswith("-p "):
|
||||
package_name = pkg_spec[3:]
|
||||
@@ -486,7 +567,9 @@ class TargetResolver:
|
||||
".proto",
|
||||
],
|
||||
}
|
||||
return extension_map.get(language, [".go", ".ts", ".js", ".py", ".rs", ".c", ".cpp"])
|
||||
return extension_map.get(
|
||||
language, [".go", ".ts", ".js", ".py", ".rs", ".c", ".cpp"]
|
||||
)
|
||||
|
||||
|
||||
class BackendSelector:
|
||||
@@ -499,7 +582,13 @@ class BackendSelector:
|
||||
return backend_hint
|
||||
|
||||
backend_map = {
|
||||
"go": {"fmt": "goimports", "test": "go", "build": "go", "lint": "golangci-lint", "edit": "gopls"},
|
||||
"go": {
|
||||
"fmt": "goimports",
|
||||
"test": "go",
|
||||
"build": "go",
|
||||
"lint": "golangci-lint",
|
||||
"edit": "gopls",
|
||||
},
|
||||
"ts": {
|
||||
"fmt": "prettier",
|
||||
"test": "npm", # or pnpm/yarn
|
||||
@@ -507,9 +596,27 @@ class BackendSelector:
|
||||
"lint": "eslint",
|
||||
"edit": "typescript-language-server",
|
||||
},
|
||||
"py": {"fmt": "ruff", "test": "pytest", "build": "build", "lint": "ruff", "edit": "pyright"},
|
||||
"rs": {"fmt": "cargo", "test": "cargo", "build": "cargo", "lint": "cargo", "edit": "rust-analyzer"},
|
||||
"cc": {"fmt": "clang-format", "test": "ctest", "build": "cmake", "lint": "clang-tidy", "edit": "clangd"},
|
||||
"py": {
|
||||
"fmt": "ruff",
|
||||
"test": "pytest",
|
||||
"build": "build",
|
||||
"lint": "ruff",
|
||||
"edit": "pyright",
|
||||
},
|
||||
"rs": {
|
||||
"fmt": "cargo",
|
||||
"test": "cargo",
|
||||
"build": "cargo",
|
||||
"lint": "cargo",
|
||||
"edit": "rust-analyzer",
|
||||
},
|
||||
"cc": {
|
||||
"fmt": "clang-format",
|
||||
"test": "ctest",
|
||||
"build": "cmake",
|
||||
"lint": "clang-tidy",
|
||||
"edit": "clangd",
|
||||
},
|
||||
"sol": {
|
||||
"fmt": "prettier",
|
||||
"test": "hardhat",
|
||||
@@ -517,7 +624,13 @@ class BackendSelector:
|
||||
"lint": "slither",
|
||||
"edit": "solidity-language-server",
|
||||
},
|
||||
"schema": {"fmt": "buf", "test": "buf", "build": "buf", "lint": "buf", "edit": "buf"},
|
||||
"schema": {
|
||||
"fmt": "buf",
|
||||
"test": "buf",
|
||||
"build": "buf",
|
||||
"lint": "buf",
|
||||
"edit": "buf",
|
||||
},
|
||||
}
|
||||
|
||||
return backend_map.get(language, {}).get(tool, "unknown")
|
||||
@@ -595,7 +708,9 @@ class LSPBridge:
|
||||
errors.append(str(data["error"]))
|
||||
return {"touched_files": sorted(set(touched)), "errors": errors}
|
||||
|
||||
async def apply_workspace_edit(self, edit: Dict[str, Any], workspace_root: str) -> Dict[str, Any]:
|
||||
async def apply_workspace_edit(
|
||||
self, edit: Dict[str, Any], workspace_root: str
|
||||
) -> Dict[str, Any]:
|
||||
"""Apply a WorkspaceEdit directly."""
|
||||
applied, errors = self.tool._apply_workspace_edit(edit, workspace_root)
|
||||
return {"touched_files": applied, "errors": errors}
|
||||
@@ -610,7 +725,9 @@ class HanzoTools:
|
||||
self.backend_selector = BackendSelector()
|
||||
self.lsp_bridge = LSPBridge()
|
||||
|
||||
def _prepare_environment(self, workspace: Dict, target_spec: TargetSpec) -> Dict[str, str]:
|
||||
def _prepare_environment(
|
||||
self, workspace: Dict, target_spec: TargetSpec
|
||||
) -> Dict[str, str]:
|
||||
"""Prepare environment variables for tool execution"""
|
||||
env = os.environ.copy()
|
||||
|
||||
@@ -632,7 +749,9 @@ class HanzoTools:
|
||||
workspace = resolved["workspace"]
|
||||
language = resolved["language"]
|
||||
|
||||
backend = self.backend_selector.select_backend(language, "edit", target_spec.backend)
|
||||
backend = self.backend_selector.select_backend(
|
||||
language, "edit", target_spec.backend
|
||||
)
|
||||
|
||||
touched_files = []
|
||||
stdout_parts = []
|
||||
@@ -654,10 +773,14 @@ class HanzoTools:
|
||||
)
|
||||
if "error" in result:
|
||||
raise RuntimeError(str(result["error"]))
|
||||
touched_files = result.get("applied_files", result.get("touched_files", []))
|
||||
touched_files = result.get(
|
||||
"applied_files", result.get("touched_files", [])
|
||||
)
|
||||
errors.extend(result.get("apply_errors", []))
|
||||
if apply:
|
||||
stdout_parts.append(f"Renamed symbol to '{args.new_name}' in {len(touched_files)} files")
|
||||
stdout_parts.append(
|
||||
f"Renamed symbol to '{args.new_name}' in {len(touched_files)} files"
|
||||
)
|
||||
else:
|
||||
stdout_parts.append(
|
||||
f"Would rename symbol to '{args.new_name}' at {args.file}:{args.pos['line']}:{args.pos['character']}"
|
||||
@@ -676,10 +799,14 @@ class HanzoTools:
|
||||
)
|
||||
if "error" in result:
|
||||
raise RuntimeError(str(result["error"]))
|
||||
touched_files = result.get("applied_files", result.get("touched_files", []))
|
||||
touched_files = result.get(
|
||||
"applied_files", result.get("touched_files", [])
|
||||
)
|
||||
errors.extend(result.get("apply_errors", []))
|
||||
if apply:
|
||||
stdout_parts.append(f"Applied code actions to {len(touched_files)} files")
|
||||
stdout_parts.append(
|
||||
f"Applied code actions to {len(touched_files)} files"
|
||||
)
|
||||
else:
|
||||
stdout_parts.append(f"Would apply code actions to {args.file}")
|
||||
|
||||
@@ -690,20 +817,30 @@ class HanzoTools:
|
||||
touched_files = result.get("touched_files", [])
|
||||
errors.extend(result.get("errors", []))
|
||||
if apply:
|
||||
stdout_parts.append(f"Organized imports in {len(touched_files)} files")
|
||||
stdout_parts.append(
|
||||
f"Organized imports in {len(touched_files)} files"
|
||||
)
|
||||
else:
|
||||
stdout_parts.append(f"Would organize imports in {len(resolved['paths'])} files")
|
||||
stdout_parts.append(
|
||||
f"Would organize imports in {len(resolved['paths'])} files"
|
||||
)
|
||||
|
||||
elif args.op == "apply_workspace_edit":
|
||||
if not args.workspace_edit:
|
||||
raise ValueError("apply_workspace_edit requires workspace_edit")
|
||||
result = await self.lsp_bridge.apply_workspace_edit(args.workspace_edit, workspace["root"])
|
||||
result = await self.lsp_bridge.apply_workspace_edit(
|
||||
args.workspace_edit, workspace["root"]
|
||||
)
|
||||
touched_files = result.get("touched_files", [])
|
||||
errors.extend(result.get("errors", []))
|
||||
if apply:
|
||||
stdout_parts.append(f"Applied WorkspaceEdit to {len(touched_files)} files")
|
||||
stdout_parts.append(
|
||||
f"Applied WorkspaceEdit to {len(touched_files)} files"
|
||||
)
|
||||
else:
|
||||
stdout_parts.append(f"Would apply WorkspaceEdit to {len(touched_files)} files")
|
||||
stdout_parts.append(
|
||||
f"Would apply WorkspaceEdit to {len(touched_files)} files"
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
ok=len(errors) == 0,
|
||||
@@ -743,7 +880,9 @@ class HanzoTools:
|
||||
workspace = resolved["workspace"]
|
||||
language = resolved["language"]
|
||||
|
||||
backend = self.backend_selector.select_backend(language, "fmt", target_spec.backend)
|
||||
backend = self.backend_selector.select_backend(
|
||||
language, "fmt", target_spec.backend
|
||||
)
|
||||
env = self._prepare_environment(workspace, target_spec)
|
||||
|
||||
touched_files = []
|
||||
@@ -785,14 +924,21 @@ class HanzoTools:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=30
|
||||
cmd,
|
||||
cwd=workspace["root"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
touched_files.append(file_path)
|
||||
stdout_parts.append(f"Formatted: {file_path}")
|
||||
else:
|
||||
stderr_parts.append(f"Failed to format {file_path}: {result.stderr}")
|
||||
stderr_parts.append(
|
||||
f"Failed to format {file_path}: {result.stderr}"
|
||||
)
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
stderr_parts.append(f"Timeout formatting {file_path}")
|
||||
@@ -837,7 +983,9 @@ class HanzoTools:
|
||||
workspace = resolved["workspace"]
|
||||
language = resolved["language"]
|
||||
|
||||
backend = self.backend_selector.select_backend(language, "test", target_spec.backend)
|
||||
backend = self.backend_selector.select_backend(
|
||||
language, "test", target_spec.backend
|
||||
)
|
||||
env = self._prepare_environment(workspace, target_spec)
|
||||
|
||||
# Build test command based on language and scope
|
||||
@@ -950,7 +1098,9 @@ class HanzoTools:
|
||||
workspace = resolved["workspace"]
|
||||
language = resolved["language"]
|
||||
|
||||
backend = self.backend_selector.select_backend(language, "build", target_spec.backend)
|
||||
backend = self.backend_selector.select_backend(
|
||||
language, "build", target_spec.backend
|
||||
)
|
||||
env = self._prepare_environment(workspace, target_spec)
|
||||
|
||||
# Build command based on language
|
||||
@@ -996,7 +1146,14 @@ class HanzoTools:
|
||||
execution_time=(datetime.utcnow() - start_time).total_seconds(),
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=300)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=workspace["root"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
return ToolResult(
|
||||
ok=result.returncode == 0,
|
||||
@@ -1036,7 +1193,9 @@ class HanzoTools:
|
||||
workspace = resolved["workspace"]
|
||||
language = resolved["language"]
|
||||
|
||||
backend = self.backend_selector.select_backend(language, "lint", target_spec.backend)
|
||||
backend = self.backend_selector.select_backend(
|
||||
language, "lint", target_spec.backend
|
||||
)
|
||||
env = self._prepare_environment(workspace, target_spec)
|
||||
|
||||
# Build lint command based on language
|
||||
@@ -1094,7 +1253,14 @@ class HanzoTools:
|
||||
execution_time=(datetime.utcnow() - start_time).total_seconds(),
|
||||
)
|
||||
|
||||
result = subprocess.run(cmd, cwd=workspace["root"], env=env, capture_output=True, text=True, timeout=120)
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=workspace["root"],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
|
||||
# Lint tools often return non-zero for warnings
|
||||
success = result.returncode == 0
|
||||
@@ -1140,7 +1306,9 @@ class HanzoTools:
|
||||
violations = []
|
||||
|
||||
for rule in args.rules:
|
||||
rule_violations = await self._check_guard_rule(rule, resolved["paths"], workspace["root"], language)
|
||||
rule_violations = await self._check_guard_rule(
|
||||
rule, resolved["paths"], workspace["root"], language
|
||||
)
|
||||
violations.extend(rule_violations)
|
||||
|
||||
stdout_lines = []
|
||||
@@ -1163,7 +1331,9 @@ class HanzoTools:
|
||||
stdout="\n".join(stdout_lines),
|
||||
stderr="",
|
||||
exit_code=0 if len(violations) == 0 else 1,
|
||||
errors=[f"{v.rule_id}: {v.file}:{v.line}:{v.column}" for v in violations],
|
||||
errors=[
|
||||
f"{v.rule_id}: {v.file}:{v.line}:{v.column}" for v in violations
|
||||
],
|
||||
violations=[v.model_dump() for v in violations],
|
||||
execution_time=(datetime.utcnow() - start_time).total_seconds(),
|
||||
)
|
||||
@@ -1200,12 +1370,18 @@ class HanzoTools:
|
||||
matched_files.append(file_path)
|
||||
|
||||
if rule.type == "import" and rule.forbid_import_prefix:
|
||||
wants_go = language in ["go", "auto"] and any(p.endswith(".go") for p in matched_files)
|
||||
wants_go = language in ["go", "auto"] and any(
|
||||
p.endswith(".go") for p in matched_files
|
||||
)
|
||||
wants_ts = language in ["ts", "js", "auto"] and any(
|
||||
p.endswith((".ts", ".tsx", ".js", ".jsx")) for p in matched_files
|
||||
)
|
||||
go_index = await self._go_package_index(workspace_root) if wants_go else None
|
||||
ts_graph = self._build_ts_dependency_graph(workspace_root) if wants_ts else None
|
||||
go_index = (
|
||||
await self._go_package_index(workspace_root) if wants_go else None
|
||||
)
|
||||
ts_graph = (
|
||||
self._build_ts_dependency_graph(workspace_root) if wants_ts else None
|
||||
)
|
||||
else:
|
||||
go_index = None
|
||||
ts_graph = None
|
||||
@@ -1223,7 +1399,11 @@ class HanzoTools:
|
||||
if pattern.search(line):
|
||||
violations.append(
|
||||
GuardViolation(
|
||||
file=file_path, line=line_num, column=1, text=line.strip(), rule_id=rule.id
|
||||
file=file_path,
|
||||
line=line_num,
|
||||
column=1,
|
||||
text=line.strip(),
|
||||
rule_id=rule.id,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1333,7 +1513,13 @@ class HanzoTools:
|
||||
import_path = self._extract_go_import_path(line)
|
||||
if import_path:
|
||||
column = raw.find(import_path) + 1
|
||||
imports.append({"import_path": import_path, "line": idx, "column": max(1, column)})
|
||||
imports.append(
|
||||
{
|
||||
"import_path": import_path,
|
||||
"line": idx,
|
||||
"column": max(1, column),
|
||||
}
|
||||
)
|
||||
return imports
|
||||
|
||||
def _extract_go_import_path(self, line: str) -> Optional[str]:
|
||||
@@ -1360,7 +1546,13 @@ class HanzoTools:
|
||||
if match:
|
||||
module = match.group(1)
|
||||
column = raw.find(module) + 1
|
||||
imports.append({"import_path": module, "line": idx, "column": max(1, column)})
|
||||
imports.append(
|
||||
{
|
||||
"import_path": module,
|
||||
"line": idx,
|
||||
"column": max(1, column),
|
||||
}
|
||||
)
|
||||
return imports
|
||||
|
||||
def _build_ts_dependency_graph(self, workspace_root: str) -> Dict[str, Any]:
|
||||
@@ -1453,7 +1645,11 @@ class HanzoTools:
|
||||
import_path = pkg.get("ImportPath", "")
|
||||
deps = set(pkg.get("Deps", []) or [])
|
||||
pkg_deps[import_path] = deps
|
||||
for name in pkg.get("GoFiles", []) + pkg.get("CgoFiles", []) + pkg.get("CompiledGoFiles", []):
|
||||
for name in (
|
||||
pkg.get("GoFiles", [])
|
||||
+ pkg.get("CgoFiles", [])
|
||||
+ pkg.get("CompiledGoFiles", [])
|
||||
):
|
||||
file_to_pkg[str(Path(pkg.get("Dir", "")) / name)] = import_path
|
||||
return {"file_to_pkg": file_to_pkg, "pkg_deps": pkg_deps}
|
||||
|
||||
@@ -1470,7 +1666,9 @@ if __name__ == "__main__":
|
||||
|
||||
async def main():
|
||||
parser = argparse.ArgumentParser(description="Hanzo MCP Tools")
|
||||
parser.add_argument("tool", choices=["edit", "fmt", "test", "build", "lint", "guard"])
|
||||
parser.add_argument(
|
||||
"tool", choices=["edit", "fmt", "test", "build", "lint", "guard"]
|
||||
)
|
||||
parser.add_argument("target", help="Target specification")
|
||||
parser.add_argument("--language", default="auto")
|
||||
parser.add_argument("--backend", default="auto")
|
||||
@@ -1482,7 +1680,12 @@ if __name__ == "__main__":
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
target_spec = TargetSpec(target=args.target, language=args.language, backend=args.backend, dry_run=args.dry_run)
|
||||
target_spec = TargetSpec(
|
||||
target=args.target,
|
||||
language=args.language,
|
||||
backend=args.backend,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
|
||||
if args.tool == "edit":
|
||||
if not args.op:
|
||||
@@ -1516,7 +1719,12 @@ if __name__ == "__main__":
|
||||
elif args.tool == "guard":
|
||||
# Example guard rules
|
||||
example_rules = [
|
||||
GuardRule(id="no-node-in-sdk", type="import", glob="sdk/**/*.py", forbid_import_prefix="node")
|
||||
GuardRule(
|
||||
id="no-node-in-sdk",
|
||||
type="import",
|
||||
glob="sdk/**/*.py",
|
||||
forbid_import_prefix="node",
|
||||
)
|
||||
]
|
||||
result = await tools.guard(target_spec, GuardArgs(rules=example_rules))
|
||||
|
||||
|
||||
@@ -49,7 +49,12 @@ class HanzoMCPServer:
|
||||
},
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
|
||||
"enum": [
|
||||
"rename",
|
||||
"code_action",
|
||||
"organize_imports",
|
||||
"apply_workspace_edit",
|
||||
],
|
||||
"description": "Operation to perform",
|
||||
},
|
||||
"language": {
|
||||
@@ -57,17 +62,30 @@ class HanzoMCPServer:
|
||||
"default": "auto",
|
||||
"description": "Language override (auto, go, ts, py, rs, cc, sol)",
|
||||
},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
"description": "Environment variables",
|
||||
},
|
||||
"file": {"type": "string", "description": "File path for rename/code_action operations"},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "File path for rename/code_action operations",
|
||||
},
|
||||
"pos": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
"description": "Position for rename/code_action",
|
||||
},
|
||||
"range": {
|
||||
@@ -75,16 +93,25 @@ class HanzoMCPServer:
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Range for code actions",
|
||||
},
|
||||
"new_name": {"type": "string", "description": "New name for rename operation"},
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "New name for rename operation",
|
||||
},
|
||||
"only": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -119,9 +146,20 @@ class HanzoMCPServer:
|
||||
"type": "string",
|
||||
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
|
||||
},
|
||||
"language": {"type": "string", "default": "auto", "description": "Language override"},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -137,7 +175,11 @@ class HanzoMCPServer:
|
||||
},
|
||||
"description": "Tool-specific options",
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview mode",
|
||||
},
|
||||
},
|
||||
"required": ["target"],
|
||||
"additionalProperties": False,
|
||||
@@ -153,9 +195,20 @@ class HanzoMCPServer:
|
||||
"type": "string",
|
||||
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
|
||||
},
|
||||
"language": {"type": "string", "default": "auto", "description": "Language override"},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -164,14 +217,31 @@ class HanzoMCPServer:
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run_filter": {"type": "string", "description": "Test filter pattern"},
|
||||
"count": {"type": "integer", "description": "Number of times to run each test"},
|
||||
"race": {"type": "boolean", "description": "Enable race detection (Go)"},
|
||||
"watch": {"type": "boolean", "default": False, "description": "Watch mode"},
|
||||
"run_filter": {
|
||||
"type": "string",
|
||||
"description": "Test filter pattern",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Number of times to run each test",
|
||||
},
|
||||
"race": {
|
||||
"type": "boolean",
|
||||
"description": "Enable race detection (Go)",
|
||||
},
|
||||
"watch": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Watch mode",
|
||||
},
|
||||
},
|
||||
"description": "Test-specific options",
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview mode",
|
||||
},
|
||||
},
|
||||
"required": ["target"],
|
||||
"additionalProperties": False,
|
||||
@@ -187,9 +257,20 @@ class HanzoMCPServer:
|
||||
"type": "string",
|
||||
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
|
||||
},
|
||||
"language": {"type": "string", "default": "auto", "description": "Language override"},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -198,7 +279,11 @@ class HanzoMCPServer:
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"release": {"type": "boolean", "default": False, "description": "Release build"},
|
||||
"release": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Release build",
|
||||
},
|
||||
"features": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
@@ -207,7 +292,11 @@ class HanzoMCPServer:
|
||||
},
|
||||
"description": "Build-specific options",
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview mode",
|
||||
},
|
||||
},
|
||||
"required": ["target"],
|
||||
"additionalProperties": False,
|
||||
@@ -223,9 +312,20 @@ class HanzoMCPServer:
|
||||
"type": "string",
|
||||
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
|
||||
},
|
||||
"language": {"type": "string", "default": "auto", "description": "Language override"},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -242,7 +342,11 @@ class HanzoMCPServer:
|
||||
},
|
||||
"description": "Lint-specific options",
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview mode",
|
||||
},
|
||||
},
|
||||
"required": ["target"],
|
||||
"additionalProperties": False,
|
||||
@@ -258,9 +362,20 @@ class HanzoMCPServer:
|
||||
"type": "string",
|
||||
"description": "Target: file:<path>, dir:<path>, pkg:<spec>, ws, or changed",
|
||||
},
|
||||
"language": {"type": "string", "default": "auto", "description": "Language override"},
|
||||
"backend": {"type": "string", "default": "auto", "description": "Backend override"},
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Language override",
|
||||
},
|
||||
"backend": {
|
||||
"type": "string",
|
||||
"default": "auto",
|
||||
"description": "Backend override",
|
||||
},
|
||||
"root": {
|
||||
"type": "string",
|
||||
"description": "Workspace root override",
|
||||
},
|
||||
"env": {
|
||||
"type": "object",
|
||||
"additionalProperties": {"type": "string"},
|
||||
@@ -272,7 +387,10 @@ class HanzoMCPServer:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"type": {"type": "string", "enum": ["regex", "import", "generated"]},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["regex", "import", "generated"],
|
||||
},
|
||||
"glob": {"type": "string"},
|
||||
"pattern": {"type": "string"},
|
||||
"forbid_import_prefix": {"type": "string"},
|
||||
@@ -282,7 +400,11 @@ class HanzoMCPServer:
|
||||
},
|
||||
"description": "Guard rules to check",
|
||||
},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Preview mode"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Preview mode",
|
||||
},
|
||||
},
|
||||
"required": ["target", "rules"],
|
||||
"additionalProperties": False,
|
||||
@@ -304,8 +426,15 @@ class HanzoMCPServer:
|
||||
"default": "symbols",
|
||||
"description": "Search type",
|
||||
},
|
||||
"language": {"type": "string", "description": "Filter by language"},
|
||||
"limit": {"type": "integer", "default": 50, "description": "Maximum results"},
|
||||
"language": {
|
||||
"type": "string",
|
||||
"description": "Filter by language",
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 50,
|
||||
"description": "Maximum results",
|
||||
},
|
||||
},
|
||||
"required": ["query"],
|
||||
},
|
||||
@@ -316,7 +445,11 @@ class HanzoMCPServer:
|
||||
inputSchema={
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {"type": "integer", "default": 10, "description": "Number of recent sessions"}
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"default": 10,
|
||||
"description": "Number of recent sessions",
|
||||
}
|
||||
},
|
||||
},
|
||||
),
|
||||
@@ -346,9 +479,13 @@ class HanzoMCPServer:
|
||||
only=arguments.get("only", []),
|
||||
)
|
||||
|
||||
backend.session_manager.log_tool_execution("edit", arguments, result)
|
||||
backend.session_manager.log_tool_execution(
|
||||
"edit", arguments, result
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "fmt":
|
||||
target_spec = TargetSpec(
|
||||
@@ -360,11 +497,15 @@ class HanzoMCPServer:
|
||||
dry_run=arguments.get("dry_run", False),
|
||||
)
|
||||
|
||||
result = await backend.fmt(target_spec, opts=arguments.get("opts", {}))
|
||||
result = await backend.fmt(
|
||||
target_spec, opts=arguments.get("opts", {})
|
||||
)
|
||||
|
||||
backend.session_manager.log_tool_execution("fmt", arguments, result)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "test":
|
||||
target_spec = TargetSpec(
|
||||
@@ -376,11 +517,17 @@ class HanzoMCPServer:
|
||||
dry_run=arguments.get("dry_run", False),
|
||||
)
|
||||
|
||||
result = await backend.test(target_spec, opts=arguments.get("opts", {}))
|
||||
result = await backend.test(
|
||||
target_spec, opts=arguments.get("opts", {})
|
||||
)
|
||||
|
||||
backend.session_manager.log_tool_execution("test", arguments, result)
|
||||
backend.session_manager.log_tool_execution(
|
||||
"test", arguments, result
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "build":
|
||||
target_spec = TargetSpec(
|
||||
@@ -392,11 +539,17 @@ class HanzoMCPServer:
|
||||
dry_run=arguments.get("dry_run", False),
|
||||
)
|
||||
|
||||
result = await backend.build(target_spec, opts=arguments.get("opts", {}))
|
||||
result = await backend.build(
|
||||
target_spec, opts=arguments.get("opts", {})
|
||||
)
|
||||
|
||||
backend.session_manager.log_tool_execution("build", arguments, result)
|
||||
backend.session_manager.log_tool_execution(
|
||||
"build", arguments, result
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "lint":
|
||||
target_spec = TargetSpec(
|
||||
@@ -408,11 +561,17 @@ class HanzoMCPServer:
|
||||
dry_run=arguments.get("dry_run", False),
|
||||
)
|
||||
|
||||
result = await backend.lint(target_spec, opts=arguments.get("opts", {}))
|
||||
result = await backend.lint(
|
||||
target_spec, opts=arguments.get("opts", {})
|
||||
)
|
||||
|
||||
backend.session_manager.log_tool_execution("lint", arguments, result)
|
||||
backend.session_manager.log_tool_execution(
|
||||
"lint", arguments, result
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "guard":
|
||||
target_spec = TargetSpec(
|
||||
@@ -426,9 +585,13 @@ class HanzoMCPServer:
|
||||
|
||||
result = await backend.guard(target_spec, rules=arguments["rules"])
|
||||
|
||||
backend.session_manager.log_tool_execution("guard", arguments, result)
|
||||
backend.session_manager.log_tool_execution(
|
||||
"guard", arguments, result
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=self._format_tool_result(result))]
|
||||
return [
|
||||
TextContent(type="text", text=self._format_tool_result(result))
|
||||
]
|
||||
|
||||
elif name == "search_codebase":
|
||||
query = arguments["query"]
|
||||
@@ -439,12 +602,22 @@ class HanzoMCPServer:
|
||||
if search_type == "symbols":
|
||||
results = backend.indexer.search_symbols(query, language)
|
||||
results_text = "\n".join(
|
||||
[f"{r['path']}:{r['line']} - {r['kind']} {r['name']}" for r in results[:limit]]
|
||||
[
|
||||
f"{r['path']}:{r['line']} - {r['kind']} {r['name']}"
|
||||
for r in results[:limit]
|
||||
]
|
||||
)
|
||||
else:
|
||||
results_text = f"Search type '{search_type}' not implemented yet"
|
||||
results_text = (
|
||||
f"Search type '{search_type}' not implemented yet"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text=f"Search results for '{query}':\n{results_text}")]
|
||||
return [
|
||||
TextContent(
|
||||
type="text",
|
||||
text=f"Search results for '{query}':\n{results_text}",
|
||||
)
|
||||
]
|
||||
|
||||
elif name == "get_session_history":
|
||||
limit = arguments.get("limit", 10)
|
||||
@@ -461,7 +634,9 @@ class HanzoMCPServer:
|
||||
return [TextContent(type="text", text=f"Unknown tool: {name}")]
|
||||
|
||||
except Exception as e:
|
||||
return [TextContent(type="text", text=f"Error executing {name}: {str(e)}")]
|
||||
return [
|
||||
TextContent(type="text", text=f"Error executing {name}: {str(e)}")
|
||||
]
|
||||
|
||||
def _format_tool_result(self, result: ToolResult) -> str:
|
||||
"""Format tool result for display"""
|
||||
@@ -508,7 +683,8 @@ class HanzoMCPServer:
|
||||
server_name="hanzo-mcp",
|
||||
server_version="1.0.0",
|
||||
capabilities=self.server.get_capabilities(
|
||||
notification_options=NotificationOptions(), experimental_capabilities={}
|
||||
notification_options=NotificationOptions(),
|
||||
experimental_capabilities={},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -60,7 +60,11 @@ class PluginMemoryService:
|
||||
self._initialized = False
|
||||
|
||||
async def store_memory(
|
||||
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
|
||||
self,
|
||||
content: str,
|
||||
metadata: Dict[str, Any],
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> str:
|
||||
"""Store a memory using active plugins."""
|
||||
if not self._initialized:
|
||||
@@ -76,7 +80,11 @@ class PluginMemoryService:
|
||||
return await plugin.store_memory(content, metadata, user_id, project_id)
|
||||
|
||||
async def retrieve_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Retrieve memories using active plugins."""
|
||||
if not self._initialized:
|
||||
@@ -92,7 +100,11 @@ class PluginMemoryService:
|
||||
return await plugin.retrieve_memory(query, user_id, project_id, limit)
|
||||
|
||||
async def search_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search memories using active plugins."""
|
||||
if not self._initialized:
|
||||
@@ -107,7 +119,9 @@ class PluginMemoryService:
|
||||
plugin = active_plugins[0]
|
||||
return await plugin.search_memory(query, user_id, project_id, limit)
|
||||
|
||||
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
|
||||
async def delete_memory(
|
||||
self, memory_id: str, user_id: str = "default", project_id: str = "default"
|
||||
) -> bool:
|
||||
"""Delete a memory using active plugins."""
|
||||
if not self._initialized:
|
||||
await self.initialize()
|
||||
|
||||
@@ -40,24 +40,38 @@ class MemoryBackendPlugin(Protocol):
|
||||
...
|
||||
|
||||
async def store_memory(
|
||||
self, content: str, metadata: Dict[str, Any], user_id: str = "default", project_id: str = "default"
|
||||
self,
|
||||
content: str,
|
||||
metadata: Dict[str, Any],
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
) -> str:
|
||||
"""Store a memory and return its ID."""
|
||||
...
|
||||
|
||||
async def retrieve_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Retrieve memories based on query."""
|
||||
...
|
||||
|
||||
async def search_memory(
|
||||
self, query: str, user_id: str = "default", project_id: str = "default", limit: int = 10
|
||||
self,
|
||||
query: str,
|
||||
user_id: str = "default",
|
||||
project_id: str = "default",
|
||||
limit: int = 10,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Search memories based on query with scoring."""
|
||||
...
|
||||
|
||||
async def delete_memory(self, memory_id: str, user_id: str = "default", project_id: str = "default") -> bool:
|
||||
async def delete_memory(
|
||||
self, memory_id: str, user_id: str = "default", project_id: str = "default"
|
||||
) -> bool:
|
||||
"""Delete a memory by ID."""
|
||||
...
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -594,7 +594,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 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"
|
||||
|
||||
@@ -21,7 +21,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
# 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
|
||||
)
|
||||
|
||||
# Cached imports - lazy loaded on first use
|
||||
_FastMCP = None
|
||||
@@ -162,8 +164,12 @@ class HanzoMCPServer:
|
||||
# Generate a secure random token if none provided
|
||||
self.auth_token = secrets.token_urlsafe(32)
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"No auth token provided. Generated token: {self.auth_token}")
|
||||
logger.warning("Set HANZO_MCP_TOKEN environment variable for persistent auth")
|
||||
logger.warning(
|
||||
f"No auth token provided. Generated token: {self.auth_token}"
|
||||
)
|
||||
logger.warning(
|
||||
"Set HANZO_MCP_TOKEN environment variable for persistent auth"
|
||||
)
|
||||
|
||||
# Initialize permissions and command executor
|
||||
PermissionManager = _get_permission_manager()
|
||||
@@ -268,7 +274,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()
|
||||
|
||||
def _background_cleanup(self) -> None:
|
||||
|
||||
@@ -97,9 +97,13 @@ def register_all_tools(
|
||||
# Filesystem tools
|
||||
for tool in PACKAGE_TOOL_PREFIXES.get("filesystem", []):
|
||||
if tool in ["write", "edit", "multi_edit"]:
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(tool, not disable_write_tools)
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(
|
||||
tool, not disable_write_tools
|
||||
)
|
||||
elif tool in ["ast", "search"]:
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(tool, not disable_search_tools)
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(
|
||||
tool, not disable_search_tools
|
||||
)
|
||||
else:
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(tool, True)
|
||||
|
||||
@@ -132,7 +136,9 @@ def register_all_tools(
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(tool, True)
|
||||
|
||||
# Agent tools
|
||||
resolved_enabled_tools["agent"] = enable_agent_tool or is_tool_enabled("agent", False)
|
||||
resolved_enabled_tools["agent"] = enable_agent_tool or is_tool_enabled(
|
||||
"agent", False
|
||||
)
|
||||
resolved_enabled_tools["swarm"] = is_tool_enabled("swarm", False)
|
||||
for tool in ["claude", "codex", "gemini", "grok", "code_auth"]:
|
||||
resolved_enabled_tools[tool] = is_tool_enabled(tool, enable_agent_tool)
|
||||
@@ -170,7 +176,9 @@ def register_all_tools(
|
||||
discovered = loader.discover_packages()
|
||||
|
||||
if discovered:
|
||||
logger.info(f"Discovered {len(discovered)} tool packages: {', '.join(discovered.keys())}")
|
||||
logger.info(
|
||||
f"Discovered {len(discovered)} tool packages: {', '.join(discovered.keys())}"
|
||||
)
|
||||
|
||||
# Load all discovered tools
|
||||
loaded = loader.load_all(
|
||||
|
||||
@@ -123,7 +123,9 @@ class BuildTool(DevToolBase):
|
||||
language_used=self.language,
|
||||
backend_used=self.backend,
|
||||
scope_resolved=self.target,
|
||||
errors=["No build configuration found (tsconfig.json or package.json build script)"],
|
||||
errors=[
|
||||
"No build configuration found (tsconfig.json or package.json build script)"
|
||||
],
|
||||
)
|
||||
|
||||
async def _build_python(self) -> DevResult:
|
||||
@@ -212,7 +214,9 @@ class BuildTool(DevToolBase):
|
||||
language_used=self.language,
|
||||
backend_used=self.backend,
|
||||
scope_resolved=self.target,
|
||||
errors=["No build system detected (CMakeLists.txt, build.ninja, or Makefile)"],
|
||||
errors=[
|
||||
"No build system detected (CMakeLists.txt, build.ninja, or Makefile)"
|
||||
],
|
||||
)
|
||||
|
||||
async def _build_solidity(self) -> DevResult:
|
||||
@@ -328,7 +332,9 @@ class BuildTool(DevToolBase):
|
||||
|
||||
async def _build_with_ninja(self) -> DevResult:
|
||||
"""Build with Ninja"""
|
||||
result = self._run_command(["ninja"], cwd=str(Path(self.workspace["root"]) / "build"))
|
||||
result = self._run_command(
|
||||
["ninja"], cwd=str(Path(self.workspace["root"]) / "build")
|
||||
)
|
||||
|
||||
return create_dev_result(
|
||||
ok=result.returncode == 0,
|
||||
@@ -426,7 +432,13 @@ async def build_tool_handler(
|
||||
"""MCP handler for build tool"""
|
||||
|
||||
tool = BuildTool(
|
||||
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
|
||||
target=target,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env,
|
||||
dry_run=dry_run,
|
||||
opts=opts or {},
|
||||
)
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
@@ -78,7 +78,13 @@ class MCPToolTimeoutManager:
|
||||
return self.timeout
|
||||
|
||||
async def _background_tool_execution(
|
||||
self, tool_func: Callable, tool_name: str, ctx: MCPContext, process_id: str, log_file: Path, **params: Any
|
||||
self,
|
||||
tool_func: Callable,
|
||||
tool_name: str,
|
||||
ctx: MCPContext,
|
||||
process_id: str,
|
||||
log_file: Path,
|
||||
**params: Any,
|
||||
) -> None:
|
||||
"""Execute tool in background and log results.
|
||||
|
||||
@@ -131,7 +137,9 @@ class MCPToolTimeoutManager:
|
||||
self.process_manager.mark_completed(process_id, 1)
|
||||
|
||||
|
||||
def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutManager] = None):
|
||||
def with_auto_timeout(
|
||||
tool_name: str, timeout_manager: Optional[MCPToolTimeoutManager] = None
|
||||
):
|
||||
"""Decorator to add automatic timeout and backgrounding to MCP tools.
|
||||
|
||||
Args:
|
||||
@@ -153,11 +161,13 @@ def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutMa
|
||||
# Method call: self, ctx, **params
|
||||
self_or_ctx = args[0]
|
||||
ctx = args[1]
|
||||
|
||||
def call_func():
|
||||
return func(self_or_ctx, ctx, **params)
|
||||
elif len(args) == 1:
|
||||
# Function call: ctx, **params
|
||||
ctx = args[0]
|
||||
|
||||
def call_func():
|
||||
return func(ctx, **params)
|
||||
else:
|
||||
@@ -186,7 +196,9 @@ def with_auto_timeout(tool_name: str, timeout_manager: Optional[MCPToolTimeoutMa
|
||||
return f"Operation timed out after {timeout_formatted}. Backgrounding unavailable."
|
||||
|
||||
process_id = f"{tool_name}_{uuid.uuid4().hex[:8]}"
|
||||
log_file = await timeout_manager.process_manager.create_log_file(process_id)
|
||||
log_file = await timeout_manager.process_manager.create_log_file(
|
||||
process_id
|
||||
)
|
||||
|
||||
# Start background execution (need to reconstruct the call)
|
||||
async def background_call():
|
||||
|
||||
@@ -49,7 +49,12 @@ def with_error_logging(tool_name: str) -> Callable:
|
||||
log_call_signature_error(tool_name, expected, actual, e)
|
||||
|
||||
# Log the error
|
||||
log_tool_error(tool_name, e, params=kwargs, context="Call signature mismatch or type error")
|
||||
log_tool_error(
|
||||
tool_name,
|
||||
e,
|
||||
params=kwargs,
|
||||
context="Call signature mismatch or type error",
|
||||
)
|
||||
|
||||
# Return user-friendly error message
|
||||
return (
|
||||
|
||||
@@ -126,7 +126,9 @@ class CLIToolFactory:
|
||||
)
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=effective_timeout)
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
proc.communicate(), timeout=effective_timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
@@ -149,7 +151,8 @@ class CLIToolFactory:
|
||||
# Register with MCP server
|
||||
try:
|
||||
self._mcp_server.tool(
|
||||
name=tool_def.name, description=f"{tool_def.description}\n\nCommand: {tool_def.command}"
|
||||
name=tool_def.name,
|
||||
description=f"{tool_def.description}\n\nCommand: {tool_def.command}",
|
||||
)(cli_handler)
|
||||
self._registered_handlers[tool_def.name] = cli_handler
|
||||
logger.info(f"Registered dynamic CLI tool: {tool_def.name}")
|
||||
@@ -252,7 +255,11 @@ class CLIToolFactory:
|
||||
self._save_config()
|
||||
|
||||
# Note: Can't unregister from FastMCP, but tool won't be re-registered on restart
|
||||
return {"success": True, "name": name, "message": "Tool disabled. Restart server to remove."}
|
||||
return {
|
||||
"success": True,
|
||||
"name": name,
|
||||
"message": "Tool disabled. Restart server to remove.",
|
||||
}
|
||||
|
||||
async def get_help(self, name: str) -> str:
|
||||
"""Get help text for a CLI tool by running --help."""
|
||||
@@ -305,7 +312,10 @@ Examples:
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
@mcp_server.tool(name="cli_list", description="List all dynamic CLI tools that have been created.")
|
||||
@mcp_server.tool(
|
||||
name="cli_list",
|
||||
description="List all dynamic CLI tools that have been created.",
|
||||
)
|
||||
async def cli_list() -> str:
|
||||
tools = factory.list()
|
||||
if not tools:
|
||||
@@ -323,7 +333,9 @@ Examples:
|
||||
result = factory.remove(name)
|
||||
return json.dumps(result, indent=2)
|
||||
|
||||
@mcp_server.tool(name="cli_help", description="Get help text for a CLI tool by running --help.")
|
||||
@mcp_server.tool(
|
||||
name="cli_help", description="Get help text for a CLI tool by running --help."
|
||||
)
|
||||
async def cli_help(name: str) -> str:
|
||||
return await factory.get_help(name)
|
||||
|
||||
|
||||
@@ -67,7 +67,9 @@ class ToolContext:
|
||||
"""
|
||||
return self._mcp_context.client_id
|
||||
|
||||
async def set_tool_info(self, tool_name: str, execution_id: str | None = None) -> None:
|
||||
async def set_tool_info(
|
||||
self, tool_name: str, execution_id: str | None = None
|
||||
) -> None:
|
||||
"""Set information about the currently executing tool.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -96,4 +96,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
|
||||
)
|
||||
|
||||
@@ -37,7 +37,11 @@ PACKAGE_TOOL_PREFIXES: dict[str, list[str]] = {
|
||||
"lsp": ["lsp"],
|
||||
"refactor": ["refactor"],
|
||||
"database": ["sql", "graph"], # Consolidated database tools
|
||||
"agent": ["agent", "iching", "review"], # Consolidated agent tools (critic is from reasoning)
|
||||
"agent": [
|
||||
"agent",
|
||||
"iching",
|
||||
"review",
|
||||
], # Consolidated agent tools (critic is from reasoning)
|
||||
"jupyter": ["jupyter"],
|
||||
"editor": ["neovim_edit", "neovim_command", "neovim_session"],
|
||||
"llm": ["llm", "consensus"], # Removed llm_manage
|
||||
@@ -125,7 +129,9 @@ class EntryPointToolLoader:
|
||||
|
||||
discovered[ep.name] = tool_names
|
||||
self._discovered_packages[ep.name] = tools_list
|
||||
logger.debug(f"Discovered package '{ep.name}' with tools: {tool_names}")
|
||||
logger.debug(
|
||||
f"Discovered package '{ep.name}' with tools: {tool_names}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load entry point '{ep.name}': {e}")
|
||||
@@ -230,7 +236,9 @@ class EntryPointToolLoader:
|
||||
|
||||
# Build package-specific enabled_tools
|
||||
package_tool_names = PACKAGE_TOOL_PREFIXES.get(package_name, [])
|
||||
package_enabled = {name: enabled_tools.get(name, True) for name in package_tool_names}
|
||||
package_enabled = {
|
||||
name: enabled_tools.get(name, True) for name in package_tool_names
|
||||
}
|
||||
|
||||
self.load_package(
|
||||
package_name,
|
||||
|
||||
@@ -57,7 +57,8 @@ class MCPErrorLogger:
|
||||
|
||||
# Formatter
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S"
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
general_handler.setFormatter(formatter)
|
||||
@@ -92,7 +93,9 @@ class MCPErrorLogger:
|
||||
}
|
||||
|
||||
# Write to JSON log for structured parsing
|
||||
json_log_file = self.log_dir / f"tool-errors-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
|
||||
json_log_file = (
|
||||
self.log_dir / f"tool-errors-{datetime.now().strftime('%Y-%m-%d')}.jsonl"
|
||||
)
|
||||
try:
|
||||
with open(json_log_file, "a") as f:
|
||||
json.dump(error_data, f)
|
||||
@@ -120,7 +123,9 @@ class MCPErrorLogger:
|
||||
if context:
|
||||
f.write(f"Context: {context}\n")
|
||||
if params:
|
||||
f.write(f"\nParameters:\n{json.dumps(error_data['params'], indent=2)}\n")
|
||||
f.write(
|
||||
f"\nParameters:\n{json.dumps(error_data['params'], indent=2)}\n"
|
||||
)
|
||||
f.write(f"\nTraceback:\n{error_data['traceback']}\n")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to write tool-specific log: {e}")
|
||||
@@ -167,7 +172,9 @@ class MCPErrorLogger:
|
||||
|
||||
# Also log as regular tool error
|
||||
self.log_tool_error(
|
||||
tool_name, error, context=f"Call signature mismatch - Expected: {expected_signature}, Got: {actual_call}"
|
||||
tool_name,
|
||||
error,
|
||||
context=f"Call signature mismatch - Expected: {expected_signature}, Got: {actual_call}",
|
||||
)
|
||||
|
||||
def _sanitize_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
@@ -208,7 +215,9 @@ class MCPErrorLogger:
|
||||
|
||||
return sanitized
|
||||
|
||||
def get_recent_errors(self, tool_name: Optional[str] = None, limit: int = 10) -> list[dict]:
|
||||
def get_recent_errors(
|
||||
self, tool_name: Optional[str] = None, limit: int = 10
|
||||
) -> list[dict]:
|
||||
"""Get recent errors from the JSON log.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -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,6 +275,7 @@ class TokenAwarePaginator:
|
||||
|
||||
# Default token estimation
|
||||
if not estimate_func:
|
||||
|
||||
def estimate_func(x):
|
||||
return estimate_tokens(json.dumps(x) if not isinstance(x, str) else x)
|
||||
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,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():
|
||||
|
||||
@@ -70,7 +70,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
|
||||
@@ -88,11 +92,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:
|
||||
|
||||
@@ -149,7 +149,9 @@ class PermissionManager(BasePermissionManager):
|
||||
# Security check: Ensure resolved path doesn't escape allowed directories
|
||||
# by checking if it's actually under an allowed path after resolution
|
||||
original_path = Path(path)
|
||||
if original_path.is_absolute() and str(resolved_path) != str(original_path.resolve(strict=False)):
|
||||
if original_path.is_absolute() and str(resolved_path) != str(
|
||||
original_path.resolve(strict=False)
|
||||
):
|
||||
# Path resolution changed the path significantly, might be symlink attack
|
||||
# Additional check: is the resolved path still under allowed paths?
|
||||
pass # Continue to normal checks
|
||||
@@ -172,7 +174,10 @@ class PermissionManager(BasePermissionManager):
|
||||
link_target = Path(os.readlink(resolved_path))
|
||||
if link_target.is_absolute():
|
||||
# Absolute symlink - check if it points within allowed paths
|
||||
if not any(self._is_subpath(link_target, ap) for ap in self.allowed_paths):
|
||||
if not any(
|
||||
self._is_subpath(link_target, ap)
|
||||
for ap in self.allowed_paths
|
||||
):
|
||||
return False
|
||||
return True
|
||||
except ValueError:
|
||||
@@ -277,9 +282,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:
|
||||
@@ -303,7 +312,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)
|
||||
|
||||
@@ -26,7 +26,10 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# Tool mappings for persona categories
|
||||
CATEGORY_TOOL_MAPPINGS = {
|
||||
"programmer": ESSENTIAL_TOOLS + ["symbols", "multi_edit"] + SEARCH_TOOLS + BUILD_TOOLS,
|
||||
"programmer": ESSENTIAL_TOOLS
|
||||
+ ["symbols", "multi_edit"]
|
||||
+ SEARCH_TOOLS
|
||||
+ BUILD_TOOLS,
|
||||
"scientist": ESSENTIAL_TOOLS + ["jupyter", "critic"] + AI_TOOLS + VECTOR_TOOLS,
|
||||
"philosopher": ESSENTIAL_TOOLS + ["critic", "think", "search"],
|
||||
"artist": ESSENTIAL_TOOLS + ["watch", "jupyter"] + AI_TOOLS,
|
||||
@@ -35,7 +38,10 @@ CATEGORY_TOOL_MAPPINGS = {
|
||||
"mathematician": ESSENTIAL_TOOLS + ["jupyter", "symbols", "critic"],
|
||||
"musician": ESSENTIAL_TOOLS + ["watch", "todo"],
|
||||
"athlete": ESSENTIAL_TOOLS + ["todo", "watch", "process"],
|
||||
"entrepreneur": ESSENTIAL_TOOLS + ["todo", "agent", "consensus"] + BUILD_TOOLS + DATABASE_TOOLS,
|
||||
"entrepreneur": ESSENTIAL_TOOLS
|
||||
+ ["todo", "agent", "consensus"]
|
||||
+ BUILD_TOOLS
|
||||
+ DATABASE_TOOLS,
|
||||
"activist": ESSENTIAL_TOOLS + ["search", "todo", "rules"],
|
||||
"religious_leader": ESSENTIAL_TOOLS + ["search", "critic", "think"],
|
||||
"military_leader": ESSENTIAL_TOOLS + ["todo", "process", "critic"] + UNIX_TOOLS,
|
||||
@@ -71,7 +77,9 @@ def persona_to_tool_personality(persona: Dict[str, Any]) -> Optional[ToolPersona
|
||||
|
||||
# Determine tools based on category and persona's tool preferences
|
||||
category = persona.get("category", "default")
|
||||
base_tools = CATEGORY_TOOL_MAPPINGS.get(category, CATEGORY_TOOL_MAPPINGS["default"])
|
||||
base_tools = CATEGORY_TOOL_MAPPINGS.get(
|
||||
category, CATEGORY_TOOL_MAPPINGS["default"]
|
||||
)
|
||||
|
||||
# Add tools from persona's tool preferences
|
||||
tools_config = persona.get("tools", {})
|
||||
|
||||
@@ -158,13 +158,17 @@ def register_default_personalities() -> None:
|
||||
if loaded > 0:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(f"Loaded {loaded} personas from hanzo-persona package")
|
||||
logging.getLogger(__name__).debug(
|
||||
f"Loaded {loaded} personas from hanzo-persona package"
|
||||
)
|
||||
except ImportError:
|
||||
pass
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).warning(f"Failed to load personas from package: {e}")
|
||||
logging.getLogger(__name__).warning(
|
||||
f"Failed to load personas from package: {e}"
|
||||
)
|
||||
|
||||
|
||||
def _register_builtin_personalities() -> None:
|
||||
@@ -219,7 +223,16 @@ def _register_builtin_personalities() -> None:
|
||||
+ BUILD_TOOLS
|
||||
+ UNIX_TOOLS
|
||||
+ VECTOR_TOOLS
|
||||
+ ["todo", "rules", "browser", "jupyter", "neovim_edit", "mcp", "refactor", "lsp"]
|
||||
+ [
|
||||
"todo",
|
||||
"rules",
|
||||
"browser",
|
||||
"jupyter",
|
||||
"neovim_edit",
|
||||
"mcp",
|
||||
"refactor",
|
||||
"lsp",
|
||||
]
|
||||
)
|
||||
),
|
||||
environment={"ALL_TOOLS": "enabled"},
|
||||
@@ -229,7 +242,9 @@ def _register_builtin_personalities() -> None:
|
||||
programmer="DevOps Engineer",
|
||||
description="Automate everything",
|
||||
philosophy="You build it, you run it.",
|
||||
tools=list(set(ESSENTIAL_TOOLS + BUILD_TOOLS + UNIX_TOOLS + ["todo", "browser"])),
|
||||
tools=list(
|
||||
set(ESSENTIAL_TOOLS + BUILD_TOOLS + UNIX_TOOLS + ["todo", "browser"])
|
||||
),
|
||||
environment={"CI_CD": "enabled"},
|
||||
cli_tools=[
|
||||
CLIToolDef("docker", "docker", "Docker container management"),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -99,7 +99,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}%")
|
||||
|
||||
@@ -109,10 +111,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("")
|
||||
|
||||
@@ -169,7 +175,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
|
||||
@@ -195,7 +203,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)}")
|
||||
@@ -217,7 +227,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"
|
||||
|
||||
@@ -89,7 +89,20 @@ def format_timeout(seconds: float) -> str:
|
||||
|
||||
# Test the parser
|
||||
if __name__ == "__main__":
|
||||
test_cases = ["2min", "5m", "120s", "30sec", "1.5h", "0.5hr", "90", 120, 3600.0, "1hour", "2hours", "30seconds"]
|
||||
test_cases = [
|
||||
"2min",
|
||||
"5m",
|
||||
"120s",
|
||||
"30sec",
|
||||
"1.5h",
|
||||
"0.5hr",
|
||||
"90",
|
||||
120,
|
||||
3600.0,
|
||||
"1hour",
|
||||
"2hours",
|
||||
"30seconds",
|
||||
]
|
||||
|
||||
for case in test_cases:
|
||||
try:
|
||||
|
||||
@@ -214,14 +214,20 @@ Examples:
|
||||
lines.append(f"[{cat}]")
|
||||
for tool_name, config, is_enabled in sorted(tools, key=lambda x: x[0]):
|
||||
status = "✓" if is_enabled else "○"
|
||||
desc = config.description[:50] + "..." if len(config.description) > 50 else config.description
|
||||
desc = (
|
||||
config.description[:50] + "..."
|
||||
if len(config.description) > 50
|
||||
else config.description
|
||||
)
|
||||
lines.append(f" {status} {tool_name}: {desc}")
|
||||
lines.append("")
|
||||
|
||||
# Summary
|
||||
total = sum(len(t) for t in by_category.values())
|
||||
enabled = sum(1 for tools in by_category.values() for _, _, e in tools if e)
|
||||
lines.append(f"Total: {total} | Enabled: {enabled} | Disabled: {total - enabled}")
|
||||
lines.append(
|
||||
f"Total: {total} | Enabled: {enabled} | Disabled: {total - enabled}"
|
||||
)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -329,7 +335,9 @@ Description: {config.description}"""
|
||||
result = await registry.upgrade(name)
|
||||
|
||||
if result["success"]:
|
||||
upgraded = [r["package"] for r in result.get("results", []) if r.get("success")]
|
||||
upgraded = [
|
||||
r["package"] for r in result.get("results", []) if r.get("success")
|
||||
]
|
||||
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
|
||||
return "✗ Upgrade failed"
|
||||
|
||||
@@ -368,11 +376,19 @@ Description: {config.description}"""
|
||||
@mcp_server.tool()
|
||||
async def tool(
|
||||
action: Action = "list",
|
||||
name: Annotated[Optional[str], Field(description="Tool or package name")] = None,
|
||||
source: Annotated[str, Field(description="Source: pypi, git, local")] = "pypi",
|
||||
version: Annotated[Optional[str], Field(description="Version constraint")] = None,
|
||||
name: Annotated[
|
||||
Optional[str], Field(description="Tool or package name")
|
||||
] = None,
|
||||
source: Annotated[
|
||||
str, Field(description="Source: pypi, git, local")
|
||||
] = "pypi",
|
||||
version: Annotated[
|
||||
Optional[str], Field(description="Version constraint")
|
||||
] = None,
|
||||
persist: Annotated[bool, Field(description="Persist changes")] = True,
|
||||
category: Annotated[Optional[str], Field(description="Filter by category")] = None,
|
||||
category: Annotated[
|
||||
Optional[str], Field(description="Filter by category")
|
||||
] = None,
|
||||
disabled: Annotated[bool, Field(description="Show only disabled")] = False,
|
||||
enabled: Annotated[bool, Field(description="Show only enabled")] = False,
|
||||
ctx: MCPContext = None,
|
||||
|
||||
@@ -127,7 +127,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"}
|
||||
@@ -137,7 +139,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)
|
||||
|
||||
@@ -135,11 +135,15 @@ Sources:
|
||||
result = await registry.upgrade(package)
|
||||
|
||||
if result["success"]:
|
||||
upgraded = [r["package"] for r in result.get("results", []) if r.get("success")]
|
||||
upgraded = [
|
||||
r["package"] for r in result.get("results", []) if r.get("success")
|
||||
]
|
||||
return f"✓ Upgraded: {', '.join(upgraded) if upgraded else 'none'}"
|
||||
else:
|
||||
errors = [
|
||||
f"{r['package']}: {r.get('error')}" for r in result.get("results", []) if not r.get("success")
|
||||
f"{r['package']}: {r.get('error')}"
|
||||
for r in result.get("results", [])
|
||||
if not r.get("success")
|
||||
]
|
||||
return "✗ Some upgrades failed:\n" + "\n".join(errors)
|
||||
|
||||
@@ -165,7 +169,9 @@ Sources:
|
||||
lines = ["Installed tool packages:", ""]
|
||||
for pkg in packages:
|
||||
status = "✓" if pkg["enabled"] else "○"
|
||||
lines.append(f"{status} {pkg['name']} v{pkg['version']} ({pkg['source']})")
|
||||
lines.append(
|
||||
f"{status} {pkg['name']} v{pkg['version']} ({pkg['source']})"
|
||||
)
|
||||
if pkg["tools"]:
|
||||
lines.append(f" Tools: {', '.join(pkg['tools'])}")
|
||||
|
||||
@@ -193,9 +199,15 @@ Sources:
|
||||
@mcp_server.tool()
|
||||
async def tool_install(
|
||||
action: Action,
|
||||
package: Annotated[str | None, Field(description="Package name or URL")] = None,
|
||||
source: Annotated[str, Field(description="Source: pypi, git, or local")] = "pypi",
|
||||
version: Annotated[str | None, Field(description="Version constraint")] = None,
|
||||
package: Annotated[
|
||||
str | None, Field(description="Package name or URL")
|
||||
] = None,
|
||||
source: Annotated[
|
||||
str, Field(description="Source: pypi, git, or local")
|
||||
] = "pypi",
|
||||
version: Annotated[
|
||||
str | None, Field(description="Version constraint")
|
||||
] = None,
|
||||
ctx: MCPContext = None,
|
||||
) -> str:
|
||||
"""Install, update, and manage tool packages dynamically.
|
||||
|
||||
@@ -207,7 +207,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:
|
||||
@@ -242,7 +244,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("")
|
||||
|
||||
|
||||
@@ -54,7 +54,9 @@ class PackageManager:
|
||||
self._tools: dict[str, Any] = {} # name -> tool instance
|
||||
self._tool_classes: dict[str, Type] = {} # name -> tool class
|
||||
self._config_path = Path.home() / ".hanzo" / "mcp" / "registry.json"
|
||||
self._tools_path = Path.home() / ".hanzo" / "tools" # Tool packages install here
|
||||
self._tools_path = (
|
||||
Path.home() / ".hanzo" / "tools"
|
||||
) # Tool packages install here
|
||||
self._reload_callbacks: list[Callable] = []
|
||||
self._mcp_server = None
|
||||
|
||||
@@ -331,7 +333,10 @@ class PackageManager:
|
||||
try:
|
||||
# Clear existing module from cache
|
||||
modules_to_remove = [
|
||||
mod for mod in sys.modules if mod.startswith(package) or mod.startswith(package.replace("-", "_"))
|
||||
mod
|
||||
for mod in sys.modules
|
||||
if mod.startswith(package)
|
||||
or mod.startswith(package.replace("-", "_"))
|
||||
]
|
||||
for mod in modules_to_remove:
|
||||
del sys.modules[mod]
|
||||
@@ -357,11 +362,15 @@ class PackageManager:
|
||||
tool_instance.register(self._mcp_server)
|
||||
self._tools[tool_name] = tool_instance
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register tool {tool_name}: {e}")
|
||||
logger.warning(
|
||||
f"Failed to register tool {tool_name}: {e}"
|
||||
)
|
||||
|
||||
# Check for register_* functions
|
||||
for attr_name in dir(pkg_module):
|
||||
if attr_name.startswith("register_") and callable(getattr(pkg_module, attr_name)):
|
||||
if attr_name.startswith("register_") and callable(
|
||||
getattr(pkg_module, attr_name)
|
||||
):
|
||||
register_func = getattr(pkg_module, attr_name)
|
||||
if self._mcp_server:
|
||||
try:
|
||||
@@ -391,7 +400,11 @@ class PackageManager:
|
||||
}
|
||||
|
||||
except ImportError as e:
|
||||
return {"success": False, "error": f"Import failed: {e}", "package": package}
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Import failed: {e}",
|
||||
"package": package,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
logger.exception(f"Failed to reload package: {package}")
|
||||
@@ -404,7 +417,15 @@ class PackageManager:
|
||||
from hanzo_mcp import __version__ as current_version
|
||||
|
||||
# Check for updates
|
||||
cmd = [sys.executable, "-m", "uv", "pip", "install", "--upgrade", "hanzo-mcp"]
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uv",
|
||||
"pip",
|
||||
"install",
|
||||
"--upgrade",
|
||||
"hanzo-mcp",
|
||||
]
|
||||
|
||||
result = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
|
||||
@@ -65,19 +65,44 @@ class WorkspaceDetector:
|
||||
while current != current.parent:
|
||||
# Check for workspace files in order of preference
|
||||
if (current / "go.work").exists():
|
||||
return {"root": str(current), "type": "go", "config": "go.work", "primary_language": "go"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "go",
|
||||
"config": "go.work",
|
||||
"primary_language": "go",
|
||||
}
|
||||
|
||||
if (current / "pnpm-workspace.yaml").exists():
|
||||
return {"root": str(current), "type": "pnpm", "config": "pnpm-workspace.yaml", "primary_language": "ts"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "pnpm",
|
||||
"config": "pnpm-workspace.yaml",
|
||||
"primary_language": "ts",
|
||||
}
|
||||
|
||||
if (current / "package.json").exists():
|
||||
return {"root": str(current), "type": "npm", "config": "package.json", "primary_language": "ts"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "npm",
|
||||
"config": "package.json",
|
||||
"primary_language": "ts",
|
||||
}
|
||||
|
||||
if (current / "pyproject.toml").exists():
|
||||
return {"root": str(current), "type": "python", "config": "pyproject.toml", "primary_language": "py"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "python",
|
||||
"config": "pyproject.toml",
|
||||
"primary_language": "py",
|
||||
}
|
||||
|
||||
if (current / "Cargo.toml").exists():
|
||||
return {"root": str(current), "type": "rust", "config": "Cargo.toml", "primary_language": "rs"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "rust",
|
||||
"config": "Cargo.toml",
|
||||
"primary_language": "rs",
|
||||
}
|
||||
|
||||
current = current.parent
|
||||
|
||||
@@ -176,7 +201,15 @@ class TargetResolver:
|
||||
files = []
|
||||
|
||||
# Common source file patterns
|
||||
patterns = ["**/*.go", "**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.py", "**/*.rs"]
|
||||
patterns = [
|
||||
"**/*.go",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"**/*.js",
|
||||
"**/*.jsx",
|
||||
"**/*.py",
|
||||
"**/*.rs",
|
||||
]
|
||||
|
||||
for pattern in patterns:
|
||||
files.extend([str(f) for f in path.glob(pattern) if f.is_file()])
|
||||
@@ -221,7 +254,10 @@ class TargetResolver:
|
||||
"""Get changed files from git"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD"], cwd=workspace["root"], capture_output=True, text=True
|
||||
["git", "diff", "--name-only", "HEAD"],
|
||||
cwd=workspace["root"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
|
||||
@@ -251,7 +287,9 @@ class DevToolBase:
|
||||
self.dry_run = dry_run
|
||||
|
||||
# Detect workspace
|
||||
workspace_root = root or TargetResolver.resolve(target, {"root": "."})["package"]
|
||||
workspace_root = (
|
||||
root or TargetResolver.resolve(target, {"root": "."})["package"]
|
||||
)
|
||||
self.workspace = WorkspaceDetector.detect(workspace_root)
|
||||
|
||||
# Resolve target
|
||||
@@ -293,7 +331,9 @@ class DevToolBase:
|
||||
backend_map = {
|
||||
"go": "go",
|
||||
"ts": "pnpm" if ws_type == "pnpm" else "npm",
|
||||
"py": "uv" if Path(self.workspace["root"], "uv.lock").exists() else "pytest",
|
||||
"py": "uv"
|
||||
if Path(self.workspace["root"], "uv.lock").exists()
|
||||
else "pytest",
|
||||
"rs": "cargo",
|
||||
"cc": "cmake",
|
||||
"sol": "forge",
|
||||
@@ -302,7 +342,9 @@ class DevToolBase:
|
||||
|
||||
return backend_map.get(lang, "auto")
|
||||
|
||||
def _run_command(self, cmd: List[str], cwd: Optional[str] = None) -> subprocess.CompletedProcess:
|
||||
def _run_command(
|
||||
self, cmd: List[str], cwd: Optional[str] = None
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run command with proper environment"""
|
||||
env = {**os.environ, **self.env}
|
||||
|
||||
@@ -315,7 +357,9 @@ class DevToolBase:
|
||||
if self.dry_run:
|
||||
return subprocess.CompletedProcess(cmd, 0, f"DRY RUN: {' '.join(cmd)}", "")
|
||||
|
||||
return subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True)
|
||||
return subprocess.run(
|
||||
cmd, cwd=work_dir, env=env, capture_output=True, text=True
|
||||
)
|
||||
|
||||
|
||||
# Individual tool implementations will be in separate files
|
||||
|
||||
@@ -75,7 +75,11 @@ BACKEND_PARAM = {
|
||||
COMMON_PARAMS = {
|
||||
"root": {"type": "string", "description": "Workspace root override"},
|
||||
"env": {"type": "object", "description": "Additional environment variables"},
|
||||
"dry_run": {"type": "boolean", "default": False, "description": "Show planned operations without executing"},
|
||||
"dry_run": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Show planned operations without executing",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -92,13 +96,24 @@ async def list_tools() -> List[Tool]:
|
||||
"target": TARGET_PARAM,
|
||||
"op": {
|
||||
"type": "string",
|
||||
"enum": ["rename", "code_action", "organize_imports", "apply_workspace_edit"],
|
||||
"enum": [
|
||||
"rename",
|
||||
"code_action",
|
||||
"organize_imports",
|
||||
"apply_workspace_edit",
|
||||
],
|
||||
"description": "Edit operation to perform",
|
||||
},
|
||||
"file": {"type": "string", "description": "File path for rename/code_action operations"},
|
||||
"file": {
|
||||
"type": "string",
|
||||
"description": "File path for rename/code_action operations",
|
||||
},
|
||||
"pos": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
"description": "Position for rename/code_action (0-based)",
|
||||
},
|
||||
"range": {
|
||||
@@ -106,18 +121,35 @@ async def list_tools() -> List[Tool]:
|
||||
"properties": {
|
||||
"start": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
"end": {
|
||||
"type": "object",
|
||||
"properties": {"line": {"type": "integer"}, "character": {"type": "integer"}},
|
||||
"properties": {
|
||||
"line": {"type": "integer"},
|
||||
"character": {"type": "integer"},
|
||||
},
|
||||
},
|
||||
},
|
||||
"description": "Range for code actions",
|
||||
},
|
||||
"new_name": {"type": "string", "description": "New name for rename operation"},
|
||||
"only": {"type": "array", "items": {"type": "string"}, "description": "Code action kinds to apply"},
|
||||
"apply": {"type": "boolean", "default": True, "description": "Whether to apply edits to disk"},
|
||||
"new_name": {
|
||||
"type": "string",
|
||||
"description": "New name for rename operation",
|
||||
},
|
||||
"only": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Code action kinds to apply",
|
||||
},
|
||||
"apply": {
|
||||
"type": "boolean",
|
||||
"default": True,
|
||||
"description": "Whether to apply edits to disk",
|
||||
},
|
||||
"language": LANGUAGE_PARAM,
|
||||
"backend": BACKEND_PARAM,
|
||||
**COMMON_PARAMS,
|
||||
@@ -161,14 +193,32 @@ async def list_tools() -> List[Tool]:
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"run": {"type": "string", "description": "Test name pattern (Go -run)"},
|
||||
"count": {"type": "integer", "description": "Test count (Go -count)"},
|
||||
"race": {"type": "boolean", "description": "Enable race detection (Go -race)"},
|
||||
"filter": {"type": "string", "description": "Test filter (JS --filter, Python -k)"},
|
||||
"run": {
|
||||
"type": "string",
|
||||
"description": "Test name pattern (Go -run)",
|
||||
},
|
||||
"count": {
|
||||
"type": "integer",
|
||||
"description": "Test count (Go -count)",
|
||||
},
|
||||
"race": {
|
||||
"type": "boolean",
|
||||
"description": "Enable race detection (Go -race)",
|
||||
},
|
||||
"filter": {
|
||||
"type": "string",
|
||||
"description": "Test filter (JS --filter, Python -k)",
|
||||
},
|
||||
"k": {"type": "string", "description": "Pytest -k filter"},
|
||||
"m": {"type": "string", "description": "Pytest -m marker"},
|
||||
"p": {"type": "string", "description": "Rust package filter"},
|
||||
"features": {"type": "string", "description": "Rust features"},
|
||||
"p": {
|
||||
"type": "string",
|
||||
"description": "Rust package filter",
|
||||
},
|
||||
"features": {
|
||||
"type": "string",
|
||||
"description": "Rust features",
|
||||
},
|
||||
},
|
||||
"description": "Test options",
|
||||
},
|
||||
@@ -189,11 +239,26 @@ async def list_tools() -> List[Tool]:
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"race": {"type": "boolean", "description": "Enable race detection (Go)"},
|
||||
"release": {"type": "boolean", "description": "Release build (Rust)"},
|
||||
"tags": {"type": "string", "description": "Build tags (Go)"},
|
||||
"ldflags": {"type": "string", "description": "Linker flags (Go)"},
|
||||
"features": {"type": "string", "description": "Features (Rust)"},
|
||||
"race": {
|
||||
"type": "boolean",
|
||||
"description": "Enable race detection (Go)",
|
||||
},
|
||||
"release": {
|
||||
"type": "boolean",
|
||||
"description": "Release build (Rust)",
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "Build tags (Go)",
|
||||
},
|
||||
"ldflags": {
|
||||
"type": "string",
|
||||
"description": "Linker flags (Go)",
|
||||
},
|
||||
"features": {
|
||||
"type": "string",
|
||||
"description": "Features (Rust)",
|
||||
},
|
||||
},
|
||||
"description": "Build options",
|
||||
},
|
||||
@@ -214,7 +279,11 @@ async def list_tools() -> List[Tool]:
|
||||
"opts": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fix": {"type": "boolean", "default": False, "description": "Apply fixes where supported"}
|
||||
"fix": {
|
||||
"type": "boolean",
|
||||
"default": False,
|
||||
"description": "Apply fixes where supported",
|
||||
}
|
||||
},
|
||||
"description": "Lint options",
|
||||
},
|
||||
@@ -235,8 +304,14 @@ async def list_tools() -> List[Tool]:
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string", "description": "Rule identifier"},
|
||||
"glob": {"type": "string", "description": "File glob pattern"},
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Rule identifier",
|
||||
},
|
||||
"glob": {
|
||||
"type": "string",
|
||||
"description": "File glob pattern",
|
||||
},
|
||||
"pattern": {
|
||||
"type": "string",
|
||||
"description": "Regex pattern to match (for regex rules)",
|
||||
@@ -249,8 +324,14 @@ async def list_tools() -> List[Tool]:
|
||||
"type": "boolean",
|
||||
"description": "Forbid writes to matched files (for generated rules)",
|
||||
},
|
||||
"message": {"type": "string", "description": "Custom violation message"},
|
||||
"description": {"type": "string", "description": "Rule description"},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"description": "Custom violation message",
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
"description": "Rule description",
|
||||
},
|
||||
},
|
||||
"required": ["id", "glob"],
|
||||
},
|
||||
@@ -303,7 +384,9 @@ async def call_tool(name: str, arguments: Dict[str, Any]) -> List[TextContent]:
|
||||
for file in result["touched_files"][:10]: # Limit to first 10
|
||||
output_lines.append(f" • {file}")
|
||||
if len(result["touched_files"]) > 10:
|
||||
output_lines.append(f" ... and {len(result['touched_files']) - 10} more")
|
||||
output_lines.append(
|
||||
f" ... and {len(result['touched_files']) - 10} more"
|
||||
)
|
||||
|
||||
if result["stdout"]:
|
||||
output_lines.append("\n📤 Output:")
|
||||
@@ -351,7 +434,9 @@ async def _compose_rename_and_test(args: Dict[str, Any]) -> List[TextContent]:
|
||||
|
||||
# 1. Rename
|
||||
rename_result = await edit_tool_handler(
|
||||
target=args["target"], op="rename", **{k: v for k, v in args.items() if k not in ["target"]}
|
||||
target=args["target"],
|
||||
op="rename",
|
||||
**{k: v for k, v in args.items() if k not in ["target"]},
|
||||
)
|
||||
results.append(("rename", rename_result))
|
||||
|
||||
@@ -375,10 +460,14 @@ async def _compose_rename_and_test(args: Dict[str, Any]) -> List[TextContent]:
|
||||
output_lines = ["🔄 RENAME AND TEST WORKFLOW"]
|
||||
for step, result in results:
|
||||
status = "✅" if result["ok"] else "❌"
|
||||
output_lines.append(f"{status} {step.upper()}: {result.get('stdout', '')[:100]}")
|
||||
output_lines.append(
|
||||
f"{status} {step.upper()}: {result.get('stdout', '')[:100]}"
|
||||
)
|
||||
|
||||
overall_success = all(result["ok"] for _, result in results)
|
||||
output_lines.append(f"\n{'✅ Workflow completed successfully' if overall_success else '❌ Workflow failed'}")
|
||||
output_lines.append(
|
||||
f"\n{'✅ Workflow completed successfully' if overall_success else '❌ Workflow failed'}"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text="\n".join(output_lines))]
|
||||
|
||||
@@ -403,10 +492,14 @@ async def _compose_fix_and_verify(args: Dict[str, Any]) -> List[TextContent]:
|
||||
output_lines = ["🔧 FIX AND VERIFY WORKFLOW"]
|
||||
for step, result in results:
|
||||
status = "✅" if result["ok"] else "❌"
|
||||
output_lines.append(f"{status} {step.upper()}: {result.get('stdout', '')[:100]}")
|
||||
output_lines.append(
|
||||
f"{status} {step.upper()}: {result.get('stdout', '')[:100]}"
|
||||
)
|
||||
|
||||
overall_success = all(result["ok"] for _, result in results)
|
||||
output_lines.append(f"\n{'✅ All checks passed' if overall_success else '❌ Some checks failed'}")
|
||||
output_lines.append(
|
||||
f"\n{'✅ All checks passed' if overall_success else '❌ Some checks failed'}"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text="\n".join(output_lines))]
|
||||
|
||||
@@ -439,7 +532,9 @@ async def _compose_full_check(args: Dict[str, Any]) -> List[TextContent]:
|
||||
output_lines.append(f"{status} {step.upper()}: {summary}")
|
||||
|
||||
overall_success = all(result["ok"] for _, result in results)
|
||||
output_lines.append(f"\n{'🎉 All checks passed!' if overall_success else '⚠️ Some checks failed'}")
|
||||
output_lines.append(
|
||||
f"\n{'🎉 All checks passed!' if overall_success else '⚠️ Some checks failed'}"
|
||||
)
|
||||
|
||||
return [TextContent(type="text", text="\n".join(output_lines))]
|
||||
|
||||
|
||||
@@ -120,7 +120,9 @@ class EditTool(DevToolBase):
|
||||
return await self._typescript_organize_imports()
|
||||
elif self.language == "py":
|
||||
# Use ruff or isort
|
||||
result = self._run_command(["ruff", "check", "--select", "I", "--fix"] + self.resolved["files"])
|
||||
result = self._run_command(
|
||||
["ruff", "check", "--select", "I", "--fix"] + self.resolved["files"]
|
||||
)
|
||||
else:
|
||||
return create_dev_result(
|
||||
ok=False,
|
||||
@@ -128,7 +130,9 @@ class EditTool(DevToolBase):
|
||||
language_used=self.language,
|
||||
backend_used=self.backend,
|
||||
scope_resolved=self.target,
|
||||
errors=[f"Organize imports not supported for language: {self.language}"],
|
||||
errors=[
|
||||
f"Organize imports not supported for language: {self.language}"
|
||||
],
|
||||
)
|
||||
|
||||
return create_dev_result(
|
||||
|
||||
@@ -103,7 +103,11 @@ class FmtTool(DevToolBase):
|
||||
async def _format_typescript(self) -> DevResult:
|
||||
"""Format TypeScript/JavaScript code"""
|
||||
files = self.resolved["files"]
|
||||
ts_files = [f for f in files if any(f.endswith(ext) for ext in [".ts", ".tsx", ".js", ".jsx"])]
|
||||
ts_files = [
|
||||
f
|
||||
for f in files
|
||||
if any(f.endswith(ext) for ext in [".ts", ".tsx", ".js", ".jsx"])
|
||||
]
|
||||
|
||||
if not ts_files:
|
||||
return create_dev_result(
|
||||
@@ -193,7 +197,13 @@ class FmtTool(DevToolBase):
|
||||
async def _format_cpp(self) -> DevResult:
|
||||
"""Format C/C++ code using clang-format"""
|
||||
files = self.resolved["files"]
|
||||
cpp_files = [f for f in files if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"])]
|
||||
cpp_files = [
|
||||
f
|
||||
for f in files
|
||||
if any(
|
||||
f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx", ".h", ".hpp"]
|
||||
)
|
||||
]
|
||||
|
||||
if not cpp_files:
|
||||
return create_dev_result(
|
||||
@@ -364,7 +374,13 @@ async def fmt_tool_handler(
|
||||
"""MCP handler for fmt tool"""
|
||||
|
||||
tool = FmtTool(
|
||||
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
|
||||
target=target,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env,
|
||||
dry_run=dry_run,
|
||||
opts=opts or {},
|
||||
)
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
@@ -148,7 +148,9 @@ class GuardTool(DevToolBase):
|
||||
glob=glob_pattern,
|
||||
description=description,
|
||||
forbid_writes=rule_data["forbid_writes"],
|
||||
message=rule_data.get("message", "Modification of generated files forbidden"),
|
||||
message=rule_data.get(
|
||||
"message", "Modification of generated files forbidden"
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -226,7 +228,9 @@ class GuardTool(DevToolBase):
|
||||
|
||||
return matching_files
|
||||
|
||||
async def _check_regex_rule(self, rule: RegexRule, files: List[str]) -> List[Violation]:
|
||||
async def _check_regex_rule(
|
||||
self, rule: RegexRule, files: List[str]
|
||||
) -> List[Violation]:
|
||||
"""Check regex rule against files"""
|
||||
violations = []
|
||||
pattern = re.compile(rule.pattern)
|
||||
@@ -251,7 +255,9 @@ class GuardTool(DevToolBase):
|
||||
|
||||
return violations
|
||||
|
||||
async def _check_import_rule(self, rule: ImportRule, files: List[str]) -> List[Violation]:
|
||||
async def _check_import_rule(
|
||||
self, rule: ImportRule, files: List[str]
|
||||
) -> List[Violation]:
|
||||
"""Check import rule against files"""
|
||||
violations = []
|
||||
|
||||
@@ -260,7 +266,9 @@ class GuardTool(DevToolBase):
|
||||
with open(file_path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
for line_num, line in enumerate(f, 1):
|
||||
# Check for import statements containing forbidden prefix
|
||||
if self._line_has_forbidden_import(line, rule.forbid_import_prefix):
|
||||
if self._line_has_forbidden_import(
|
||||
line, rule.forbid_import_prefix
|
||||
):
|
||||
violations.append(
|
||||
Violation(
|
||||
file=file_path,
|
||||
@@ -275,7 +283,9 @@ class GuardTool(DevToolBase):
|
||||
|
||||
return violations
|
||||
|
||||
async def _check_generated_rule(self, rule: GeneratedRule, files: List[str]) -> List[Violation]:
|
||||
async def _check_generated_rule(
|
||||
self, rule: GeneratedRule, files: List[str]
|
||||
) -> List[Violation]:
|
||||
"""Check generated file rule"""
|
||||
violations = []
|
||||
|
||||
@@ -348,7 +358,9 @@ class GuardTool(DevToolBase):
|
||||
for rule_id, rule_violations in by_rule.items():
|
||||
output.append(f"Rule: {rule_id}")
|
||||
for violation in rule_violations:
|
||||
output.append(f" {violation.file}:{violation.line} - {violation.message}")
|
||||
output.append(
|
||||
f" {violation.file}:{violation.line} - {violation.message}"
|
||||
)
|
||||
output.append(f" {violation.text}")
|
||||
output.append("")
|
||||
|
||||
@@ -416,7 +428,13 @@ async def guard_tool_handler(
|
||||
all_rules.extend(rules)
|
||||
|
||||
tool = GuardTool(
|
||||
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, rules=all_rules
|
||||
target=target,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env,
|
||||
dry_run=dry_run,
|
||||
rules=all_rules,
|
||||
)
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
@@ -82,8 +82,12 @@ class LintTool(DevToolBase):
|
||||
|
||||
# Combine results
|
||||
overall_ok = all(result.returncode == 0 for _, result in results)
|
||||
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
|
||||
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
|
||||
combined_stdout = "\n".join(
|
||||
f"=== {name} ===\n{result.stdout}" for name, result in results
|
||||
)
|
||||
combined_stderr = "\n".join(
|
||||
f"=== {name} ===\n{result.stderr}" for name, result in results
|
||||
)
|
||||
|
||||
return create_dev_result(
|
||||
ok=overall_ok,
|
||||
@@ -111,8 +115,12 @@ class LintTool(DevToolBase):
|
||||
|
||||
# Combine results
|
||||
overall_ok = all(result.returncode == 0 for _, result in results)
|
||||
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
|
||||
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
|
||||
combined_stdout = "\n".join(
|
||||
f"=== {name} ===\n{result.stdout}" for name, result in results
|
||||
)
|
||||
combined_stderr = "\n".join(
|
||||
f"=== {name} ===\n{result.stderr}" for name, result in results
|
||||
)
|
||||
|
||||
backend = " + ".join(name for name, _ in results)
|
||||
|
||||
@@ -156,8 +164,12 @@ class LintTool(DevToolBase):
|
||||
|
||||
# Combine results
|
||||
overall_ok = all(result.returncode == 0 for _, result in results)
|
||||
combined_stdout = "\n".join(f"=== {name} ===\n{result.stdout}" for name, result in results)
|
||||
combined_stderr = "\n".join(f"=== {name} ===\n{result.stderr}" for name, result in results)
|
||||
combined_stdout = "\n".join(
|
||||
f"=== {name} ===\n{result.stdout}" for name, result in results
|
||||
)
|
||||
combined_stderr = "\n".join(
|
||||
f"=== {name} ===\n{result.stderr}" for name, result in results
|
||||
)
|
||||
|
||||
backend = " + ".join(name for name, _ in results)
|
||||
|
||||
@@ -215,7 +227,11 @@ class LintTool(DevToolBase):
|
||||
stdout="clang-tidy not available, skipping C++ linting",
|
||||
)
|
||||
|
||||
files = [f for f in self.resolved["files"] if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx"])]
|
||||
files = [
|
||||
f
|
||||
for f in self.resolved["files"]
|
||||
if any(f.endswith(ext) for ext in [".c", ".cpp", ".cc", ".cxx"])
|
||||
]
|
||||
|
||||
if not files:
|
||||
return create_dev_result(
|
||||
@@ -485,7 +501,13 @@ async def lint_tool_handler(
|
||||
"""MCP handler for lint tool"""
|
||||
|
||||
tool = LintTool(
|
||||
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
|
||||
target=target,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env,
|
||||
dry_run=dry_run,
|
||||
opts=opts or {},
|
||||
)
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
@@ -126,7 +126,9 @@ class TestTool(DevToolBase):
|
||||
language_used=self.language,
|
||||
backend_used=self.backend,
|
||||
scope_resolved=self.target,
|
||||
errors=["No test runner detected (jest, vitest, or package.json script)"],
|
||||
errors=[
|
||||
"No test runner detected (jest, vitest, or package.json script)"
|
||||
],
|
||||
)
|
||||
|
||||
async def _test_python(self) -> DevResult:
|
||||
@@ -345,7 +347,11 @@ class TestTool(DevToolBase):
|
||||
return str(path)
|
||||
|
||||
# Look for corresponding test files
|
||||
patterns = [f"**/*{path.stem}*.test.*", f"**/*{path.stem}*.spec.*", f"**/test*{path.stem}*"]
|
||||
patterns = [
|
||||
f"**/*{path.stem}*.test.*",
|
||||
f"**/*{path.stem}*.spec.*",
|
||||
f"**/test*{path.stem}*",
|
||||
]
|
||||
|
||||
return patterns[0] # Return first pattern as fallback
|
||||
|
||||
@@ -383,7 +389,13 @@ async def test_tool_handler(
|
||||
"""MCP handler for test tool"""
|
||||
|
||||
tool = TestTool(
|
||||
target=target, language=language, backend=backend, root=root, env=env, dry_run=dry_run, opts=opts or {}
|
||||
target=target,
|
||||
language=language,
|
||||
backend=backend,
|
||||
root=root,
|
||||
env=env,
|
||||
dry_run=dry_run,
|
||||
opts=opts or {},
|
||||
)
|
||||
|
||||
result = await tool.execute()
|
||||
|
||||
@@ -51,8 +51,14 @@ class MCPResourceDocument:
|
||||
total = stats.get("total", len(results))
|
||||
time_ms = stats.get("time_ms", {})
|
||||
if time_ms:
|
||||
total_time = sum(time_ms.values()) if isinstance(time_ms, dict) else time_ms
|
||||
lines.append(f"# Search: '{query}' ({total} results, {total_time}ms)")
|
||||
total_time = (
|
||||
sum(time_ms.values())
|
||||
if isinstance(time_ms, dict)
|
||||
else time_ms
|
||||
)
|
||||
lines.append(
|
||||
f"# Search: '{query}' ({total} results, {total_time}ms)"
|
||||
)
|
||||
else:
|
||||
lines.append(f"# Found {total} results")
|
||||
lines.append("")
|
||||
@@ -70,7 +76,9 @@ class MCPResourceDocument:
|
||||
lines.append(f"{i}. {loc}")
|
||||
if match:
|
||||
# Truncate long matches
|
||||
match_preview = match[:200] + "..." if len(match) > 200 else match
|
||||
match_preview = (
|
||||
match[:200] + "..." if len(match) > 200 else match
|
||||
)
|
||||
lines.append(f" {match_preview}")
|
||||
if rtype:
|
||||
lines.append(f" [{rtype}]")
|
||||
@@ -85,7 +93,9 @@ class MCPResourceDocument:
|
||||
total = pagination.get("total", 0)
|
||||
has_next = pagination.get("has_next", False)
|
||||
if has_next:
|
||||
lines.append(f"\n... showing page {page} of {(total // 50) + 1}")
|
||||
lines.append(
|
||||
f"\n... showing page {page} of {(total // 50) + 1}"
|
||||
)
|
||||
else:
|
||||
# Generic dict - format as key-value pairs
|
||||
for key, value in self.data.items():
|
||||
|
||||
@@ -51,11 +51,15 @@ class ToolResult:
|
||||
class TargetSpec(BaseModel):
|
||||
"""Target specification for tool operations"""
|
||||
|
||||
target: str = Field(..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed")
|
||||
target: str = Field(
|
||||
..., description="file:<path>, dir:<path>, pkg:<spec>, ws, or changed"
|
||||
)
|
||||
language: str = Field(default="auto", description="Language override")
|
||||
backend: str = Field(default="auto", description="Backend override")
|
||||
root: Optional[str] = Field(default=None, description="Workspace root override")
|
||||
env: Dict[str, str] = Field(default_factory=dict, description="Environment variables")
|
||||
env: Dict[str, str] = Field(
|
||||
default_factory=dict, description="Environment variables"
|
||||
)
|
||||
dry_run: bool = Field(default=False, description="Preview mode")
|
||||
|
||||
|
||||
@@ -71,7 +75,12 @@ class WorkspaceDetector:
|
||||
for current in [path] + list(path.parents):
|
||||
# Check for various workspace indicators
|
||||
if (current / "go.work").exists():
|
||||
return {"root": str(current), "type": "go_workspace", "config": current / "go.work", "language": "go"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "go_workspace",
|
||||
"config": current / "go.work",
|
||||
"language": "go",
|
||||
}
|
||||
elif (current / "package.json").exists():
|
||||
return {
|
||||
"root": str(current),
|
||||
@@ -94,7 +103,12 @@ class WorkspaceDetector:
|
||||
"language": "rs",
|
||||
}
|
||||
elif (current / ".git").exists():
|
||||
return {"root": str(current), "type": "git_repository", "config": current / ".git", "language": "auto"}
|
||||
return {
|
||||
"root": str(current),
|
||||
"type": "git_repository",
|
||||
"config": current / ".git",
|
||||
"language": "auto",
|
||||
}
|
||||
|
||||
# Default to current directory
|
||||
return {
|
||||
@@ -133,7 +147,9 @@ class SessionManager:
|
||||
def get_recent_sessions(self, limit: int = 10) -> List[Dict]:
|
||||
"""Get recent session activity"""
|
||||
sessions = []
|
||||
for session_file in sorted(self.sessions_dir.glob("*.jsonl"), reverse=True)[:limit]:
|
||||
for session_file in sorted(self.sessions_dir.glob("*.jsonl"), reverse=True)[
|
||||
:limit
|
||||
]:
|
||||
with open(session_file) as f:
|
||||
session_data = [json.loads(line) for line in f]
|
||||
if session_data:
|
||||
@@ -142,7 +158,9 @@ class SessionManager:
|
||||
"session_id": session_file.stem,
|
||||
"start_time": session_data[0]["timestamp"],
|
||||
"tool_count": len(session_data),
|
||||
"tools_used": list(set(entry["tool"] for entry in session_data)),
|
||||
"tools_used": list(
|
||||
set(entry["tool"] for entry in session_data)
|
||||
),
|
||||
}
|
||||
)
|
||||
return sessions
|
||||
@@ -219,7 +237,6 @@ class CodebaseIndexer:
|
||||
(file_path, content_hash, language, file_size, modified_time),
|
||||
)
|
||||
|
||||
|
||||
# Symbol extraction handled by LSP integration in the tools layer
|
||||
# See hanzo_mcp/tools/common/lsp.py for language-specific parsing
|
||||
|
||||
@@ -240,7 +257,13 @@ class CodebaseIndexer:
|
||||
|
||||
cursor = conn.execute(sql, params)
|
||||
return [
|
||||
{"path": row[0], "name": row[1], "kind": row[2], "line": row[3], "definition": row[4]}
|
||||
{
|
||||
"path": row[0],
|
||||
"name": row[1],
|
||||
"kind": row[2],
|
||||
"line": row[3],
|
||||
"definition": row[4],
|
||||
}
|
||||
for row in cursor.fetchall()
|
||||
]
|
||||
|
||||
@@ -266,7 +289,9 @@ class LSPBridge:
|
||||
# and handle communication via JSON-RPC
|
||||
pass
|
||||
|
||||
async def rename_symbol(self, file_path: str, line: int, character: int, new_name: str):
|
||||
async def rename_symbol(
|
||||
self, file_path: str, line: int, character: int, new_name: str
|
||||
):
|
||||
"""Perform LSP rename operation"""
|
||||
# Implementation would send LSP rename request
|
||||
pass
|
||||
@@ -302,7 +327,11 @@ class UnifiedBackend:
|
||||
files = []
|
||||
for ext in [".py", ".go", ".ts", ".js", ".rs", ".c", ".cpp", ".sol"]:
|
||||
files.extend(Path(path).rglob(f"*{ext}"))
|
||||
return {"type": "directory", "paths": [str(f) for f in files], "workspace": workspace}
|
||||
return {
|
||||
"type": "directory",
|
||||
"paths": [str(f) for f in files],
|
||||
"workspace": workspace,
|
||||
}
|
||||
|
||||
elif target.startswith("pkg:"):
|
||||
pkg_spec = target[4:]
|
||||
@@ -321,17 +350,30 @@ class UnifiedBackend:
|
||||
files = []
|
||||
for ext in [".py", ".go", ".ts", ".js", ".rs", ".c", ".cpp", ".sol"]:
|
||||
files.extend(root.rglob(f"*{ext}"))
|
||||
return {"type": "workspace", "paths": [str(f) for f in files], "workspace": workspace}
|
||||
return {
|
||||
"type": "workspace",
|
||||
"paths": [str(f) for f in files],
|
||||
"workspace": workspace,
|
||||
}
|
||||
|
||||
elif target == "changed":
|
||||
# Git diff against HEAD
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "diff", "--name-only", "HEAD"], capture_output=True, text=True, check=True
|
||||
["git", "diff", "--name-only", "HEAD"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
changed_files = (
|
||||
result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
)
|
||||
changed_files = result.stdout.strip().split("\n") if result.stdout.strip() else []
|
||||
workspace = WorkspaceDetector.detect(".")
|
||||
return {"type": "changed", "paths": changed_files, "workspace": workspace}
|
||||
return {
|
||||
"type": "changed",
|
||||
"paths": changed_files,
|
||||
"workspace": workspace,
|
||||
}
|
||||
except subprocess.CalledProcessError:
|
||||
return {
|
||||
"type": "changed",
|
||||
@@ -381,7 +423,9 @@ class UnifiedBackend:
|
||||
new_name = kwargs.get("new_name")
|
||||
|
||||
if target.dry_run:
|
||||
result.stdout = f"Would rename symbol at {file_path}:{pos} to {new_name}"
|
||||
result.stdout = (
|
||||
f"Would rename symbol at {file_path}:{pos} to {new_name}"
|
||||
)
|
||||
else:
|
||||
# Actual LSP rename
|
||||
await self.lsp_bridge.rename_symbol(
|
||||
@@ -396,7 +440,9 @@ class UnifiedBackend:
|
||||
kwargs.get("only", [])
|
||||
|
||||
actions = await self.lsp_bridge.code_actions(
|
||||
file_path, range_spec.get("start", {}).get("line", 0), range_spec.get("end", {}).get("line", 0)
|
||||
file_path,
|
||||
range_spec.get("start", {}).get("line", 0),
|
||||
range_spec.get("end", {}).get("line", 0),
|
||||
)
|
||||
result.stdout = f"Available actions: {actions}"
|
||||
|
||||
@@ -475,11 +521,15 @@ class UnifiedBackend:
|
||||
cmd.extend(["-local", local_prefix])
|
||||
cmd.append(file_path)
|
||||
|
||||
proc_result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
proc_result = subprocess.run(
|
||||
cmd, capture_output=True, text=True
|
||||
)
|
||||
if proc_result.returncode == 0:
|
||||
result.touched_files.append(file_path)
|
||||
else:
|
||||
result.errors.append(f"Failed to format {file_path}: {proc_result.stderr}")
|
||||
result.errors.append(
|
||||
f"Failed to format {file_path}: {proc_result.stderr}"
|
||||
)
|
||||
|
||||
# Add other language formatting logic
|
||||
|
||||
@@ -518,7 +568,9 @@ class UnifiedBackend:
|
||||
# Implementation for linting
|
||||
pass
|
||||
|
||||
async def guard(self, target: TargetSpec, rules: List[Dict], **kwargs) -> ToolResult:
|
||||
async def guard(
|
||||
self, target: TargetSpec, rules: List[Dict], **kwargs
|
||||
) -> ToolResult:
|
||||
"""Guard tool: repo invariants"""
|
||||
# Implementation for guard rules
|
||||
pass
|
||||
|
||||
@@ -241,8 +241,14 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
"source_size_bytes": total_size,
|
||||
"database_size_bytes": db_size,
|
||||
"storage_ratio": storage_ratio,
|
||||
"files_per_second": (len(python_files) / indexing_time if indexing_time > 0 else 0),
|
||||
"mb_per_second": ((total_size / 1024 / 1024) / indexing_time if indexing_time > 0 else 0),
|
||||
"files_per_second": (
|
||||
len(python_files) / indexing_time if indexing_time > 0 else 0
|
||||
),
|
||||
"mb_per_second": (
|
||||
(total_size / 1024 / 1024) / indexing_time
|
||||
if indexing_time > 0
|
||||
else 0
|
||||
),
|
||||
}
|
||||
|
||||
print(f"Indexing completed in {indexing_time:.2f} seconds")
|
||||
@@ -264,7 +270,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
"indexing_time_seconds": time.time() - start_time,
|
||||
}
|
||||
|
||||
async def benchmark_search_performance(self, project_path: Path, queries: List[str]) -> Dict[str, Any]:
|
||||
async def benchmark_search_performance(
|
||||
self, project_path: Path, queries: List[str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Benchmark search performance across different query types."""
|
||||
print("\\n=== Benchmarking Search Performance ===")
|
||||
|
||||
@@ -294,7 +302,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
|
||||
try:
|
||||
# Mock path validation methods
|
||||
unified_tool.validate_path = lambda x: type("obj", (object,), {"is_error": False})()
|
||||
unified_tool.validate_path = lambda x: type(
|
||||
"obj", (object,), {"is_error": False}
|
||||
)()
|
||||
unified_tool.check_path_allowed = lambda x, y: (True, None)
|
||||
unified_tool.check_path_exists = lambda x, y: (True, None)
|
||||
|
||||
@@ -325,7 +335,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
valid_times = [t for t in times if t != float("inf")]
|
||||
|
||||
search_results[query] = {
|
||||
"avg_time_seconds": (statistics.mean(valid_times) if valid_times else float("inf")),
|
||||
"avg_time_seconds": (
|
||||
statistics.mean(valid_times) if valid_times else float("inf")
|
||||
),
|
||||
"min_time_seconds": min(valid_times) if valid_times else float("inf"),
|
||||
"max_time_seconds": max(valid_times) if valid_times else float("inf"),
|
||||
"avg_results": statistics.mean(result_counts) if result_counts else 0,
|
||||
@@ -374,9 +386,13 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
"total_files": len(python_files),
|
||||
"successful_analyses": len(valid_times),
|
||||
"total_time_seconds": total_time,
|
||||
"avg_time_per_file": (statistics.mean(valid_times) if valid_times else float("inf")),
|
||||
"avg_time_per_file": (
|
||||
statistics.mean(valid_times) if valid_times else float("inf")
|
||||
),
|
||||
"total_symbols": sum(symbol_counts),
|
||||
"avg_symbols_per_file": (statistics.mean(symbol_counts) if symbol_counts else 0),
|
||||
"avg_symbols_per_file": (
|
||||
statistics.mean(symbol_counts) if symbol_counts else 0
|
||||
),
|
||||
"files_per_second": len(valid_times) / total_time if total_time > 0 else 0,
|
||||
}
|
||||
|
||||
@@ -421,10 +437,14 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
size_results["indexing"] = await self.benchmark_indexing(test_project)
|
||||
|
||||
# 2. Benchmark AST analysis
|
||||
size_results["ast_analysis"] = await self.benchmark_ast_analysis(test_project)
|
||||
size_results["ast_analysis"] = await self.benchmark_ast_analysis(
|
||||
test_project
|
||||
)
|
||||
|
||||
# 3. Benchmark search performance
|
||||
size_results["search_performance"] = await self.benchmark_search_performance(test_project, test_queries)
|
||||
size_results[
|
||||
"search_performance"
|
||||
] = await self.benchmark_search_performance(test_project, test_queries)
|
||||
|
||||
benchmark_results["results"][size] = size_results
|
||||
|
||||
@@ -446,16 +466,24 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
indexing = size_results["indexing"]
|
||||
report.append("### Indexing Performance")
|
||||
if indexing["success"]:
|
||||
report.append(f"- **Indexing Time**: {indexing['indexing_time_seconds']:.2f} seconds")
|
||||
report.append(
|
||||
f"- **Indexing Time**: {indexing['indexing_time_seconds']:.2f} seconds"
|
||||
)
|
||||
report.append(f"- **Files Indexed**: {indexing['files_count']}")
|
||||
report.append(f"- **Source Size**: {indexing['source_size_bytes'] / 1024 / 1024:.2f} MB")
|
||||
report.append(f"- **Database Size**: {indexing['database_size_bytes'] / 1024 / 1024:.2f} MB")
|
||||
report.append(
|
||||
f"- **Source Size**: {indexing['source_size_bytes'] / 1024 / 1024:.2f} MB"
|
||||
)
|
||||
report.append(
|
||||
f"- **Database Size**: {indexing['database_size_bytes'] / 1024 / 1024:.2f} MB"
|
||||
)
|
||||
report.append(f"- **Storage Ratio**: {indexing['storage_ratio']:.2f}x")
|
||||
report.append(
|
||||
f"- **Performance**: {indexing['files_per_second']:.1f} files/sec, {indexing['mb_per_second']:.1f} MB/sec"
|
||||
)
|
||||
else:
|
||||
report.append(f"- **Status**: Failed - {indexing.get('error', 'Unknown error')}")
|
||||
report.append(
|
||||
f"- **Status**: Failed - {indexing.get('error', 'Unknown error')}"
|
||||
)
|
||||
report.append("")
|
||||
|
||||
# AST Analysis results
|
||||
@@ -464,9 +492,13 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
report.append(f"- **Total Files**: {ast['total_files']}")
|
||||
report.append(f"- **Successful Analyses**: {ast['successful_analyses']}")
|
||||
report.append(f"- **Total Time**: {ast['total_time_seconds']:.2f} seconds")
|
||||
report.append(f"- **Avg Time per File**: {ast['avg_time_per_file']:.3f} seconds")
|
||||
report.append(
|
||||
f"- **Avg Time per File**: {ast['avg_time_per_file']:.3f} seconds"
|
||||
)
|
||||
report.append(f"- **Total Symbols**: {ast['total_symbols']}")
|
||||
report.append(f"- **Avg Symbols per File**: {ast['avg_symbols_per_file']:.1f}")
|
||||
report.append(
|
||||
f"- **Avg Symbols per File**: {ast['avg_symbols_per_file']:.1f}"
|
||||
)
|
||||
report.append(f"- **Files per Second**: {ast['files_per_second']:.1f}")
|
||||
report.append("")
|
||||
|
||||
@@ -486,7 +518,9 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
else:
|
||||
time_str = f"{avg_time:.3f}"
|
||||
|
||||
report.append(f"| `{query}` | {time_str} | {avg_results:.1f} | {success_rate:.1f}% |")
|
||||
report.append(
|
||||
f"| `{query}` | {time_str} | {avg_results:.1f} | {success_rate:.1f}% |"
|
||||
)
|
||||
|
||||
report.append("")
|
||||
|
||||
@@ -500,9 +534,15 @@ def apply_transformations(record: Any, params: Dict[str, Any]) -> Any:
|
||||
|
||||
if indexing["success"]:
|
||||
report.append(f"**{size.upper()} Project:**")
|
||||
report.append(f"- Indexed {indexing['files_count']} files in {indexing['indexing_time_seconds']:.2f}s")
|
||||
report.append(f"- Database compression: {indexing['storage_ratio']:.2f}x")
|
||||
report.append(f"- AST analysis: {ast['files_per_second']:.1f} files/sec")
|
||||
report.append(
|
||||
f"- Indexed {indexing['files_count']} files in {indexing['indexing_time_seconds']:.2f}s"
|
||||
)
|
||||
report.append(
|
||||
f"- Database compression: {indexing['storage_ratio']:.2f}x"
|
||||
)
|
||||
report.append(
|
||||
f"- AST analysis: {ast['files_per_second']:.1f} files/sec"
|
||||
)
|
||||
report.append("")
|
||||
|
||||
return "\\n".join(report)
|
||||
|
||||
@@ -60,7 +60,9 @@ async def simple_benchmark():
|
||||
analysis_times.append(analysis_time)
|
||||
symbol_counts.append(len(file_ast.symbols) if file_ast else 0)
|
||||
|
||||
print(f" {file_path.name}: {analysis_time:.3f}s, {len(file_ast.symbols) if file_ast else 0} symbols")
|
||||
print(
|
||||
f" {file_path.name}: {analysis_time:.3f}s, {len(file_ast.symbols) if file_ast else 0} symbols"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f" {file_path.name}: FAILED - {e}")
|
||||
@@ -68,7 +70,9 @@ async def simple_benchmark():
|
||||
if analysis_times:
|
||||
avg_time = sum(analysis_times) / len(analysis_times)
|
||||
avg_symbols = sum(symbol_counts) / len(symbol_counts)
|
||||
print(f"\\nAverage: {avg_time:.3f}s per file, {avg_symbols:.1f} symbols per file")
|
||||
print(
|
||||
f"\\nAverage: {avg_time:.3f}s per file, {avg_symbols:.1f} symbols per file"
|
||||
)
|
||||
|
||||
print()
|
||||
|
||||
@@ -83,8 +87,12 @@ async def simple_benchmark():
|
||||
|
||||
# Mock the validation methods
|
||||
unified_tool.validate_path = lambda x: type("obj", (object,), {"is_error": False})()
|
||||
unified_tool.check_path_allowed = lambda x, y: asyncio.coroutine(lambda: (True, None))()
|
||||
unified_tool.check_path_exists = lambda x, y: asyncio.coroutine(lambda: (True, None))()
|
||||
unified_tool.check_path_allowed = lambda x, y: asyncio.coroutine(
|
||||
lambda: (True, None)
|
||||
)()
|
||||
unified_tool.check_path_exists = lambda x, y: asyncio.coroutine(
|
||||
lambda: (True, None)
|
||||
)()
|
||||
|
||||
# Test queries
|
||||
queries = [
|
||||
@@ -101,9 +109,15 @@ async def simple_benchmark():
|
||||
# Test grep search
|
||||
start_time = time.time()
|
||||
try:
|
||||
grep_result = await grep_tool.call(MockContext(), pattern=query, path=str(project_root), include="*.py")
|
||||
grep_result = await grep_tool.call(
|
||||
MockContext(), pattern=query, path=str(project_root), include="*.py"
|
||||
)
|
||||
grep_time = time.time() - start_time
|
||||
grep_matches = grep_result.count("\\n") if grep_result and "Found" in grep_result else 0
|
||||
grep_matches = (
|
||||
grep_result.count("\\n")
|
||||
if grep_result and "Found" in grep_result
|
||||
else 0
|
||||
)
|
||||
|
||||
print(f" Grep: {grep_time:.3f}s, ~{grep_matches} matches")
|
||||
except Exception as e:
|
||||
@@ -120,7 +134,11 @@ async def simple_benchmark():
|
||||
line_number=True,
|
||||
)
|
||||
ast_time = time.time() - start_time
|
||||
ast_matches = ast_result.count("\\n") if ast_result and not ast_result.startswith("No matches") else 0
|
||||
ast_matches = (
|
||||
ast_result.count("\\n")
|
||||
if ast_result and not ast_result.startswith("No matches")
|
||||
else 0
|
||||
)
|
||||
|
||||
print(f" AST: {ast_time:.3f}s, ~{ast_matches} matches")
|
||||
except Exception as e:
|
||||
@@ -137,7 +155,9 @@ async def simple_benchmark():
|
||||
max_results=20,
|
||||
)
|
||||
unified_time = time.time() - start_time
|
||||
unified_matches = unified_result.count("Result ") if "Result " in unified_result else 0
|
||||
unified_matches = (
|
||||
unified_result.count("Result ") if "Result " in unified_result else 0
|
||||
)
|
||||
|
||||
print(f" Unified: {unified_time:.3f}s, {unified_matches} results")
|
||||
except Exception as e:
|
||||
|
||||
@@ -36,8 +36,12 @@ os.environ["PYTEST_CURRENT_TEST"] = "1" # Mark as pytest run
|
||||
def pytest_configure(config):
|
||||
"""Configure pytest."""
|
||||
# Register custom markers
|
||||
config.addinivalue_line("markers", "requires_hanzo_agents: mark test as requiring hanzo-agents SDK")
|
||||
config.addinivalue_line("markers", "requires_memory_tools: mark test as requiring hanzo-memory package")
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_hanzo_agents: mark test as requiring hanzo-agents SDK"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_memory_tools: mark test as requiring hanzo-memory package"
|
||||
)
|
||||
config.addinivalue_line("markers", "slow: mark test as slow running")
|
||||
config.addinivalue_line("markers", "integration: mark test as integration test")
|
||||
|
||||
|
||||
@@ -42,7 +42,9 @@ async def test_filters():
|
||||
print("No results")
|
||||
|
||||
print("\nTest 2: Time filter (modified_after='12 hours ago')")
|
||||
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_after="12 hours ago")
|
||||
result = await find_tool.run(
|
||||
pattern="*.txt", path=tmpdir, modified_after="12 hours ago"
|
||||
)
|
||||
|
||||
if result.data:
|
||||
results = result.data.get("results", [])
|
||||
|
||||
@@ -23,7 +23,9 @@ def test_parse_size():
|
||||
|
||||
for size_str, expected in test_cases:
|
||||
result = tool._parse_size(size_str)
|
||||
print(f"'{size_str}' -> {result} bytes (expected: {expected}, match: {result == expected})")
|
||||
print(
|
||||
f"'{size_str}' -> {result} bytes (expected: {expected}, match: {result == expected})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -42,7 +42,9 @@ def check_api_connection():
|
||||
|
||||
elif "insufficient_quota" in str(e).lower():
|
||||
print("\nPossible cause: Your OpenAI account may be out of credits.")
|
||||
print("Solution: Check your usage and billing information in the OpenAI dashboard.")
|
||||
print(
|
||||
"Solution: Check your usage and billing information in the OpenAI dashboard."
|
||||
)
|
||||
|
||||
elif "connection" in str(e).lower():
|
||||
print("\nPossible cause: Network connectivity issue.")
|
||||
|
||||
@@ -59,9 +59,17 @@ class TestAgentTool:
|
||||
"""Test agent tool initialization."""
|
||||
assert agent_tool.name == "agent"
|
||||
assert "agent" in agent_tool.description.lower()
|
||||
assert hasattr(agent_tool, "model_override") and agent_tool.model_override is None
|
||||
assert hasattr(agent_tool, "api_key_override") and agent_tool.api_key_override is None
|
||||
assert hasattr(agent_tool, "max_tokens_override") and agent_tool.max_tokens_override is None
|
||||
assert (
|
||||
hasattr(agent_tool, "model_override") and agent_tool.model_override is None
|
||||
)
|
||||
assert (
|
||||
hasattr(agent_tool, "api_key_override")
|
||||
and agent_tool.api_key_override is None
|
||||
)
|
||||
assert (
|
||||
hasattr(agent_tool, "max_tokens_override")
|
||||
and agent_tool.max_tokens_override is None
|
||||
)
|
||||
assert hasattr(agent_tool, "max_iterations") and agent_tool.max_iterations == 10
|
||||
assert hasattr(agent_tool, "max_tool_uses") and agent_tool.max_tool_uses == 30
|
||||
|
||||
@@ -80,8 +88,14 @@ class TestAgentTool:
|
||||
hasattr(agent_tool_with_params, "max_tokens_override")
|
||||
and agent_tool_with_params.max_tokens_override == 2000
|
||||
)
|
||||
assert hasattr(agent_tool_with_params, "max_iterations") and agent_tool_with_params.max_iterations == 40
|
||||
assert hasattr(agent_tool_with_params, "max_tool_uses") and agent_tool_with_params.max_tool_uses == 150
|
||||
assert (
|
||||
hasattr(agent_tool_with_params, "max_iterations")
|
||||
and agent_tool_with_params.max_iterations == 40
|
||||
)
|
||||
assert (
|
||||
hasattr(agent_tool_with_params, "max_tool_uses")
|
||||
and agent_tool_with_params.max_tool_uses == 150
|
||||
)
|
||||
|
||||
# Parameters are not exposed directly in the new interface
|
||||
# def test_parameters(self, tool_helper, agent_tool):
|
||||
@@ -161,7 +175,9 @@ class TestAgentTool:
|
||||
tool_ctx.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_with_valid_prompt_string(self, tool_helper, agent_tool, mcp_context, mock_tools):
|
||||
async def test_call_with_valid_prompt_string(
|
||||
self, tool_helper, agent_tool, mcp_context, mock_tools
|
||||
):
|
||||
"""Test agent tool call with valid prompt as string - without hanzo-agents SDK."""
|
||||
# Mock the tool context
|
||||
tool_ctx = MagicMock()
|
||||
@@ -180,14 +196,18 @@ class TestAgentTool:
|
||||
return_value=tool_ctx,
|
||||
):
|
||||
# Update the test to use a list instead of a string
|
||||
result = await agent_tool.call(ctx=mcp_context, prompts=["Test prompt /home/test/path"])
|
||||
result = await agent_tool.call(
|
||||
ctx=mcp_context, prompts=["Test prompt /home/test/path"]
|
||||
)
|
||||
|
||||
tool_helper.assert_in_result("Error", result)
|
||||
tool_helper.assert_in_result("hanzo-agents SDK is required", result)
|
||||
tool_ctx.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_with_multiple_prompts(self, tool_helper, agent_tool, mcp_context, mock_tools):
|
||||
async def test_call_with_multiple_prompts(
|
||||
self, tool_helper, agent_tool, mcp_context, mock_tools
|
||||
):
|
||||
"""Test agent tool call with multiple prompts - without hanzo-agents SDK."""
|
||||
# Mock the tool context
|
||||
tool_ctx = MagicMock()
|
||||
@@ -219,7 +239,9 @@ class TestAgentTool:
|
||||
tool_ctx.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_call_with_empty_prompt_list(self, tool_helper, agent_tool, mcp_context):
|
||||
async def test_call_with_empty_prompt_list(
|
||||
self, tool_helper, agent_tool, mcp_context
|
||||
):
|
||||
"""Test agent tool call with an empty prompt list."""
|
||||
# Mock the tool context
|
||||
tool_ctx = MagicMock()
|
||||
@@ -260,7 +282,9 @@ class TestAgentTool:
|
||||
tool_ctx.error.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_class_default_model(self, tool_helper, agent_tool, mcp_context):
|
||||
async def test_get_agent_class_default_model(
|
||||
self, tool_helper, agent_tool, mcp_context
|
||||
):
|
||||
"""Test _get_agent_class returns appropriate agent class for default model."""
|
||||
agent_class = agent_tool._get_agent_class(None, mcp_context)
|
||||
|
||||
@@ -269,7 +293,9 @@ class TestAgentTool:
|
||||
assert "DynamicMCPAgent" in agent_class.__name__
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_agent_class_with_custom_model(self, tool_helper, agent_tool, mcp_context):
|
||||
async def test_get_agent_class_with_custom_model(
|
||||
self, tool_helper, agent_tool, mcp_context
|
||||
):
|
||||
"""Test _get_agent_class with a custom model string."""
|
||||
agent_class = agent_tool._get_agent_class("model://openai/gpt-4", mcp_context)
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Test LiteLLM with different providers."""
|
||||
|
||||
|
||||
import litellm
|
||||
import pytest
|
||||
from hanzo_tools.agent.tool_adapter import convert_tools_to_openai_functions
|
||||
@@ -64,7 +63,9 @@ def test_convert_echo_tool_to_openai_functions(echo_tool):
|
||||
assert len(openai_functions) == 1
|
||||
assert openai_functions[0]["type"] == "function"
|
||||
assert openai_functions[0]["function"]["name"] == "echo"
|
||||
assert openai_functions[0]["function"]["description"] == "Echo back the input message."
|
||||
assert (
|
||||
openai_functions[0]["function"]["description"] == "Echo back the input message."
|
||||
)
|
||||
assert "parameters" in openai_functions[0]["function"]
|
||||
|
||||
|
||||
@@ -84,7 +85,9 @@ def test_litellm_openai_provider_mocked():
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
with patch.object(litellm, "completion", return_value=mock_response) as mock_completion:
|
||||
with patch.object(
|
||||
litellm, "completion", return_value=mock_response
|
||||
) as mock_completion:
|
||||
response = litellm.completion(
|
||||
model="openai/gpt-3.5-turbo",
|
||||
messages=messages,
|
||||
@@ -113,7 +116,9 @@ def test_litellm_anthropic_provider_mocked():
|
||||
mock_response = MagicMock()
|
||||
mock_response.choices = [mock_choice]
|
||||
|
||||
with patch.object(litellm, "completion", return_value=mock_response) as mock_completion:
|
||||
with patch.object(
|
||||
litellm, "completion", return_value=mock_response
|
||||
) as mock_completion:
|
||||
response = litellm.completion(
|
||||
model="anthropic/claude-3-haiku-20240307",
|
||||
messages=messages,
|
||||
|
||||
@@ -98,8 +98,13 @@ class TestPrompt:
|
||||
# Test with model override - explicitly with TEST_MODE to avoid provider prefix
|
||||
os.environ["TEST_MODE"] = "1"
|
||||
assert get_default_model("openai/gpt-4o") == "openai/gpt-4o"
|
||||
assert get_default_model("gpt-4o-mini") == "gpt-4o-mini" # In test mode, no prefix added
|
||||
assert get_default_model("anthropic/claude-3-sonnet") == "anthropic/claude-3-sonnet"
|
||||
assert (
|
||||
get_default_model("gpt-4o-mini") == "gpt-4o-mini"
|
||||
) # In test mode, no prefix added
|
||||
assert (
|
||||
get_default_model("anthropic/claude-3-sonnet")
|
||||
== "anthropic/claude-3-sonnet"
|
||||
)
|
||||
|
||||
# Test with provider prefixing in non-test mode
|
||||
del os.environ["TEST_MODE"]
|
||||
|
||||
@@ -54,10 +54,14 @@ class TestToolAdapter:
|
||||
tool.required = ["thought"]
|
||||
return tool
|
||||
|
||||
def test_convert_tools_to_openai_functions(self, tool_helper, mock_tool, mock_simple_tool):
|
||||
def test_convert_tools_to_openai_functions(
|
||||
self, tool_helper, mock_tool, mock_simple_tool
|
||||
):
|
||||
"""Test convert_tools_to_openai_functions."""
|
||||
# Convert tools
|
||||
openai_functions = convert_tools_to_openai_functions([mock_tool, mock_simple_tool])
|
||||
openai_functions = convert_tools_to_openai_functions(
|
||||
[mock_tool, mock_simple_tool]
|
||||
)
|
||||
|
||||
# Verify result
|
||||
assert len(openai_functions) == 2
|
||||
@@ -65,7 +69,10 @@ class TestToolAdapter:
|
||||
# Check first tool
|
||||
assert openai_functions[0]["type"] == "function"
|
||||
assert openai_functions[0]["function"]["name"] == "read_files"
|
||||
assert openai_functions[0]["function"]["description"] == "Read files from the file system"
|
||||
assert (
|
||||
openai_functions[0]["function"]["description"]
|
||||
== "Read files from the file system"
|
||||
)
|
||||
assert "parameters" in openai_functions[0]["function"]
|
||||
|
||||
# Check second tool
|
||||
|
||||
@@ -40,7 +40,11 @@ class TestAgentTools:
|
||||
tool = IChingTool()
|
||||
assert tool.name == "iching"
|
||||
desc_lower = tool.description.lower()
|
||||
assert "i ching" in desc_lower or "wisdom" in desc_lower or "hexagram" in desc_lower
|
||||
assert (
|
||||
"i ching" in desc_lower
|
||||
or "wisdom" in desc_lower
|
||||
or "hexagram" in desc_lower
|
||||
)
|
||||
|
||||
def test_review_tool_creation(self):
|
||||
"""Test ReviewTool can be created."""
|
||||
|
||||
@@ -137,7 +137,9 @@ class TestBatchToolEdgeCases:
|
||||
mock_tool_ctx.info = AsyncMock()
|
||||
|
||||
# Make tools return large output
|
||||
batch_tool.tools["tool1"].call = AsyncMock(return_value="X" * 100000) # 100KB output
|
||||
batch_tool.tools["tool1"].call = AsyncMock(
|
||||
return_value="X" * 100000
|
||||
) # 100KB output
|
||||
|
||||
# Create many invocations
|
||||
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(50)]
|
||||
@@ -146,7 +148,9 @@ class TestBatchToolEdgeCases:
|
||||
"hanzo_mcp.tools.common.context.create_tool_context",
|
||||
return_value=mock_tool_ctx,
|
||||
):
|
||||
result = await batch_tool.call(ctx=mock_ctx, description="Large batch", invocations=invocations)
|
||||
result = await batch_tool.call(
|
||||
ctx=mock_ctx, description="Large batch", invocations=invocations
|
||||
)
|
||||
|
||||
# Should handle pagination
|
||||
tool_helper.assert_in_result("results", result)
|
||||
@@ -172,7 +176,9 @@ class TestBatchToolEdgeCases:
|
||||
invocations = [{"tool_name": "tool1", "input": {"id": i}} for i in range(20)]
|
||||
|
||||
start_time = asyncio.get_event_loop().time()
|
||||
await batch_tool.call(ctx=mock_ctx, description="Concurrent test", invocations=invocations)
|
||||
await batch_tool.call(
|
||||
ctx=mock_ctx, description="Concurrent test", invocations=invocations
|
||||
)
|
||||
end_time = asyncio.get_event_loop().time()
|
||||
|
||||
# Check that execution was concurrent but limited
|
||||
@@ -227,7 +233,9 @@ class TestBatchToolEdgeCases:
|
||||
{"tool_name": "Test_Tool", "input": {}},
|
||||
]
|
||||
|
||||
result = await batch_tool.call(ctx=mock_ctx, description="Name normalization", invocations=invocations)
|
||||
result = await batch_tool.call(
|
||||
ctx=mock_ctx, description="Name normalization", invocations=invocations
|
||||
)
|
||||
|
||||
# Should handle all variations - but batch tool doesn't normalize names
|
||||
assert isinstance(result, str)
|
||||
|
||||
+109
-60
@@ -16,9 +16,10 @@ class TestCLI:
|
||||
|
||||
def test_main_server_run(self) -> None:
|
||||
"""Test the main function running the server."""
|
||||
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
|
||||
"hanzo_mcp.server.HanzoMCPServer"
|
||||
) as mock_server_class:
|
||||
with (
|
||||
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
|
||||
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
|
||||
):
|
||||
# Mock parsed arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.name = "test-server"
|
||||
@@ -83,9 +84,10 @@ class TestCLI:
|
||||
|
||||
def test_main_with_install(self) -> None:
|
||||
"""Test the main function with install option."""
|
||||
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
|
||||
"hanzo_mcp.cli.install_claude_desktop_config"
|
||||
) as mock_install:
|
||||
with (
|
||||
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
|
||||
patch("hanzo_mcp.cli.install_claude_desktop_config") as mock_install,
|
||||
):
|
||||
# Mock parsed arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.name = "test-server"
|
||||
@@ -124,9 +126,11 @@ class TestCLI:
|
||||
|
||||
def test_main_without_allowed_paths(self) -> None:
|
||||
"""Test the main function without specified allowed paths."""
|
||||
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
|
||||
"hanzo_mcp.server.HanzoMCPServer"
|
||||
) as mock_server_class, patch("os.path.expanduser", return_value="/home/testuser"):
|
||||
with (
|
||||
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
|
||||
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
|
||||
patch("os.path.expanduser", return_value="/home/testuser"),
|
||||
):
|
||||
# Mock parsed arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.name = "test-server"
|
||||
@@ -189,9 +193,10 @@ class TestCLI:
|
||||
|
||||
def test_main_with_disable_write_tools(self) -> None:
|
||||
"""Test the main function with disable_write_tools=True."""
|
||||
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
|
||||
"hanzo_mcp.server.HanzoMCPServer"
|
||||
) as mock_server_class:
|
||||
with (
|
||||
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
|
||||
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
|
||||
):
|
||||
# Mock parsed arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.name = "test-server"
|
||||
@@ -255,9 +260,10 @@ class TestCLI:
|
||||
|
||||
def test_main_with_disable_search_tools(self) -> None:
|
||||
"""Test the main function with disable_search_tools=True."""
|
||||
with patch("argparse.ArgumentParser.parse_args") as mock_parse_args, patch(
|
||||
"hanzo_mcp.server.HanzoMCPServer"
|
||||
) as mock_server_class:
|
||||
with (
|
||||
patch("argparse.ArgumentParser.parse_args") as mock_parse_args,
|
||||
patch("hanzo_mcp.server.HanzoMCPServer") as mock_server_class,
|
||||
):
|
||||
# Mock parsed arguments
|
||||
mock_args = MagicMock()
|
||||
mock_args.name = "test-server"
|
||||
@@ -337,17 +343,22 @@ class TestInstallClaudeDesktopConfig:
|
||||
# Restore original platform
|
||||
monkeypatch.setattr(sys, "platform", original_platform)
|
||||
|
||||
def test_install_config_macos(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_macos(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test installing config on macOS."""
|
||||
# Set platform to macOS
|
||||
mock_platform("darwin")
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir") as mock_mkdir:
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir") as mock_mkdir,
|
||||
):
|
||||
# Construct expected config path
|
||||
config_dir = tmp_path / "Library" / "Application Support" / "Claude"
|
||||
config_file = config_dir / "claude_desktop_config.json"
|
||||
@@ -375,21 +386,31 @@ class TestInstallClaudeDesktopConfig:
|
||||
config_data = mock_json_dump.call_args[0][0]
|
||||
assert "mcpServers" in config_data
|
||||
assert "test-server" in config_data["mcpServers"]
|
||||
assert "/usr/bin/python3" in config_data["mcpServers"]["test-server"]["command"]
|
||||
assert "--allow-path" in str(config_data["mcpServers"]["test-server"]["args"])
|
||||
assert (
|
||||
"/usr/bin/python3"
|
||||
in config_data["mcpServers"]["test-server"]["command"]
|
||||
)
|
||||
assert "--allow-path" in str(
|
||||
config_data["mcpServers"]["test-server"]["args"]
|
||||
)
|
||||
assert "/test/path" in str(config_data["mcpServers"]["test-server"]["args"])
|
||||
|
||||
def test_install_config_windows(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_windows(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test installing config on Windows."""
|
||||
# Set platform to Windows
|
||||
mock_platform("win32")
|
||||
|
||||
# Mock environment variable
|
||||
with patch.dict(os.environ, {"APPDATA": str(tmp_path)}), patch(
|
||||
"sys.executable", "C:\\Python\\python.exe"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir") as mock_mkdir:
|
||||
with (
|
||||
patch.dict(os.environ, {"APPDATA": str(tmp_path)}),
|
||||
patch("sys.executable", "C:\\Python\\python.exe"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir") as mock_mkdir,
|
||||
):
|
||||
# Construct expected config path
|
||||
config_dir = Path(tmp_path) / "Claude"
|
||||
config_file = config_dir / "claude_desktop_config.json"
|
||||
@@ -415,7 +436,9 @@ class TestInstallClaudeDesktopConfig:
|
||||
assert "mcpServers" in config_data
|
||||
assert "test-server" in config_data["mcpServers"]
|
||||
|
||||
def test_install_config_merge_existing(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_merge_existing(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test merging with existing config file."""
|
||||
# Set platform to Linux
|
||||
mock_platform("linux")
|
||||
@@ -432,11 +455,15 @@ class TestInstallClaudeDesktopConfig:
|
||||
}
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("json.load", return_value=existing_config), patch(
|
||||
"builtins.open", create=True
|
||||
) as mock_open, patch("pathlib.Path.exists", return_value=True), patch("pathlib.Path.mkdir") as mock_mkdir:
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("json.load", return_value=existing_config),
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=True),
|
||||
patch("pathlib.Path.mkdir") as mock_mkdir,
|
||||
):
|
||||
# Construct expected config path
|
||||
config_dir = tmp_path / ".config" / "claude"
|
||||
config_dir / "claude_desktop_config.json"
|
||||
@@ -462,17 +489,22 @@ class TestInstallClaudeDesktopConfig:
|
||||
assert "test-server" in config_data["mcpServers"]
|
||||
assert "otherSetting" in config_data
|
||||
|
||||
def test_install_config_default_paths(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_default_paths(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test installing config with default allowed paths."""
|
||||
# Set platform to macOS
|
||||
mock_platform("darwin")
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir"):
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
# Mock file opening
|
||||
mock_file = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value = mock_file
|
||||
@@ -493,23 +525,30 @@ class TestInstallClaudeDesktopConfig:
|
||||
# Verify --disable-write-tools flag is not present
|
||||
assert "--disable-write-tools" not in server_args
|
||||
|
||||
def test_install_config_with_disable_write_tools(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_with_disable_write_tools(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test installing config with disable_write_tools=True."""
|
||||
# Set platform to macOS
|
||||
mock_platform("darwin")
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir"):
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
# Mock file opening
|
||||
mock_file = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value = mock_file
|
||||
|
||||
# Call the install function with disable_write_tools=True
|
||||
install_claude_desktop_config("test-server", allowed_paths=["/test/path"], disable_write_tools=True)
|
||||
install_claude_desktop_config(
|
||||
"test-server", allowed_paths=["/test/path"], disable_write_tools=True
|
||||
)
|
||||
|
||||
# Verify correct config was written
|
||||
mock_json_dump.assert_called_once()
|
||||
@@ -532,17 +571,22 @@ class TestInstallClaudeDesktopConfig:
|
||||
mock_platform("darwin")
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir"):
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
# Mock file opening
|
||||
mock_file = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value = mock_file
|
||||
|
||||
# Call the install function with disable_search_tools=True
|
||||
install_claude_desktop_config("test-server", allowed_paths=["/test/path"], disable_search_tools=True)
|
||||
install_claude_desktop_config(
|
||||
"test-server", allowed_paths=["/test/path"], disable_search_tools=True
|
||||
)
|
||||
|
||||
# Verify correct config was written
|
||||
mock_json_dump.assert_called_once()
|
||||
@@ -557,17 +601,22 @@ class TestInstallClaudeDesktopConfig:
|
||||
# Verify --disable-search-tools flag is present
|
||||
assert "--disable-search-tools" in server_args
|
||||
|
||||
def test_install_config_with_both_flags(self, mock_platform: Callable[[str], str], tmp_path: Path) -> None:
|
||||
def test_install_config_with_both_flags(
|
||||
self, mock_platform: Callable[[str], str], tmp_path: Path
|
||||
) -> None:
|
||||
"""Test installing config with both disable_write_tools and disable_search_tools set to True."""
|
||||
# Set platform to macOS
|
||||
mock_platform("darwin")
|
||||
|
||||
# Mock home directory and config path
|
||||
with patch("pathlib.Path.home", return_value=Path(tmp_path)), patch(
|
||||
"sys.executable", "/usr/bin/python3"
|
||||
), patch("json.dump") as mock_json_dump, patch("builtins.open", create=True) as mock_open, patch(
|
||||
"pathlib.Path.exists", return_value=False
|
||||
), patch("pathlib.Path.mkdir"):
|
||||
with (
|
||||
patch("pathlib.Path.home", return_value=Path(tmp_path)),
|
||||
patch("sys.executable", "/usr/bin/python3"),
|
||||
patch("json.dump") as mock_json_dump,
|
||||
patch("builtins.open", create=True) as mock_open,
|
||||
patch("pathlib.Path.exists", return_value=False),
|
||||
patch("pathlib.Path.mkdir"),
|
||||
):
|
||||
# Mock file opening
|
||||
mock_file = MagicMock()
|
||||
mock_open.return_value.__enter__.return_value = mock_file
|
||||
|
||||
@@ -52,7 +52,12 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Claude response"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Test prompt", model="claude-3-opus-20240229", timeout=300)
|
||||
result = await tool.call(
|
||||
mock_context,
|
||||
prompt="Test prompt",
|
||||
model="claude-3-opus-20240229",
|
||||
timeout=300,
|
||||
)
|
||||
|
||||
assert result == "Claude response"
|
||||
mock_exec.assert_called_once()
|
||||
@@ -77,7 +82,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "GPT-4 response"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Generate code", model="gpt-4-turbo")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Generate code", model="gpt-4-turbo"
|
||||
)
|
||||
|
||||
assert result == "GPT-4 response"
|
||||
|
||||
@@ -92,7 +99,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Gemini response"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Analyze image", model="gemini-1.5-pro")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Analyze image", model="gemini-1.5-pro"
|
||||
)
|
||||
|
||||
assert result == "Gemini response"
|
||||
|
||||
@@ -107,7 +116,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Grok response"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Real-time analysis", model="grok-2")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Real-time analysis", model="grok-2"
|
||||
)
|
||||
|
||||
assert result == "Grok response"
|
||||
|
||||
@@ -122,7 +133,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "OpenHands execution complete"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Build feature", working_dir="/project")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Build feature", working_dir="/project"
|
||||
)
|
||||
|
||||
assert result == "OpenHands execution complete"
|
||||
|
||||
@@ -145,7 +158,11 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Hanzo Dev completed task"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Implement feature", model="claude-3-5-sonnet-20241022")
|
||||
result = await tool.call(
|
||||
mock_context,
|
||||
prompt="Implement feature",
|
||||
model="claude-3-5-sonnet-20241022",
|
||||
)
|
||||
|
||||
assert result == "Hanzo Dev completed task"
|
||||
|
||||
@@ -160,7 +177,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Cline autonomous coding complete"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Fix bugs", working_dir="/src")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Fix bugs", working_dir="/src"
|
||||
)
|
||||
|
||||
assert result == "Cline autonomous coding complete"
|
||||
|
||||
@@ -175,7 +194,9 @@ class TestCLITools:
|
||||
with patch.object(tool, "execute_cli", new_callable=AsyncMock) as mock_exec:
|
||||
mock_exec.return_value = "Aider pair programming complete"
|
||||
|
||||
result = await tool.call(mock_context, prompt="Refactor module", model="gpt-4-turbo")
|
||||
result = await tool.call(
|
||||
mock_context, prompt="Refactor module", model="gpt-4-turbo"
|
||||
)
|
||||
|
||||
assert result == "Aider pair programming complete"
|
||||
|
||||
@@ -223,7 +244,9 @@ class TestCLITools:
|
||||
# Mock a subprocess error
|
||||
with patch("asyncio.create_subprocess_exec") as mock_subprocess:
|
||||
mock_process = MagicMock()
|
||||
mock_process.communicate = AsyncMock(return_value=(b"", b"Error: API key not found"))
|
||||
mock_process.communicate = AsyncMock(
|
||||
return_value=(b"", b"Error: API key not found")
|
||||
)
|
||||
mock_process.returncode = 1
|
||||
mock_subprocess.return_value = mock_process
|
||||
|
||||
@@ -287,7 +310,9 @@ class TestBatchWithCLITools:
|
||||
{"tool_name": "gemini", "input": {"prompt": "Review code"}},
|
||||
]
|
||||
|
||||
result = await batch_tool.call(mock_context, description="Multi-AI analysis", invocations=invocations)
|
||||
result = await batch_tool.call(
|
||||
mock_context, description="Multi-AI analysis", invocations=invocations
|
||||
)
|
||||
|
||||
# Check that all tools were called
|
||||
assert "claude response" in result
|
||||
@@ -308,7 +333,9 @@ class TestBatchWithCLITools:
|
||||
},
|
||||
]
|
||||
|
||||
result = await batch_tool.call(mock_context, description="Test aliases", invocations=invocations)
|
||||
result = await batch_tool.call(
|
||||
mock_context, description="Test aliases", invocations=invocations
|
||||
)
|
||||
|
||||
assert "cc response" in result
|
||||
assert "oh response" in result
|
||||
@@ -334,7 +361,9 @@ class TestBatchWithCLITools:
|
||||
]
|
||||
|
||||
start = time.time()
|
||||
result = await batch_tool.call(mock_context, description="Parallel test", invocations=invocations)
|
||||
result = await batch_tool.call(
|
||||
mock_context, description="Parallel test", invocations=invocations
|
||||
)
|
||||
duration = time.time() - start
|
||||
|
||||
# If executed in parallel, should take ~100ms, not 400ms
|
||||
|
||||
@@ -26,12 +26,16 @@ class TestHiddenFilePermissions:
|
||||
|
||||
# Test paths with the fixed implementation
|
||||
# The workflow example and gitignore files should be allowed
|
||||
assert manager.is_path_allowed(problem_path), "Should allow .github-workflow-example.yml"
|
||||
assert manager.is_path_allowed(problem_path), (
|
||||
"Should allow .github-workflow-example.yml"
|
||||
)
|
||||
assert manager.is_path_allowed(gitignore_file), "Should allow .gitignore"
|
||||
assert manager.is_path_allowed(git_related_file), "Should allow git-tutorial.md"
|
||||
|
||||
# Since .git is now allowed by default, this should also be allowed
|
||||
assert manager.is_path_allowed(actual_github_dir), "Should allow actual .github directory"
|
||||
assert manager.is_path_allowed(actual_github_dir), (
|
||||
"Should allow actual .github directory"
|
||||
)
|
||||
|
||||
def test_various_hidden_files(self, tool_helper, temp_dir: str):
|
||||
"""Test a variety of hidden files and paths to ensure correct behavior."""
|
||||
@@ -89,11 +93,15 @@ class TestHiddenFilePermissions:
|
||||
|
||||
# Test full component paths (should be excluded)
|
||||
for path in full_component_paths:
|
||||
assert not manager.is_path_allowed(path), f"Should exclude full component: {path}"
|
||||
assert not manager.is_path_allowed(path), (
|
||||
f"Should exclude full component: {path}"
|
||||
)
|
||||
|
||||
# Test partial component paths (should be allowed)
|
||||
for path in partial_component_paths:
|
||||
assert manager.is_path_allowed(path), f"Should allow partial component: {path}"
|
||||
assert manager.is_path_allowed(path), (
|
||||
f"Should allow partial component: {path}"
|
||||
)
|
||||
|
||||
def test_wildcard_patterns(self, tool_helper, temp_dir: str):
|
||||
"""Test that wildcard patterns work correctly."""
|
||||
@@ -119,7 +127,9 @@ class TestHiddenFilePermissions:
|
||||
|
||||
# Test wildcard matching paths (should be excluded)
|
||||
for path in wildcard_matches:
|
||||
assert not manager.is_path_allowed(path), f"Should exclude wildcard match: {path}"
|
||||
assert not manager.is_path_allowed(path), (
|
||||
f"Should exclude wildcard match: {path}"
|
||||
)
|
||||
|
||||
# Test non-matching paths (should be allowed)
|
||||
for path in wildcard_non_matches:
|
||||
|
||||
@@ -110,7 +110,9 @@ class TestPermissibleOperation:
|
||||
"""Test the PermissibleOperation decorator."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permissible_operation_with_allowed_path(self, tool_helper, temp_dir: str):
|
||||
async def test_permissible_operation_with_allowed_path(
|
||||
self, tool_helper, temp_dir: str
|
||||
):
|
||||
"""Test the decorator with an allowed path."""
|
||||
manager = PermissionManager()
|
||||
manager.add_allowed_path(temp_dir)
|
||||
@@ -126,7 +128,9 @@ class TestPermissibleOperation:
|
||||
assert result == f"Read {temp_dir}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permissible_operation_with_custom_path_fn(self, tool_helper, temp_dir: str):
|
||||
async def test_permissible_operation_with_custom_path_fn(
|
||||
self, tool_helper, temp_dir: str
|
||||
):
|
||||
"""Test the decorator with a custom path function."""
|
||||
manager = PermissionManager()
|
||||
manager.add_allowed_path(temp_dir)
|
||||
@@ -146,7 +150,9 @@ class TestPermissibleOperation:
|
||||
assert result == f"Read {temp_dir}"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permissible_operation_with_invalid_path(self, tool_helper, temp_dir: str):
|
||||
async def test_permissible_operation_with_invalid_path(
|
||||
self, tool_helper, temp_dir: str
|
||||
):
|
||||
"""Test the decorator with an invalid path type."""
|
||||
manager = PermissionManager()
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ import pytest
|
||||
# Import guard for optional hanzo_network dependency
|
||||
try:
|
||||
# Add hanzo-network to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src"))
|
||||
sys.path.insert(
|
||||
0, str(Path(__file__).parent.parent.parent / "hanzo-network" / "src")
|
||||
)
|
||||
|
||||
from hanzo_network import (
|
||||
check_local_llm_status,
|
||||
@@ -24,7 +26,8 @@ except ImportError:
|
||||
|
||||
# Skip entire module if hanzo_network is not available
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HANZO_NETWORK_AVAILABLE, reason="hanzo_network package not installed or numpy not available"
|
||||
not HANZO_NETWORK_AVAILABLE,
|
||||
reason="hanzo_network package not installed or numpy not available",
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +57,11 @@ async def test_e2e_local_inference():
|
||||
name="echo_agent",
|
||||
description="Echoes messages",
|
||||
system="You are an echo agent. Use the echo_message tool when asked to echo.",
|
||||
tools=[create_tool(name="echo_message", description="Echo a message", handler=echo_message)],
|
||||
tools=[
|
||||
create_tool(
|
||||
name="echo_message", description="Echo a message", handler=echo_message
|
||||
)
|
||||
],
|
||||
local_model="llama3.2",
|
||||
)
|
||||
|
||||
@@ -62,7 +69,11 @@ async def test_e2e_local_inference():
|
||||
name="math_agent",
|
||||
description="Does math",
|
||||
system="You are a math agent. Use the add_numbers tool when asked to add.",
|
||||
tools=[create_tool(name="add_numbers", description="Add numbers", handler=add_numbers)],
|
||||
tools=[
|
||||
create_tool(
|
||||
name="add_numbers", description="Add numbers", handler=add_numbers
|
||||
)
|
||||
],
|
||||
local_model="llama3.2",
|
||||
)
|
||||
|
||||
@@ -87,7 +98,9 @@ async def test_e2e_local_inference():
|
||||
assert "math_agent" in status["local_agents"]
|
||||
|
||||
# Test echo agent
|
||||
result = await network.run(prompt="Echo the message 'Hello from E2E test'", initial_agent=echo_agent)
|
||||
result = await network.run(
|
||||
prompt="Echo the message 'Hello from E2E test'", initial_agent=echo_agent
|
||||
)
|
||||
assert result["success"]
|
||||
# The dummy model returns generic responses, so just check for success
|
||||
assert result["final_output"] is not None
|
||||
@@ -137,7 +150,9 @@ async def test_e2e_multi_agent_collaboration():
|
||||
await network.start(wait_for_peers=0)
|
||||
|
||||
# Test collaboration
|
||||
result = await network.run(prompt="Research what distributed inference is and write a brief explanation")
|
||||
result = await network.run(
|
||||
prompt="Research what distributed inference is and write a brief explanation"
|
||||
)
|
||||
assert result["success"]
|
||||
|
||||
await network.stop()
|
||||
@@ -162,7 +177,9 @@ async def test_e2e_mcp_tools_with_local_llm():
|
||||
description="File system operations",
|
||||
system="You handle file system operations using the available tools.",
|
||||
tools=[
|
||||
create_tool(name="read_test_file", description="Read a file", handler=read_test_file),
|
||||
create_tool(
|
||||
name="read_test_file", description="Read a file", handler=read_test_file
|
||||
),
|
||||
create_tool(
|
||||
name="list_test_files",
|
||||
description="List files",
|
||||
@@ -183,7 +200,9 @@ async def test_e2e_mcp_tools_with_local_llm():
|
||||
await network.start(wait_for_peers=0)
|
||||
|
||||
# Test file operations
|
||||
result = await network.run(prompt="List the files in the test directory", initial_agent=fs_agent)
|
||||
result = await network.run(
|
||||
prompt="List the files in the test directory", initial_agent=fs_agent
|
||||
)
|
||||
assert result["success"]
|
||||
# The dummy model returns generic responses, so just check for success
|
||||
assert result["final_output"] is not None
|
||||
|
||||
@@ -17,7 +17,9 @@ def test_imports():
|
||||
|
||||
assert network_version is not None
|
||||
assert mcp_version is not None
|
||||
print(f"✅ All imports successful! hanzo-network: {network_version}, hanzo-mcp: {mcp_version}")
|
||||
print(
|
||||
f"✅ All imports successful! hanzo-network: {network_version}, hanzo-mcp: {mcp_version}"
|
||||
)
|
||||
|
||||
|
||||
def test_hanzo_net_provider():
|
||||
@@ -41,7 +43,11 @@ def test_local_agent_creation():
|
||||
name="test_agent",
|
||||
description="Test agent",
|
||||
system="You are a test agent",
|
||||
tools=[create_tool(name="dummy_tool", description="A dummy tool", handler=dummy_tool)],
|
||||
tools=[
|
||||
create_tool(
|
||||
name="dummy_tool", description="A dummy tool", handler=dummy_tool
|
||||
)
|
||||
],
|
||||
local_model="llama3.2",
|
||||
)
|
||||
|
||||
@@ -56,7 +62,9 @@ def test_network_config():
|
||||
"""Test distributed network configuration."""
|
||||
from hanzo_network import create_local_agent, create_local_distributed_network
|
||||
|
||||
agent = create_local_agent(name="test_agent", description="Test agent", local_model="llama3.2")
|
||||
agent = create_local_agent(
|
||||
name="test_agent", description="Test agent", local_model="llama3.2"
|
||||
)
|
||||
|
||||
network = create_local_distributed_network(
|
||||
agents=[agent], name="test-network", listen_port=16100, broadcast_port=16100
|
||||
|
||||
@@ -37,7 +37,12 @@ class TestMCPErrorLogger:
|
||||
params = {"file_path": "/test/path", "limit": 100}
|
||||
|
||||
# Log the error
|
||||
logger.log_tool_error(tool_name="read", error=test_error, params=params, context="Testing error logging")
|
||||
logger.log_tool_error(
|
||||
tool_name="read",
|
||||
error=test_error,
|
||||
params=params,
|
||||
context="Testing error logging",
|
||||
)
|
||||
|
||||
# Check that log files were created
|
||||
tool_log = tmp_path / "read-errors.log"
|
||||
@@ -110,7 +115,9 @@ class TestMCPErrorLogger:
|
||||
# Log multiple errors
|
||||
for i in range(5):
|
||||
error = ValueError(f"Error {i}")
|
||||
logger.log_tool_error(tool_name=f"tool_{i}", error=error, params={"index": i})
|
||||
logger.log_tool_error(
|
||||
tool_name=f"tool_{i}", error=error, params={"index": i}
|
||||
)
|
||||
|
||||
# Get recent errors
|
||||
recent = logger.get_recent_errors(limit=3)
|
||||
@@ -145,7 +152,9 @@ class TestMCPErrorLogger:
|
||||
|
||||
# Test log_call_signature_error
|
||||
sig_error = TypeError("signature mismatch")
|
||||
log_call_signature_error("test_tool", "expected signature", "actual call", sig_error)
|
||||
log_call_signature_error(
|
||||
"test_tool", "expected signature", "actual call", sig_error
|
||||
)
|
||||
|
||||
sig_log = tmp_path / "signature-errors.log"
|
||||
assert sig_log.exists()
|
||||
|
||||
@@ -12,7 +12,9 @@ async def test_targetspec_rejects_unknown_keys():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dry_run_envelope_and_no_fs_changes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
async def test_dry_run_envelope_and_no_fs_changes(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
root = tmp_path
|
||||
(root / "go.work").write_text("go 1.21\n\nuse (\n ./mod\n)\n")
|
||||
mod_dir = root / "mod"
|
||||
@@ -33,7 +35,13 @@ async def test_dry_run_envelope_and_no_fs_changes(tmp_path: Path, monkeypatch: p
|
||||
before = file_path.read_text()
|
||||
|
||||
edit_result = await tools.edit(
|
||||
target, EditArgs(op="rename", file=str(file_path), pos={"line": 1, "character": 1}, new_name="Main")
|
||||
target,
|
||||
EditArgs(
|
||||
op="rename",
|
||||
file=str(file_path),
|
||||
pos={"line": 1, "character": 1},
|
||||
new_name="Main",
|
||||
),
|
||||
)
|
||||
assert hasattr(edit_result, "ok")
|
||||
assert hasattr(edit_result, "root")
|
||||
@@ -73,7 +81,9 @@ async def test_workspace_detection_go_work_priority(tmp_path: Path):
|
||||
(b / "go.mod").write_text("module example.com/b\n\ngo 1.21\n")
|
||||
|
||||
tools = HanzoTools()
|
||||
resolved = tools.target_resolver.resolve(TargetSpec(target="ws", root=str(root), language="go", dry_run=True))
|
||||
resolved = tools.target_resolver.resolve(
|
||||
TargetSpec(target="ws", root=str(root), language="go", dry_run=True)
|
||||
)
|
||||
assert resolved["workspace"]["root"] == str(root)
|
||||
|
||||
|
||||
@@ -86,7 +96,9 @@ async def test_workspace_detection_root_boundary(tmp_path: Path):
|
||||
(inner / "go.mod").write_text("module example.com/inner\n\ngo 1.21\n")
|
||||
|
||||
tools = HanzoTools()
|
||||
resolved = tools.target_resolver.resolve(TargetSpec(target="ws", root=str(inner), language="go", dry_run=True))
|
||||
resolved = tools.target_resolver.resolve(
|
||||
TargetSpec(target="ws", root=str(inner), language="go", dry_run=True)
|
||||
)
|
||||
assert resolved["workspace"]["root"] == str(inner)
|
||||
|
||||
|
||||
@@ -94,12 +106,21 @@ async def test_workspace_detection_root_boundary(tmp_path: Path):
|
||||
async def test_guard_transitive_go_import(tmp_path: Path):
|
||||
root = tmp_path
|
||||
(root / "go.mod").write_text("module example.com/root\n\ngo 1.21\n")
|
||||
(root / "main.go").write_text('package main\n\nimport "net/http"\n\nfunc main() {}\n')
|
||||
(root / "main.go").write_text(
|
||||
'package main\n\nimport "net/http"\n\nfunc main() {}\n'
|
||||
)
|
||||
|
||||
tools = HanzoTools()
|
||||
target = TargetSpec(target=f"dir:{root}", language="go", dry_run=True)
|
||||
guard = GuardArgs(
|
||||
rules=[GuardRule(id="no-net-http", type="import", glob="**/*.go", forbid_import_prefix="net/http")]
|
||||
rules=[
|
||||
GuardRule(
|
||||
id="no-net-http",
|
||||
type="import",
|
||||
glob="**/*.go",
|
||||
forbid_import_prefix="net/http",
|
||||
)
|
||||
]
|
||||
)
|
||||
result = await tools.guard(target, guard)
|
||||
assert result.exit_code == 1
|
||||
|
||||
@@ -17,7 +17,9 @@ def test_no_api_key():
|
||||
|
||||
try:
|
||||
# Try to create swarm without API key
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get("CLAUDE_API_KEY")
|
||||
api_key = os.environ.get("ANTHROPIC_API_KEY") or os.environ.get(
|
||||
"CLAUDE_API_KEY"
|
||||
)
|
||||
|
||||
if not api_key:
|
||||
print("✓ Correctly detected missing API key")
|
||||
|
||||
@@ -100,7 +100,9 @@ class TestRefactoredFileTools:
|
||||
return_value=tool_ctx,
|
||||
):
|
||||
# Call the tool directly
|
||||
result = await write_file_tool.call(ctx=mcp_context, file_path=test_path, content=test_content)
|
||||
result = await write_file_tool.call(
|
||||
ctx=mcp_context, file_path=test_path, content=test_content
|
||||
)
|
||||
|
||||
# Verify result
|
||||
tool_helper.assert_in_result("Successfully wrote file", result)
|
||||
|
||||
@@ -70,7 +70,8 @@ class TestFindToolFFind:
|
||||
|
||||
# Create files with different sizes and content
|
||||
files = {
|
||||
"src/main.py": "# Main application\nimport sys\n\ndef main():\n pass\n" * 100,
|
||||
"src/main.py": "# Main application\nimport sys\n\ndef main():\n pass\n"
|
||||
* 100,
|
||||
"src/utils.py": "# Utilities\ndef helper():\n return 'TODO: implement'\n",
|
||||
"src/config.json": '{"debug": true, "port": 8080}\n',
|
||||
"src/modules/auth.py": "# Authentication module\nclass Auth:\n pass\n",
|
||||
@@ -138,7 +139,9 @@ class TestFindToolFFind:
|
||||
async def test_find_with_size_filter(self, tool_helper, find_tool, test_directory):
|
||||
"""Test finding files by size."""
|
||||
# Find large files (> 500KB)
|
||||
result = await find_tool.run(pattern="*", path=test_directory, type="file", min_size="500KB")
|
||||
result = await find_tool.run(
|
||||
pattern="*", path=test_directory, type="file", min_size="500KB"
|
||||
)
|
||||
|
||||
results = result.data["results"]
|
||||
# Should only find large_file.dat
|
||||
@@ -168,9 +171,13 @@ class TestFindToolFFind:
|
||||
assert "api" not in dir_names
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_with_pattern_in_content(self, tool_helper, find_tool, test_directory):
|
||||
async def test_find_with_pattern_in_content(
|
||||
self, tool_helper, find_tool, test_directory
|
||||
):
|
||||
"""Test finding files containing specific text."""
|
||||
result = await find_tool.run(pattern="TODO", path=test_directory, in_content=True, type="file")
|
||||
result = await find_tool.run(
|
||||
pattern="TODO", path=test_directory, in_content=True, type="file"
|
||||
)
|
||||
|
||||
results = result.data["results"]
|
||||
file_names = [r["name"] for r in results]
|
||||
@@ -233,7 +240,9 @@ class TestFindToolFFind:
|
||||
find_tool._available_backends = None
|
||||
|
||||
start_time = time.time()
|
||||
result_python = await find_tool.run(pattern="*.py", path=test_directory, type="file")
|
||||
result_python = await find_tool.run(
|
||||
pattern="*.py", path=test_directory, type="file"
|
||||
)
|
||||
python_time = time.time() - start_time
|
||||
|
||||
# Restore ffind if available
|
||||
@@ -242,7 +251,9 @@ class TestFindToolFFind:
|
||||
|
||||
if FFIND_AVAILABLE:
|
||||
start_time = time.time()
|
||||
result_ffind = await find_tool.run(pattern="*.py", path=test_directory, type="file")
|
||||
result_ffind = await find_tool.run(
|
||||
pattern="*.py", path=test_directory, type="file"
|
||||
)
|
||||
ffind_time = time.time() - start_time
|
||||
|
||||
print("\nPerformance comparison:")
|
||||
@@ -251,7 +262,9 @@ class TestFindToolFFind:
|
||||
print(f"Speedup: {python_time / ffind_time:.1f}x")
|
||||
|
||||
# Results should be the same
|
||||
assert len(result_python.data["results"]) == len(result_ffind.data["results"])
|
||||
assert len(result_python.data["results"]) == len(
|
||||
result_ffind.data["results"]
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fuzzy_search(self, tool_helper, find_tool, test_directory):
|
||||
@@ -270,7 +283,9 @@ class TestFindToolFFind:
|
||||
assert any("test" in name for name in file_names)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_search(self, tool_helper, find_tool, test_directory):
|
||||
async def test_case_insensitive_search(
|
||||
self, tool_helper, find_tool, test_directory
|
||||
):
|
||||
"""Test case-insensitive search."""
|
||||
result = await find_tool.run(
|
||||
pattern="*.MD", # Uppercase extension
|
||||
@@ -302,7 +317,9 @@ class TestFindToolFFind:
|
||||
@pytest.mark.asyncio
|
||||
async def test_gitignore_respect(self, tool_helper, find_tool, test_directory):
|
||||
"""Test that .gitignore patterns are respected."""
|
||||
result = await find_tool.run(pattern="*", path=test_directory, respect_gitignore=True)
|
||||
result = await find_tool.run(
|
||||
pattern="*", path=test_directory, respect_gitignore=True
|
||||
)
|
||||
|
||||
results = result.data["results"]
|
||||
file_paths = [r["path"] for r in results]
|
||||
@@ -313,7 +330,9 @@ class TestFindToolFFind:
|
||||
assert not any(".pyc" in path for path in file_paths)
|
||||
|
||||
# Test with gitignore disabled
|
||||
result_no_ignore = await find_tool.run(pattern="*", path=test_directory, respect_gitignore=False)
|
||||
result_no_ignore = await find_tool.run(
|
||||
pattern="*", path=test_directory, respect_gitignore=False
|
||||
)
|
||||
|
||||
results_no_ignore = result_no_ignore.data["results"]
|
||||
file_paths_no_ignore = [r["path"] for r in results_no_ignore]
|
||||
|
||||
@@ -177,7 +177,9 @@ async def test_find_tool_advanced_filters(tool_helper):
|
||||
find_tool = create_find_tool()
|
||||
|
||||
# Test modified_after filter
|
||||
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_after="2 days ago")
|
||||
result = await find_tool.run(
|
||||
pattern="*.txt", path=tmpdir, modified_after="2 days ago"
|
||||
)
|
||||
|
||||
recent_files = result.data["results"]
|
||||
recent_names = [f["name"] for f in recent_files]
|
||||
@@ -186,7 +188,9 @@ async def test_find_tool_advanced_filters(tool_helper):
|
||||
assert "new_file.txt" in recent_names
|
||||
|
||||
# Test modified_before filter
|
||||
result = await find_tool.run(pattern="*.txt", path=tmpdir, modified_before="30 minutes ago")
|
||||
result = await find_tool.run(
|
||||
pattern="*.txt", path=tmpdir, modified_before="30 minutes ago"
|
||||
)
|
||||
|
||||
older_files = result.data["results"]
|
||||
older_names = [f["name"] for f in older_files]
|
||||
@@ -203,7 +207,9 @@ async def test_find_tool_advanced_filters(tool_helper):
|
||||
assert sorted_files[-1]["name"] == "new_file.txt"
|
||||
|
||||
# Test reverse sorting
|
||||
result = await find_tool.run(pattern="*.txt", path=tmpdir, sort_by="modified", reverse=True)
|
||||
result = await find_tool.run(
|
||||
pattern="*.txt", path=tmpdir, sort_by="modified", reverse=True
|
||||
)
|
||||
|
||||
reverse_sorted = result.data["results"]
|
||||
assert reverse_sorted[0]["name"] == "new_file.txt"
|
||||
|
||||
@@ -16,7 +16,9 @@ def test_find_tool_in_filesystem_tools():
|
||||
pm = PermissionManager()
|
||||
|
||||
# Register filesystem tools
|
||||
tools = register_filesystem_tools(mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True})
|
||||
tools = register_filesystem_tools(
|
||||
mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True}
|
||||
)
|
||||
|
||||
# Check that tools were registered
|
||||
assert len(tools) > 0
|
||||
@@ -35,7 +37,9 @@ def test_find_tool_in_filesystem_tools():
|
||||
def test_find_tool_in_server():
|
||||
"""Test that FindTool is registered when creating a server."""
|
||||
# Create server with search tools enabled
|
||||
server = HanzoMCPServer(name="test-server", disable_search_tools=False, enabled_tools={"find": True})
|
||||
server = HanzoMCPServer(
|
||||
name="test-server", disable_search_tools=False, enabled_tools={"find": True}
|
||||
)
|
||||
|
||||
# The server registers tools during initialization
|
||||
# We can't directly access _tool_handlers, but we know tools are registered
|
||||
@@ -54,7 +58,9 @@ def test_tool_registration_flow():
|
||||
pm = PermissionManager()
|
||||
|
||||
# Test filesystem tools registration directly
|
||||
filesystem_tools = register_filesystem_tools(mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True})
|
||||
filesystem_tools = register_filesystem_tools(
|
||||
mcp_server=mcp, permission_manager=pm, enabled_tools={"find": True}
|
||||
)
|
||||
|
||||
assert len(filesystem_tools) > 0
|
||||
|
||||
@@ -89,7 +95,9 @@ async def test_find_tool_usage():
|
||||
assert "results" in result.data
|
||||
assert isinstance(result.data["results"], list)
|
||||
|
||||
print(f"✓ FindTool executed successfully, found {len(result.data['results'])} files")
|
||||
print(
|
||||
f"✓ FindTool executed successfully, found {len(result.data['results'])} files"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -26,13 +26,19 @@ class TestGitIngestion:
|
||||
cwd=repo_path,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "config", "user.name", "Test User"], cwd=repo_path, check=True
|
||||
)
|
||||
|
||||
# Create initial commit
|
||||
readme = repo_path / "README.md"
|
||||
readme.write_text("# Test Project\n\nThis is a test project for git ingestion.")
|
||||
readme.write_text(
|
||||
"# Test Project\n\nThis is a test project for git ingestion."
|
||||
)
|
||||
subprocess.run(["git", "add", "README.md"], cwd=repo_path, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Initial commit"], cwd=repo_path, check=True
|
||||
)
|
||||
|
||||
# Add Python file
|
||||
main_py = repo_path / "main.py"
|
||||
@@ -49,7 +55,9 @@ if __name__ == "__main__":
|
||||
'''
|
||||
)
|
||||
subprocess.run(["git", "add", "main.py"], cwd=repo_path, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "Add main.py"], cwd=repo_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add main.py"], cwd=repo_path, check=True
|
||||
)
|
||||
|
||||
# Add more files and commits
|
||||
utils_py = repo_path / "utils.py"
|
||||
@@ -100,7 +108,9 @@ if __name__ == "__main__":
|
||||
)
|
||||
|
||||
# Create a feature branch
|
||||
subprocess.run(["git", "checkout", "-b", "feature/testing"], cwd=repo_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "checkout", "-b", "feature/testing"], cwd=repo_path, check=True
|
||||
)
|
||||
|
||||
test_py = repo_path / "test_main.py"
|
||||
test_py.write_text(
|
||||
@@ -117,7 +127,9 @@ class TestMain(unittest.TestCase):
|
||||
'''
|
||||
)
|
||||
subprocess.run(["git", "add", "test_main.py"], cwd=repo_path, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "Add tests"], cwd=repo_path, check=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-m", "Add tests"], cwd=repo_path, check=True
|
||||
)
|
||||
|
||||
# Switch back to main branch
|
||||
subprocess.run(["git", "checkout", "main"], cwd=repo_path, check=True)
|
||||
@@ -156,7 +168,9 @@ class TestMain(unittest.TestCase):
|
||||
parts = line.split("\t")
|
||||
if len(parts) >= 2:
|
||||
status, filename = parts[0], parts[1]
|
||||
current_commit["files"].append({"status": status, "filename": filename})
|
||||
current_commit["files"].append(
|
||||
{"status": status, "filename": filename}
|
||||
)
|
||||
|
||||
assert len(commits) >= 4 # Should have at least 4 commits
|
||||
assert any(c["message"] == "Initial commit" for c in commits)
|
||||
@@ -267,7 +281,11 @@ class TestMain(unittest.TestCase):
|
||||
"history_count": len(history),
|
||||
"first_commit": history[-1]["commit"] if history else None,
|
||||
"last_commit": history[0]["commit"] if history else None,
|
||||
"last_modified": (datetime.fromtimestamp(history[0]["timestamp"]).isoformat() if history else None),
|
||||
"last_modified": (
|
||||
datetime.fromtimestamp(history[0]["timestamp"]).isoformat()
|
||||
if history
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
assert metadata["history_count"] >= 2
|
||||
@@ -281,7 +299,9 @@ class TestGitProjectManager:
|
||||
def project_manager(self):
|
||||
"""Create a project manager."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
manager = ProjectVectorManager(global_db_path=str(Path(tmpdir) / "global_db"))
|
||||
manager = ProjectVectorManager(
|
||||
global_db_path=str(Path(tmpdir) / "global_db")
|
||||
)
|
||||
yield manager, tmpdir
|
||||
|
||||
def test_detect_git_project(self, tool_helper, project_manager):
|
||||
|
||||
@@ -61,7 +61,9 @@ try:
|
||||
print("\n✓ Web3Agent config imported")
|
||||
|
||||
config = Web3AgentConfig(wallet_enabled=True, tee_enabled=True, task_price_eth=0.01)
|
||||
print(f"✓ Created Web3 config: wallet={config.wallet_enabled}, tee={config.tee_enabled}")
|
||||
print(
|
||||
f"✓ Created Web3 config: wallet={config.wallet_enabled}, tee={config.tee_enabled}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Web3Agent test failed: {e}")
|
||||
|
||||
@@ -73,7 +73,9 @@ class TestHanzoMCPIntegration:
|
||||
assert "success" in result_text.lower()
|
||||
|
||||
# Test read operation
|
||||
read_result = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
|
||||
read_result = await mcp_server.mcp.call_tool(
|
||||
"read", arguments={"file_path": str(test_file)}
|
||||
)
|
||||
# read_result is a tuple (content_list, metadata)
|
||||
if isinstance(read_result, tuple) and len(read_result) > 0:
|
||||
content_list = read_result[0]
|
||||
@@ -95,7 +97,9 @@ class TestHanzoMCPIntegration:
|
||||
)
|
||||
|
||||
# Verify edit
|
||||
read_after_edit = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
|
||||
read_after_edit = await mcp_server.mcp.call_tool(
|
||||
"read", arguments={"file_path": str(test_file)}
|
||||
)
|
||||
# read_after_edit is a tuple (content_list, metadata)
|
||||
if isinstance(read_after_edit, tuple) and len(read_after_edit) > 0:
|
||||
content_list = read_after_edit[0]
|
||||
@@ -120,7 +124,9 @@ def function_{i}():
|
||||
)
|
||||
|
||||
# Test search
|
||||
search_result = await mcp_server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(temp_dir)})
|
||||
search_result = await mcp_server.mcp.call_tool(
|
||||
"search", arguments={"pattern": "TODO", "path": str(temp_dir)}
|
||||
)
|
||||
|
||||
# Handle tuple result from call_tool
|
||||
if isinstance(search_result, tuple) and len(search_result) > 0:
|
||||
@@ -197,7 +203,9 @@ def main():
|
||||
)
|
||||
|
||||
# 1. Search for TODOs
|
||||
search_result = await mcp_server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(temp_dir)})
|
||||
search_result = await mcp_server.mcp.call_tool(
|
||||
"search", arguments={"pattern": "TODO", "path": str(temp_dir)}
|
||||
)
|
||||
# Handle tuple result
|
||||
if isinstance(search_result, tuple) and len(search_result) > 0:
|
||||
content_list = search_result[0]
|
||||
@@ -210,7 +218,9 @@ def main():
|
||||
assert "TODO" in result_text
|
||||
|
||||
# 2. Read the file
|
||||
content = await mcp_server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
|
||||
content = await mcp_server.mcp.call_tool(
|
||||
"read", arguments={"file_path": str(test_file)}
|
||||
)
|
||||
# Handle tuple result
|
||||
if isinstance(content, tuple) and len(content) > 0:
|
||||
content_list = content[0]
|
||||
@@ -235,7 +245,9 @@ def main():
|
||||
# 4. Run the critic tool
|
||||
critic_result = await mcp_server.mcp.call_tool(
|
||||
"critic",
|
||||
arguments={"analysis": f"Review the code in {test_file} for potential issues"},
|
||||
arguments={
|
||||
"analysis": f"Review the code in {test_file} for potential issues"
|
||||
},
|
||||
)
|
||||
|
||||
# Handle tuple result
|
||||
@@ -249,7 +261,10 @@ def main():
|
||||
critic_text = str(critic_result)
|
||||
# The critic tool currently just returns a confirmation message
|
||||
# Check that it returns the expected template response
|
||||
assert "Critical analysis complete" in critic_text or "analysis" in critic_text.lower()
|
||||
assert (
|
||||
"Critical analysis complete" in critic_text
|
||||
or "analysis" in critic_text.lower()
|
||||
)
|
||||
|
||||
async def test_notebook_operations(self, tool_helper, mcp_server, temp_dir):
|
||||
"""Test notebook read/write operations."""
|
||||
@@ -319,7 +334,9 @@ def main():
|
||||
assert "cells" in notebook_data
|
||||
assert len(notebook_data["cells"]) >= 1 # At least the original code cell
|
||||
# Check that we have at least one code cell
|
||||
code_cells = [cell for cell in notebook_data["cells"] if cell.get("cell_type") == "code"]
|
||||
code_cells = [
|
||||
cell for cell in notebook_data["cells"] if cell.get("cell_type") == "code"
|
||||
]
|
||||
assert len(code_cells) >= 1
|
||||
|
||||
|
||||
@@ -345,7 +362,9 @@ class TestHanzoMCPStdioServer:
|
||||
return
|
||||
|
||||
# Use the MCP client session to test the server
|
||||
async with stdio_client(["python", "-m", "hanzo_mcp"], env={**os.environ, **server_env}) as (read, write):
|
||||
async with stdio_client(
|
||||
["python", "-m", "hanzo_mcp"], env={**os.environ, **server_env}
|
||||
) as (read, write):
|
||||
# Create a client session
|
||||
async with ClientSession(read, write) as session:
|
||||
# Initialize the session
|
||||
@@ -381,16 +400,22 @@ class TestHanzoMCPStdioServer:
|
||||
async def test_hanzo_mcp_cli_tool():
|
||||
"""Test the hanzo-mcp CLI tool directly."""
|
||||
# Test help command
|
||||
result = subprocess.run(["python", "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
["python", "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "usage" in result.stdout.lower() or "mcp server" in result.stdout.lower()
|
||||
|
||||
# Test version command
|
||||
result = subprocess.run(["python", "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
["python", "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert result.returncode == 0
|
||||
assert "0.6" in result.stdout or "0.7" in result.stdout # Should show version 0.6.x or 0.7.x
|
||||
assert (
|
||||
"0.6" in result.stdout or "0.7" in result.stdout
|
||||
) # Should show version 0.6.x or 0.7.x
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -24,7 +24,9 @@ def test_basic_import():
|
||||
def test_server_creation():
|
||||
"""Test creating an MCP server."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
|
||||
server = create_server(
|
||||
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
|
||||
)
|
||||
|
||||
print(f"✓ Created server: {server.__class__.__name__}")
|
||||
|
||||
@@ -61,7 +63,9 @@ import pytest
|
||||
async def test_file_operations():
|
||||
"""Test basic file operations."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
|
||||
server = create_server(
|
||||
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
|
||||
)
|
||||
|
||||
test_file = Path(tmpdir) / "test.txt"
|
||||
test_content = "Hello from hanzo-mcp test!"
|
||||
@@ -76,7 +80,9 @@ async def test_file_operations():
|
||||
assert test_file.read_text() == test_content
|
||||
|
||||
# Test read operation
|
||||
read_result = await server.mcp.call_tool("read", arguments={"file_path": str(test_file)})
|
||||
read_result = await server.mcp.call_tool(
|
||||
"read", arguments={"file_path": str(test_file)}
|
||||
)
|
||||
|
||||
# Handle tuple result from call_tool
|
||||
if isinstance(read_result, tuple) and len(read_result) > 0:
|
||||
@@ -107,7 +113,9 @@ async def test_file_operations():
|
||||
async def test_search_functionality():
|
||||
"""Test search functionality."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
|
||||
server = create_server(
|
||||
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
|
||||
)
|
||||
|
||||
# Create test files
|
||||
for i in range(3):
|
||||
@@ -122,7 +130,9 @@ def function_{i}():
|
||||
)
|
||||
|
||||
# Search for TODOs using the search tool
|
||||
search_result = await server.mcp.call_tool("search", arguments={"pattern": "TODO", "path": str(tmpdir)})
|
||||
search_result = await server.mcp.call_tool(
|
||||
"search", arguments={"pattern": "TODO", "path": str(tmpdir)}
|
||||
)
|
||||
|
||||
# Handle tuple result from call_tool
|
||||
if isinstance(search_result, tuple) and len(search_result) > 0:
|
||||
@@ -148,7 +158,9 @@ def test_cli_invocation():
|
||||
print("\n Testing CLI:")
|
||||
|
||||
# Test help
|
||||
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
print(" ✓ CLI help works")
|
||||
@@ -156,7 +168,9 @@ def test_cli_invocation():
|
||||
print(f" ✗ CLI help failed: {result.stderr}")
|
||||
|
||||
# Test version
|
||||
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
if result.returncode == 0 and "0.7" in result.stdout:
|
||||
print(f" ✓ CLI version works: {result.stdout.strip()}")
|
||||
@@ -168,7 +182,9 @@ def test_cli_invocation():
|
||||
async def test_notebook_operations():
|
||||
"""Test notebook operations."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
server = create_server(name="test-server", allowed_paths=[tmpdir], enable_all_tools=True)
|
||||
server = create_server(
|
||||
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
|
||||
)
|
||||
|
||||
notebook_path = Path(tmpdir) / "test.ipynb"
|
||||
|
||||
|
||||
@@ -14,7 +14,9 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
def test_cli_help():
|
||||
"""Test that CLI help works."""
|
||||
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hanzo_mcp", "--help"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
print(f"Return code: {result.returncode}")
|
||||
print(f"STDOUT: {result.stdout[:200]}")
|
||||
@@ -29,7 +31,9 @@ def test_cli_help():
|
||||
|
||||
def test_cli_version():
|
||||
"""Test version command."""
|
||||
result = subprocess.run([sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
|
||||
)
|
||||
|
||||
print(f"Version output: {result.stdout}")
|
||||
# Version command might not be supported, so just check if command doesn't crash
|
||||
@@ -77,7 +81,9 @@ async def test_stdio_server():
|
||||
|
||||
# Read response with timeout
|
||||
try:
|
||||
response_line = await asyncio.wait_for(proc.stdout.readline(), timeout=5.0)
|
||||
response_line = await asyncio.wait_for(
|
||||
proc.stdout.readline(), timeout=5.0
|
||||
)
|
||||
response = json.loads(response_line.decode())
|
||||
|
||||
print(f"Initialize response: {response}")
|
||||
@@ -96,7 +102,9 @@ async def test_stdio_server():
|
||||
proc.stdin.write((json.dumps(list_request) + "\n").encode())
|
||||
await proc.stdin.drain()
|
||||
|
||||
response_line = await asyncio.wait_for(proc.stdout.readline(), timeout=5.0)
|
||||
response_line = await asyncio.wait_for(
|
||||
proc.stdout.readline(), timeout=5.0
|
||||
)
|
||||
response = json.loads(response_line.decode())
|
||||
|
||||
print(f"Found {len(response['result']['tools'])} tools")
|
||||
|
||||
@@ -105,9 +105,13 @@ class TestHanzoNetworkIntegration:
|
||||
async def test_agent_communication(self, tool_helper, basic_network):
|
||||
"""Test agent-to-agent communication."""
|
||||
# Create communicating agents
|
||||
agent1 = Agent(name="agent1", role="Coordinator", capabilities=["coordination", "planning"])
|
||||
agent1 = Agent(
|
||||
name="agent1", role="Coordinator", capabilities=["coordination", "planning"]
|
||||
)
|
||||
|
||||
agent2 = Agent(name="agent2", role="Worker", capabilities=["execution", "reporting"])
|
||||
agent2 = Agent(
|
||||
name="agent2", role="Worker", capabilities=["execution", "reporting"]
|
||||
)
|
||||
|
||||
# Add message handler to agent2
|
||||
messages_received = []
|
||||
@@ -347,13 +351,17 @@ class TestHanzoNetworkIntegration:
|
||||
is_local=True,
|
||||
)
|
||||
|
||||
api_agent = Agent(name="api_expert", role="Expert Consultant", model="gpt-4", is_local=False)
|
||||
api_agent = Agent(
|
||||
name="api_expert", role="Expert Consultant", model="gpt-4", is_local=False
|
||||
)
|
||||
|
||||
await basic_network.add_agent(local_agent)
|
||||
await basic_network.add_agent(api_agent)
|
||||
|
||||
# Simple task (should go to local)
|
||||
simple_result = await basic_network.delegate_task(task='Format this JSON: {"name":"test"}', complexity="simple")
|
||||
simple_result = await basic_network.delegate_task(
|
||||
task='Format this JSON: {"name":"test"}', complexity="simple"
|
||||
)
|
||||
|
||||
assert simple_result["agent"] == "local_helper"
|
||||
assert simple_result["cost"] < 0.01
|
||||
@@ -366,7 +374,9 @@ class TestHanzoNetworkIntegration:
|
||||
|
||||
assert complex_result["agent"] == "api_expert"
|
||||
|
||||
async def test_agent_network_persistence(self, tool_helper, basic_network, temp_dir):
|
||||
async def test_agent_network_persistence(
|
||||
self, tool_helper, basic_network, temp_dir
|
||||
):
|
||||
"""Test saving and loading agent network state."""
|
||||
# Add some agents and state
|
||||
agent1 = Agent(name="persistent_agent", role="Keeper")
|
||||
@@ -470,7 +480,9 @@ class TestHanzoNetworkMCPIntegration:
|
||||
# Create specialized agents
|
||||
analyst = Agent(name="analyst", role="Code Analyst", has_mcp_access=True)
|
||||
|
||||
refactorer = Agent(name="refactorer", role="Code Refactorer", has_mcp_access=True)
|
||||
refactorer = Agent(
|
||||
name="refactorer", role="Code Refactorer", has_mcp_access=True
|
||||
)
|
||||
|
||||
reviewer = Agent(name="reviewer", role="Code Reviewer", has_mcp_access=True)
|
||||
|
||||
|
||||
@@ -53,7 +53,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
|
||||
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
|
||||
|
||||
# Create an agent tool with test model
|
||||
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
|
||||
agent = AgentTool(
|
||||
permission_manager=permission_manager, model="test-model", max_iterations=5
|
||||
)
|
||||
|
||||
# Mock the litellm completion to simulate agent requesting clarification
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
@@ -69,7 +71,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
|
||||
Mock(
|
||||
function=Mock(
|
||||
name="read",
|
||||
arguments=json.dumps({"file_path": str(test_project / "main.go")}),
|
||||
arguments=json.dumps(
|
||||
{"file_path": str(test_project / "main.go")}
|
||||
),
|
||||
),
|
||||
id="call_1",
|
||||
)
|
||||
@@ -93,7 +97,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
|
||||
"type": "MISSING_CONTEXT",
|
||||
"question": "What is the correct import path for the common package?",
|
||||
"context": {
|
||||
"file_path": str(test_project / "main.go"),
|
||||
"file_path": str(
|
||||
test_project / "main.go"
|
||||
),
|
||||
"undefined_symbols": ["common"],
|
||||
},
|
||||
"options": [
|
||||
@@ -123,7 +129,9 @@ async def test_agent_clarification_flow(test_project, mock_context):
|
||||
name="multi_edit",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"file_path": str(test_project / "main.go"),
|
||||
"file_path": str(
|
||||
test_project / "main.go"
|
||||
),
|
||||
"edits": [
|
||||
{
|
||||
"old_string": 'import (\n "fmt"\n)',
|
||||
@@ -177,7 +185,9 @@ async def test_agent_critic_flow(test_project, mock_context):
|
||||
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
|
||||
|
||||
# Create an agent tool
|
||||
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
|
||||
agent = AgentTool(
|
||||
permission_manager=permission_manager, model="test-model", max_iterations=5
|
||||
)
|
||||
|
||||
# Mock the litellm completion to simulate agent requesting critic review
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
@@ -192,7 +202,9 @@ async def test_agent_critic_flow(test_project, mock_context):
|
||||
Mock(
|
||||
function=Mock(
|
||||
name="read",
|
||||
arguments=json.dumps({"file_path": str(test_project / "main.go")}),
|
||||
arguments=json.dumps(
|
||||
{"file_path": str(test_project / "main.go")}
|
||||
),
|
||||
),
|
||||
id="call_1",
|
||||
)
|
||||
@@ -213,7 +225,9 @@ async def test_agent_critic_flow(test_project, mock_context):
|
||||
name="multi_edit",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"file_path": str(test_project / "main.go"),
|
||||
"file_path": str(
|
||||
test_project / "main.go"
|
||||
),
|
||||
"edits": [
|
||||
{
|
||||
"old_string": 'import (\n "fmt"\n)',
|
||||
@@ -247,7 +261,9 @@ async def test_agent_critic_flow(test_project, mock_context):
|
||||
"code_snippets": [
|
||||
'import (\n "fmt"\n "github.com/project/common"\n)'
|
||||
],
|
||||
"file_paths": [str(test_project / "main.go")],
|
||||
"file_paths": [
|
||||
str(test_project / "main.go")
|
||||
],
|
||||
"specific_concerns": "Is the import in the correct format and position?",
|
||||
}
|
||||
),
|
||||
@@ -288,7 +304,9 @@ async def test_clarification_limits(test_project, mock_context):
|
||||
"""Test that clarification requests are limited."""
|
||||
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
|
||||
|
||||
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=5)
|
||||
agent = AgentTool(
|
||||
permission_manager=permission_manager, model="test-model", max_iterations=5
|
||||
)
|
||||
|
||||
# Test that only one clarification is allowed
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
@@ -367,7 +385,9 @@ async def test_critic_limits(test_project, mock_context):
|
||||
"""Test that critic reviews are limited."""
|
||||
permission_manager = PermissionManager(allowed_paths=[str(test_project)])
|
||||
|
||||
agent = AgentTool(permission_manager=permission_manager, model="test-model", max_iterations=6)
|
||||
agent = AgentTool(
|
||||
permission_manager=permission_manager, model="test-model", max_iterations=6
|
||||
)
|
||||
|
||||
# Test that only two critic reviews are allowed
|
||||
with patch("litellm.completion") as mock_completion:
|
||||
@@ -471,7 +491,11 @@ async def test_clarification_protocol_types():
|
||||
request_type=clarification_type,
|
||||
question=f"Test question for {clarification_type.value}",
|
||||
context=context,
|
||||
options=(["option1", "option2"] if clarification_type == ClarificationType.MULTIPLE_OPTIONS else None),
|
||||
options=(
|
||||
["option1", "option2"]
|
||||
if clarification_type == ClarificationType.MULTIPLE_OPTIONS
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
assert request_id.startswith("clarify_")
|
||||
|
||||
@@ -61,7 +61,14 @@ async def test_agent_uses_all_tools(test_project, mock_context):
|
||||
Mock(
|
||||
function=Mock(
|
||||
name="read",
|
||||
arguments=json.dumps({"file_path": str(test_project / "complex_algorithm.py")}),
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"file_path": str(
|
||||
test_project
|
||||
/ "complex_algorithm.py"
|
||||
)
|
||||
}
|
||||
),
|
||||
),
|
||||
id="call_1",
|
||||
)
|
||||
@@ -138,7 +145,10 @@ async def test_agent_uses_all_tools(test_project, mock_context):
|
||||
name="multi_edit",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"file_path": str(test_project / "complex_algorithm.py"),
|
||||
"file_path": str(
|
||||
test_project
|
||||
/ "complex_algorithm.py"
|
||||
),
|
||||
"edits": [
|
||||
{
|
||||
"old_string": """def find_optimal_path(graph, start, end):
|
||||
@@ -181,7 +191,12 @@ async def test_agent_uses_all_tools(test_project, mock_context):
|
||||
"code_snippets": [
|
||||
'def find_optimal_path(graph, start, end):\n """Find optimal path"""'
|
||||
],
|
||||
"file_paths": [str(test_project / "complex_algorithm.py")],
|
||||
"file_paths": [
|
||||
str(
|
||||
test_project
|
||||
/ "complex_algorithm.py"
|
||||
)
|
||||
],
|
||||
"context": "This needs to handle various graph types efficiently",
|
||||
}
|
||||
),
|
||||
@@ -207,8 +222,15 @@ async def test_agent_uses_all_tools(test_project, mock_context):
|
||||
{
|
||||
"review_type": "PERFORMANCE",
|
||||
"work_description": "Pathfinding algorithm implementation",
|
||||
"code_snippets": ["def find_optimal_path(graph, start, end):"],
|
||||
"file_paths": [str(test_project / "complex_algorithm.py")],
|
||||
"code_snippets": [
|
||||
"def find_optimal_path(graph, start, end):"
|
||||
],
|
||||
"file_paths": [
|
||||
str(
|
||||
test_project
|
||||
/ "complex_algorithm.py"
|
||||
)
|
||||
],
|
||||
"specific_concerns": "Will this scale to large graphs?",
|
||||
}
|
||||
),
|
||||
@@ -287,11 +309,17 @@ async def test_iching_tool_directly():
|
||||
|
||||
# Verify it selected relevant principles
|
||||
if "scale" in challenge.lower():
|
||||
assert any(word in result for word in ["Scalable", "Exponentiality", "Growth"])
|
||||
assert any(
|
||||
word in result for word in ["Scalable", "Exponentiality", "Growth"]
|
||||
)
|
||||
elif "refactor" in challenge.lower():
|
||||
assert any(word in result for word in ["Simplicity", "Clarity", "Composable"])
|
||||
assert any(
|
||||
word in result for word in ["Simplicity", "Clarity", "Composable"]
|
||||
)
|
||||
elif "team" in challenge.lower():
|
||||
assert any(word in result for word in ["Autonomy", "Balance", "Collaboration"])
|
||||
assert any(
|
||||
word in result for word in ["Autonomy", "Balance", "Collaboration"]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -304,7 +332,9 @@ async def test_review_vs_critic_difference():
|
||||
reviewer = ReviewProtocol()
|
||||
|
||||
work_description = "Implemented user authentication with JWT tokens"
|
||||
code_snippet = ["func authenticate(token string) (User, error) { return User{}, nil }"]
|
||||
code_snippet = [
|
||||
"func authenticate(token string) (User, error) { return User{}, nil }"
|
||||
]
|
||||
|
||||
# Get critic feedback (harsh)
|
||||
critic_feedback = critic.request_review(
|
||||
@@ -345,7 +375,9 @@ async def test_tool_limits():
|
||||
|
||||
# First should work
|
||||
try:
|
||||
await clarifier.request_clarification(ClarificationType.MISSING_CONTEXT, "Question 1", {})
|
||||
await clarifier.request_clarification(
|
||||
ClarificationType.MISSING_CONTEXT, "Question 1", {}
|
||||
)
|
||||
except AttributeError:
|
||||
# Handler doesn't have async request_clarification, that's ok
|
||||
pass
|
||||
|
||||
@@ -69,7 +69,9 @@ class TestUnifiedJupyterTool:
|
||||
ctx = MCPContext()
|
||||
|
||||
# Read entire notebook
|
||||
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(sample_notebook))
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="read", notebook_path=str(sample_notebook)
|
||||
)
|
||||
|
||||
assert "Notebook with 2 cells" in result
|
||||
assert "Hello, World!" in result
|
||||
@@ -77,7 +79,9 @@ class TestUnifiedJupyterTool:
|
||||
assert "[stdout]: Hello, World!" in result
|
||||
|
||||
# Read specific cell by index
|
||||
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(sample_notebook), cell_index=0)
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="read", notebook_path=str(sample_notebook), cell_index=0
|
||||
)
|
||||
|
||||
assert "Cell 0 (code)" in result
|
||||
assert "print('Hello, World!')" in result
|
||||
@@ -88,7 +92,9 @@ class TestUnifiedJupyterTool:
|
||||
ctx = MCPContext()
|
||||
new_notebook = tmp_path / "new.ipynb"
|
||||
|
||||
result = await jupyter_tool.call(ctx, action="create", notebook_path=str(new_notebook))
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="create", notebook_path=str(new_notebook)
|
||||
)
|
||||
|
||||
assert "Successfully created notebook" in result
|
||||
assert new_notebook.exists()
|
||||
@@ -182,7 +188,9 @@ class TestUnifiedJupyterTool:
|
||||
"""Test deleting entire notebook."""
|
||||
ctx = MCPContext()
|
||||
|
||||
result = await jupyter_tool.call(ctx, action="delete", notebook_path=str(sample_notebook))
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="delete", notebook_path=str(sample_notebook)
|
||||
)
|
||||
|
||||
assert "Successfully deleted notebook" in result
|
||||
assert not sample_notebook.exists()
|
||||
@@ -193,11 +201,15 @@ class TestUnifiedJupyterTool:
|
||||
ctx = MCPContext()
|
||||
|
||||
# Non-existent file
|
||||
result = await jupyter_tool.call(ctx, action="read", notebook_path=str(tmp_path / "nonexistent.ipynb"))
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="read", notebook_path=str(tmp_path / "nonexistent.ipynb")
|
||||
)
|
||||
assert "Error:" in result and "does not exist" in result
|
||||
|
||||
# Invalid action
|
||||
result = await jupyter_tool.call(ctx, action="invalid", notebook_path=str(tmp_path / "test.ipynb"))
|
||||
result = await jupyter_tool.call(
|
||||
ctx, action="invalid", notebook_path=str(tmp_path / "test.ipynb")
|
||||
)
|
||||
assert "Error: Unknown action" in result
|
||||
|
||||
# Missing required params for edit (need to test insert mode)
|
||||
|
||||
@@ -20,7 +20,9 @@ def test_no_pydantic_warnings():
|
||||
)
|
||||
|
||||
# Check that the command succeeded
|
||||
assert result.returncode == 0, f"Command failed with return code {result.returncode}"
|
||||
assert result.returncode == 0, (
|
||||
f"Command failed with return code {result.returncode}"
|
||||
)
|
||||
|
||||
# Check that help text is shown
|
||||
assert "MCP server implementing Hanzo AI capabilities" in result.stdout
|
||||
|
||||
@@ -51,7 +51,9 @@ async def test_basic_functionality():
|
||||
print(f"✅ Shell detected: {interpreter} (tool: {tool_name})")
|
||||
|
||||
# Test simple command
|
||||
result = await bash_tool.execute_sync("echo 'Hello from Hanzo AI!'", timeout=5)
|
||||
result = await bash_tool.execute_sync(
|
||||
"echo 'Hello from Hanzo AI!'", timeout=5
|
||||
)
|
||||
print(f"✅ Command result: {result.strip()}")
|
||||
except Exception as e:
|
||||
print(f"❌ Shell test failed: {e}")
|
||||
|
||||
@@ -17,7 +17,9 @@ except ImportError:
|
||||
Memory = None # type: ignore
|
||||
|
||||
# Skip entire module if hanzo_memory is not available
|
||||
pytestmark = pytest.mark.skipif(not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed")
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed"
|
||||
)
|
||||
|
||||
# Only import these if hanzo_memory is available
|
||||
if HANZO_MEMORY_AVAILABLE:
|
||||
@@ -60,7 +62,9 @@ class MemoryTestBase:
|
||||
@pytest.fixture
|
||||
def mock_memory_service(self):
|
||||
"""Create a mock memory service with standard responses."""
|
||||
with patch("hanzo_memory.services.memory.get_memory_service") as mock_get_service:
|
||||
with patch(
|
||||
"hanzo_memory.services.memory.get_memory_service"
|
||||
) as mock_get_service:
|
||||
mock_service = Mock()
|
||||
|
||||
# Set up standard responses
|
||||
@@ -73,7 +77,9 @@ class MemoryTestBase:
|
||||
importance=1.0,
|
||||
)
|
||||
|
||||
mock_service.update_memory.return_value = Mock(memory_id="mem_123", content="Updated memory")
|
||||
mock_service.update_memory.return_value = Mock(
|
||||
memory_id="mem_123", content="Updated memory"
|
||||
)
|
||||
|
||||
mock_service.delete_memory.return_value = None
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ from unittest.mock import AsyncMock, Mock, patch
|
||||
def test_basic_memory():
|
||||
"""Basic test without pytest complexity."""
|
||||
with patch("hanzo_memory.services.memory.get_memory_service") as mock_get_service:
|
||||
with patch("hanzo_tools.memory.memory_tools.create_tool_context") as mock_create_tool_context:
|
||||
with patch(
|
||||
"hanzo_tools.memory.memory_tools.create_tool_context"
|
||||
) as mock_create_tool_context:
|
||||
# Mock the tool context
|
||||
mock_tool_ctx = Mock()
|
||||
mock_tool_ctx.set_tool_info = AsyncMock()
|
||||
@@ -54,7 +56,9 @@ def test_basic_memory():
|
||||
)
|
||||
|
||||
print(f"Result: {result}")
|
||||
print(f"create_memory called: {mock_service.create_memory.call_count} times")
|
||||
print(
|
||||
f"create_memory called: {mock_service.create_memory.call_count} times"
|
||||
)
|
||||
|
||||
# Check result
|
||||
assert "Successfully created 2 new memories" in str(result)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Consolidated memory tests using parametrization to reduce redundancy."""
|
||||
|
||||
|
||||
import pytest
|
||||
|
||||
# Import guard for optional hanzo_memory dependency
|
||||
@@ -12,7 +11,9 @@ except ImportError:
|
||||
HANZO_MEMORY_AVAILABLE = False
|
||||
|
||||
# Skip entire module if hanzo_memory is not available
|
||||
pytestmark = pytest.mark.skipif(not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed")
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not HANZO_MEMORY_AVAILABLE, reason="hanzo_memory package not installed"
|
||||
)
|
||||
|
||||
# Only import these if hanzo_memory is available
|
||||
if HANZO_MEMORY_AVAILABLE:
|
||||
|
||||
@@ -19,7 +19,9 @@ def test_memory_registration():
|
||||
permission_manager = PermissionManager()
|
||||
permission_manager.add_allowed_path("/tmp")
|
||||
|
||||
tools = register_memory_tools(mcp_server, permission_manager, user_id="test_user", project_id="test_project")
|
||||
tools = register_memory_tools(
|
||||
mcp_server, permission_manager, user_id="test_user", project_id="test_project"
|
||||
)
|
||||
|
||||
assert len(tools) == 9
|
||||
print(f"Successfully registered {len(tools)} memory tools")
|
||||
|
||||
@@ -53,7 +53,9 @@ class TestGraphTools:
|
||||
"""Test graph database tools."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_add_node(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_add_node(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test adding a node to the graph."""
|
||||
tool = GraphAddTool(permission_manager, db_manager)
|
||||
|
||||
@@ -68,7 +70,9 @@ class TestGraphTools:
|
||||
tool_helper.assert_in_result("Successfully added node 'test_node'", result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_add_edge(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_add_edge(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test adding an edge to the graph."""
|
||||
tool = GraphAddTool(permission_manager, db_manager)
|
||||
|
||||
@@ -77,14 +81,18 @@ class TestGraphTools:
|
||||
await tool.call(mock_ctx, node_id="node2", node_type="file")
|
||||
|
||||
# Add edge
|
||||
result = await tool.call(mock_ctx, source="node1", target="node2", relationship="imports")
|
||||
result = await tool.call(
|
||||
mock_ctx, source="node1", target="node2", relationship="imports"
|
||||
)
|
||||
if isinstance(result, dict) and "output" in result:
|
||||
result = result["output"]
|
||||
|
||||
tool_helper.assert_in_result("Successfully added edge", result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_remove_node(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_remove_node(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test removing a node from the graph."""
|
||||
add_tool = GraphAddTool(permission_manager, db_manager)
|
||||
remove_tool = GraphRemoveTool(permission_manager, db_manager)
|
||||
@@ -98,7 +106,9 @@ class TestGraphTools:
|
||||
tool_helper.assert_in_result("Successfully removed node 'test_node'", result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_query_neighbors(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_query_neighbors(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test querying neighbors in the graph."""
|
||||
add_tool = GraphAddTool(permission_manager, db_manager)
|
||||
query_tool = GraphQueryTool(permission_manager, db_manager)
|
||||
@@ -118,7 +128,9 @@ class TestGraphTools:
|
||||
tool_helper.assert_in_result("C", result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_search(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_search(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test searching in the graph."""
|
||||
add_tool = GraphAddTool(permission_manager, db_manager)
|
||||
search_tool = GraphSearchTool(permission_manager, db_manager)
|
||||
@@ -137,9 +149,13 @@ class TestGraphTools:
|
||||
tool_helper.assert_in_result("Found", result)
|
||||
tool_helper.assert_in_result("main.py", result)
|
||||
|
||||
@pytest.mark.skip(reason="Graph stats test needs database isolation - stale data causes false failures")
|
||||
@pytest.mark.skip(
|
||||
reason="Graph stats test needs database isolation - stale data causes false failures"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_graph_stats(self, tool_helper, mock_ctx, permission_manager, db_manager):
|
||||
async def test_graph_stats(
|
||||
self, tool_helper, mock_ctx, permission_manager, db_manager
|
||||
):
|
||||
"""Test graph statistics."""
|
||||
add_tool = GraphAddTool(permission_manager, db_manager)
|
||||
stats_tool = GraphStatsTool(permission_manager, db_manager)
|
||||
@@ -184,7 +200,9 @@ class TestFindFilesTool:
|
||||
assert "data.txt" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_find_files_recursive(self, tool_helper, mock_ctx, permission_manager):
|
||||
async def test_find_files_recursive(
|
||||
self, tool_helper, mock_ctx, permission_manager
|
||||
):
|
||||
"""Test recursive file finding."""
|
||||
tool = FindFilesTool(permission_manager)
|
||||
|
||||
@@ -198,7 +216,9 @@ class TestFindFilesTool:
|
||||
permission_manager.add_allowed_path(tmpdir)
|
||||
|
||||
# Find all txt files
|
||||
result = await tool.call(mock_ctx, pattern="*.txt", path=tmpdir, recursive=True)
|
||||
result = await tool.call(
|
||||
mock_ctx, pattern="*.txt", path=tmpdir, recursive=True
|
||||
)
|
||||
if isinstance(result, dict) and "output" in result:
|
||||
result = result["output"]
|
||||
|
||||
@@ -216,7 +236,9 @@ class TestPackageRunnerTools:
|
||||
tool = UvxTool(permission_manager)
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="Package output", stderr="", returncode=0)
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="Package output", stderr="", returncode=0
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/uvx"):
|
||||
result = await tool.call(mock_ctx, package="ruff", args="check .")
|
||||
@@ -232,7 +254,9 @@ class TestPackageRunnerTools:
|
||||
tool = NpxTool(permission_manager)
|
||||
|
||||
with patch("subprocess.run") as mock_run:
|
||||
mock_run.return_value = MagicMock(stdout="Package output", stderr="", returncode=0)
|
||||
mock_run.return_value = MagicMock(
|
||||
stdout="Package output", stderr="", returncode=0
|
||||
)
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/npx"):
|
||||
result = await tool.call(mock_ctx, package="eslint", args="--version")
|
||||
@@ -254,7 +278,9 @@ class TestPackageRunnerTools:
|
||||
mock_popen.return_value = mock_process
|
||||
|
||||
with patch("shutil.which", return_value="/usr/bin/uvx"):
|
||||
result = await tool.call(mock_ctx, package="streamlit", args="run app.py", name="test-app")
|
||||
result = await tool.call(
|
||||
mock_ctx, package="streamlit", args="run app.py", name="test-app"
|
||||
)
|
||||
if isinstance(result, dict) and "output" in result:
|
||||
result = result["output"]
|
||||
|
||||
@@ -266,17 +292,23 @@ class TestPackageRunnerTools:
|
||||
class TestMcpManagementTools:
|
||||
"""Test MCP management tools."""
|
||||
|
||||
@pytest.mark.skip(reason="McpAddTool test modifies user's MCP config - run manually")
|
||||
@pytest.mark.skip(
|
||||
reason="McpAddTool test modifies user's MCP config - run manually"
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_add(self, tool_helper, mock_ctx):
|
||||
"""Test adding an MCP server."""
|
||||
tool = McpAddTool()
|
||||
|
||||
result = await tool.call(mock_ctx, command="uvx mcp-server-git", name="git-server")
|
||||
result = await tool.call(
|
||||
mock_ctx, command="uvx mcp-server-git", name="git-server"
|
||||
)
|
||||
if isinstance(result, dict) and "output" in result:
|
||||
result = result["output"]
|
||||
|
||||
tool_helper.assert_in_result("Successfully added MCP server 'git-server'", result)
|
||||
tool_helper.assert_in_result(
|
||||
"Successfully added MCP server 'git-server'", result
|
||||
)
|
||||
|
||||
# Check it was saved
|
||||
servers = McpAddTool.get_servers()
|
||||
@@ -293,7 +325,9 @@ class TestMcpManagementTools:
|
||||
remove_tool = McpRemoveTool()
|
||||
result = await remove_tool.call(mock_ctx, name="test-server")
|
||||
|
||||
tool_helper.assert_in_result("Successfully removed MCP server 'test-server'", result)
|
||||
tool_helper.assert_in_result(
|
||||
"Successfully removed MCP server 'test-server'", result
|
||||
)
|
||||
|
||||
# Check it was removed
|
||||
servers = McpAddTool.get_servers()
|
||||
@@ -340,7 +374,9 @@ class TestSystemTools:
|
||||
with patch("psutil.cpu_percent", return_value=50.0):
|
||||
with patch("psutil.cpu_count", return_value=4):
|
||||
with patch("psutil.virtual_memory") as mock_mem:
|
||||
mock_mem.return_value = MagicMock(total=8 * 1024**3, used=4 * 1024**3, percent=50.0)
|
||||
mock_mem.return_value = MagicMock(
|
||||
total=8 * 1024**3, used=4 * 1024**3, percent=50.0
|
||||
)
|
||||
|
||||
with patch("psutil.disk_usage") as mock_disk:
|
||||
mock_disk.return_value = MagicMock(
|
||||
@@ -367,14 +403,18 @@ class TestSystemTools:
|
||||
|
||||
# Disable a tool
|
||||
result = await disable_tool.call(mock_ctx, tool="vector_search")
|
||||
tool_helper.assert_in_result("Successfully disabled tool 'vector_search'", result)
|
||||
tool_helper.assert_in_result(
|
||||
"Successfully disabled tool 'vector_search'", result
|
||||
)
|
||||
|
||||
# Check it's disabled
|
||||
assert not ToolEnableTool.is_tool_enabled("vector_search")
|
||||
|
||||
# Re-enable it
|
||||
result = await enable_tool.call(mock_ctx, tool="vector_search")
|
||||
tool_helper.assert_in_result("Successfully enabled tool 'vector_search'", result)
|
||||
tool_helper.assert_in_result(
|
||||
"Successfully enabled tool 'vector_search'", result
|
||||
)
|
||||
|
||||
# Check it's enabled
|
||||
assert ToolEnableTool.is_tool_enabled("vector_search")
|
||||
|
||||
@@ -43,7 +43,9 @@ class StubDetector(ast.NodeVisitor):
|
||||
|
||||
# Check for empty functions with just pass (but allow if has docstring)
|
||||
if len(node.body) == 1 and isinstance(node.body[0], ast.Pass):
|
||||
self.issues.append((node.lineno, f"Function '{node.name}' contains only 'pass' statement"))
|
||||
self.issues.append(
|
||||
(node.lineno, f"Function '{node.name}' contains only 'pass' statement")
|
||||
)
|
||||
elif len(node.body) == 2:
|
||||
# Check for docstring + pass (also a stub)
|
||||
if (
|
||||
@@ -59,7 +61,10 @@ class StubDetector(ast.NodeVisitor):
|
||||
# But allow if it's an abstract method (has docstring explaining it)
|
||||
if len(node.body) == 1 and isinstance(node.body[0], ast.Raise):
|
||||
if isinstance(node.body[0].exc, ast.Call):
|
||||
if hasattr(node.body[0].exc.func, "id") and node.body[0].exc.func.id == "NotImplementedError":
|
||||
if (
|
||||
hasattr(node.body[0].exc.func, "id")
|
||||
and node.body[0].exc.func.id == "NotImplementedError"
|
||||
):
|
||||
# This is a legitimate abstract method pattern, skip it
|
||||
pass
|
||||
elif len(node.body) == 2:
|
||||
@@ -77,7 +82,9 @@ class StubDetector(ast.NodeVisitor):
|
||||
if len(node.body) == 1 and isinstance(node.body[0], ast.Expr):
|
||||
if isinstance(node.body[0].value, ast.Constant):
|
||||
if node.body[0].value.value is Ellipsis:
|
||||
self.issues.append((node.lineno, f"Function '{node.name}' contains only ellipsis"))
|
||||
self.issues.append(
|
||||
(node.lineno, f"Function '{node.name}' contains only ellipsis")
|
||||
)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
@@ -104,8 +111,16 @@ def find_stub_patterns(filepath: Path) -> List[Tuple[int, str, str]]:
|
||||
# Format: (pattern, message, case_insensitive)
|
||||
patterns = [
|
||||
# Match TODO/FIXME comments (case insensitive for comments)
|
||||
(r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:", "contains {0} comment", True),
|
||||
(r'assert\s+False,?\s*["\']Not implemented', 'has "Not implemented" assertion', True),
|
||||
(
|
||||
r"#\s*(TODO|FIXME|STUB|FAKE|UNFINISHED|HACK|XXX)\s*:",
|
||||
"contains {0} comment",
|
||||
True,
|
||||
),
|
||||
(
|
||||
r'assert\s+False,?\s*["\']Not implemented',
|
||||
'has "Not implemented" assertion',
|
||||
True,
|
||||
),
|
||||
]
|
||||
|
||||
# Additional patterns for non-test files
|
||||
@@ -116,7 +131,11 @@ def find_stub_patterns(filepath: Path) -> List[Tuple[int, str, str]]:
|
||||
# Only match uppercase "TODO"/"STUB" strings (case sensitive to avoid "todo" tool name)
|
||||
(r'return\s+["\']TODO["\']', "returns TODO string", False),
|
||||
(r'return\s+["\']STUB["\']', "returns STUB string", False),
|
||||
(r"return\s+None\s*#\s*(STUB|FAKE)", "returns None with stub comment", True),
|
||||
(
|
||||
r"return\s+None\s*#\s*(STUB|FAKE)",
|
||||
"returns None with stub comment",
|
||||
True,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -185,11 +204,15 @@ class TestNoStubs:
|
||||
for filepath in python_files:
|
||||
issues = find_stub_patterns(filepath)
|
||||
for line_num, message, _filename in issues:
|
||||
all_issues.append(f"{filepath.relative_to(package_root.parent)}:{line_num} - {message}")
|
||||
all_issues.append(
|
||||
f"{filepath.relative_to(package_root.parent)}:{line_num} - {message}"
|
||||
)
|
||||
|
||||
if all_issues:
|
||||
report = "\n".join(all_issues)
|
||||
pytest.fail(f"Found {len(all_issues)} stub/incomplete implementations:\n{report}")
|
||||
pytest.fail(
|
||||
f"Found {len(all_issues)} stub/incomplete implementations:\n{report}"
|
||||
)
|
||||
|
||||
def test_critical_functions_implemented(self):
|
||||
"""Ensure critical functions are actually implemented."""
|
||||
@@ -227,12 +250,23 @@ class TestNoStubs:
|
||||
if len(node.body) == 1:
|
||||
body = node.body[0]
|
||||
if isinstance(body, ast.Pass):
|
||||
pytest.fail(f"Function {function_name} in {module_path} contains only 'pass'")
|
||||
if isinstance(body, ast.Expr) and isinstance(body.value, ast.Constant):
|
||||
pytest.fail(
|
||||
f"Function {function_name} in {module_path} contains only 'pass'"
|
||||
)
|
||||
if isinstance(body, ast.Expr) and isinstance(
|
||||
body.value, ast.Constant
|
||||
):
|
||||
if body.value.value is Ellipsis:
|
||||
pytest.fail(f"Function {function_name} in {module_path} contains only '...'")
|
||||
if isinstance(body, ast.Raise) and isinstance(body.exc, ast.Call):
|
||||
if hasattr(body.exc.func, "id") and body.exc.func.id == "NotImplementedError":
|
||||
pytest.fail(
|
||||
f"Function {function_name} in {module_path} contains only '...'"
|
||||
)
|
||||
if isinstance(body, ast.Raise) and isinstance(
|
||||
body.exc, ast.Call
|
||||
):
|
||||
if (
|
||||
hasattr(body.exc.func, "id")
|
||||
and body.exc.func.id == "NotImplementedError"
|
||||
):
|
||||
pytest.fail(
|
||||
f"Function {function_name} in {module_path} raises NotImplementedError"
|
||||
)
|
||||
@@ -279,7 +313,9 @@ class TestNoStubs:
|
||||
|
||||
for pattern in mock_patterns:
|
||||
if re.search(pattern, content, re.IGNORECASE):
|
||||
pytest.fail(f"Found mock/fake pattern '{pattern}' in production file: {filepath}")
|
||||
pytest.fail(
|
||||
f"Found mock/fake pattern '{pattern}' in production file: {filepath}"
|
||||
)
|
||||
|
||||
def test_all_tool_classes_have_run_method(self):
|
||||
"""Ensure all tool classes have a proper run or call method."""
|
||||
@@ -327,7 +363,10 @@ class TestNoStubs:
|
||||
# Also check for method aliases like "call = execute"
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name) and target.id in ("run", "call"):
|
||||
if isinstance(target, ast.Name) and target.id in (
|
||||
"run",
|
||||
"call",
|
||||
):
|
||||
has_run_or_call = True
|
||||
break
|
||||
|
||||
@@ -344,15 +383,23 @@ class TestNoStubs:
|
||||
elif isinstance(base, ast.Attribute):
|
||||
base_name = base.attr
|
||||
# If any base ends with Tool, Base, Mixin it's likely OK
|
||||
if any(base_name.endswith(suffix) for suffix in ("Tool", "Base", "Mixin")):
|
||||
if any(
|
||||
base_name.endswith(suffix)
|
||||
for suffix in ("Tool", "Base", "Mixin")
|
||||
):
|
||||
inherits_from_something_meaningful = True
|
||||
break
|
||||
else:
|
||||
# No bases - this is a standalone Tool class that needs run/call
|
||||
issues.append(f"Tool class {class_name} in {filepath.name} missing run() or call() method")
|
||||
issues.append(
|
||||
f"Tool class {class_name} in {filepath.name} missing run() or call() method"
|
||||
)
|
||||
|
||||
if issues:
|
||||
pytest.fail(f"Found {len(issues)} tool classes without run()/call() method:\n" + "\n".join(issues[:10]))
|
||||
pytest.fail(
|
||||
f"Found {len(issues)} tool classes without run()/call() method:\n"
|
||||
+ "\n".join(issues[:10])
|
||||
)
|
||||
|
||||
def test_no_debug_prints_in_production(self):
|
||||
"""Ensure no debug print statements in production code."""
|
||||
|
||||
@@ -75,7 +75,9 @@ class TestMemoryPerformance:
|
||||
return Mock(memory_id=f"mem_{len(operation_times)}")
|
||||
|
||||
mock_service.create_memory = Mock(side_effect=mock_operation)
|
||||
mock_service.search_memories = Mock(side_effect=lambda *a, **k: asyncio.create_task(mock_operation(*a, **k)))
|
||||
mock_service.search_memories = Mock(
|
||||
side_effect=lambda *a, **k: asyncio.create_task(mock_operation(*a, **k))
|
||||
)
|
||||
mock_get_service.return_value = mock_service
|
||||
|
||||
async def run_concurrent_operations():
|
||||
@@ -102,7 +104,9 @@ class TestMemoryPerformance:
|
||||
sequential_time = sum(op_times)
|
||||
assert total_time < sequential_time * 0.5 # At least 2x speedup
|
||||
|
||||
print(f"Concurrent: {total_time:.2f}s, Sequential would be: {sequential_time:.2f}s")
|
||||
print(
|
||||
f"Concurrent: {total_time:.2f}s, Sequential would be: {sequential_time:.2f}s"
|
||||
)
|
||||
|
||||
|
||||
class TestSearchPerformance:
|
||||
@@ -136,7 +140,11 @@ class TestSearchPerformance:
|
||||
|
||||
# Search for pattern
|
||||
start_time = time.time()
|
||||
result = asyncio.run(tool.call(mock_ctx, pattern="SPECIAL_PATTERN", path=tmpdir, max_results=50))
|
||||
result = asyncio.run(
|
||||
tool.call(
|
||||
mock_ctx, pattern="SPECIAL_PATTERN", path=tmpdir, max_results=50
|
||||
)
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should find matches quickly
|
||||
@@ -177,7 +185,11 @@ class TestBatchToolPerformance:
|
||||
|
||||
# Process batch
|
||||
start_time = time.time()
|
||||
result = asyncio.run(batch_tool.call(mock_ctx, description="Large batch test", invocations=invocations))
|
||||
result = asyncio.run(
|
||||
batch_tool.call(
|
||||
mock_ctx, description="Large batch test", invocations=invocations
|
||||
)
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should process efficiently
|
||||
@@ -193,7 +205,9 @@ class TestBatchToolPerformance:
|
||||
class TestSwarmPerformance:
|
||||
"""Performance tests for swarm tool."""
|
||||
|
||||
@pytest.mark.skip(reason="Test uses deprecated dispatch_to_model API - swarm now uses hanzo-agents SDK")
|
||||
@pytest.mark.skip(
|
||||
reason="Test uses deprecated dispatch_to_model API - swarm now uses hanzo-agents SDK"
|
||||
)
|
||||
def test_parallel_agent_execution(self):
|
||||
"""Test parallel execution of swarm agents."""
|
||||
execution_times = []
|
||||
@@ -226,7 +240,9 @@ class TestSwarmPerformance:
|
||||
|
||||
# Execute swarm
|
||||
start_time = time.time()
|
||||
asyncio.run(tool.call(mock_ctx, query="Parallel processing test", agents=agents))
|
||||
asyncio.run(
|
||||
tool.call(mock_ctx, query="Parallel processing test", agents=agents)
|
||||
)
|
||||
elapsed = time.time() - start_time
|
||||
|
||||
# Should execute workers in parallel
|
||||
@@ -329,7 +345,9 @@ class TestMemoryStressTest:
|
||||
assert len(operations) > 30 # At least the creates
|
||||
assert elapsed < 5.0 # Should complete reasonably fast
|
||||
|
||||
print(f"Completed {len(operations)} operations under load in {elapsed:.2f} seconds")
|
||||
print(
|
||||
f"Completed {len(operations)} operations under load in {elapsed:.2f} seconds"
|
||||
)
|
||||
|
||||
|
||||
class TestConcurrentFileOperations:
|
||||
@@ -388,7 +406,9 @@ class TestConcurrentFileOperations:
|
||||
assert len(errors) == 0
|
||||
assert elapsed < 2.0 # Should be fast even with 20 operations
|
||||
|
||||
print(f"Completed {len(results)} concurrent file operations in {elapsed:.2f} seconds")
|
||||
print(
|
||||
f"Completed {len(results)} concurrent file operations in {elapsed:.2f} seconds"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user