mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(hanzo-tools): complete modular tool packages v0.10.7
- Add hanzo-tools-llm (4 tools: llm, unified_llm, consensus, llm_manage) - Add hanzo-tools-vector (3 tools: index, vector_index, vector_search) - Add hanzo-tools-config (2 tools: config, mode) - Add hanzo-tools-mcp (4 tools: mcp, mcp_add, mcp_remove, mcp_stats) - Update hanzo-tools meta package with all 17 packages - Bump hanzo-mcp to v0.10.7 Total: 63 tools across 17 modular packages Bundle extras: - [core]: filesystem, shell, todo, reasoning, config - [dev]: core + editor, lsp, refactor - [ai]: llm, agent, memory - [all]: everything
This commit is contained in:
@@ -26,4 +26,4 @@ if os.environ.get("HANZO_MCP_TRANSPORT") == "stdio":
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
__version__ = "0.9.24"
|
||||
__version__ = "0.10.7"
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.10.6"
|
||||
version = "0.10.7"
|
||||
description = "The Zen of Hanzo MCP: One server to rule them all. The ultimate MCP that orchestrates all others."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
Generated
+1
-1
@@ -1271,7 +1271,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "hanzo-mcp"
|
||||
version = "0.10.4"
|
||||
version = "0.10.6"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
import pkgutil
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Configuration tools for Hanzo AI.
|
||||
|
||||
Tools:
|
||||
- config: Configuration management
|
||||
- mode: Development mode switching
|
||||
|
||||
Install:
|
||||
pip install hanzo-tools-config
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_tools = []
|
||||
|
||||
try:
|
||||
from .config_tool import ConfigTool
|
||||
_tools.append(ConfigTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ConfigTool not available: {e}")
|
||||
ConfigTool = None
|
||||
|
||||
try:
|
||||
from .mode_tool import ModeTool
|
||||
_tools.append(ModeTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ModeTool not available: {e}")
|
||||
ModeTool = None
|
||||
|
||||
TOOLS = _tools
|
||||
|
||||
__all__ = [
|
||||
"TOOLS",
|
||||
"ConfigTool",
|
||||
"ModeTool",
|
||||
"register_tools",
|
||||
]
|
||||
|
||||
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register config tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
return registered
|
||||
@@ -0,0 +1,333 @@
|
||||
"""Configuration tool for Hanzo AI.
|
||||
|
||||
Git-style config tool for managing settings.
|
||||
"""
|
||||
|
||||
from typing import Unpack, Optional, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_mcp.config import load_settings, save_settings
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import PermissionManager
|
||||
from hanzo_tools.core import auto_timeout
|
||||
from .index_config import IndexScope, IndexConfig
|
||||
|
||||
# Parameter types
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action: get (default), set, list, toggle",
|
||||
default="get",
|
||||
),
|
||||
]
|
||||
|
||||
Key = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Configuration key (e.g., tools.write.enabled, enabled_tools.write, index.scope)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Value = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Configuration value",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Scope = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Config scope: local (project) or global",
|
||||
default="local",
|
||||
),
|
||||
]
|
||||
|
||||
ConfigPath = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Path for project-specific config",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ConfigParams(TypedDict, total=False):
|
||||
"""Parameters for config tool."""
|
||||
|
||||
action: str
|
||||
key: Optional[str]
|
||||
value: Optional[str]
|
||||
scope: str
|
||||
path: Optional[str]
|
||||
|
||||
|
||||
def _parse_bool(value: str) -> Optional[bool]:
|
||||
v = value.strip().lower()
|
||||
if v in {"true", "1", "yes", "on"}:
|
||||
return True
|
||||
if v in {"false", "0", "no", "off"}:
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
@final
|
||||
class ConfigTool(BaseTool):
|
||||
"""Git-style configuration management tool."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager):
|
||||
"""Initialize config tool."""
|
||||
super().__init__(permission_manager)
|
||||
self.index_config = IndexConfig()
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "config"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Git-style configuration. Actions: get (default), set, list, toggle.
|
||||
|
||||
Usage:
|
||||
config index.scope
|
||||
config --action set index.scope project
|
||||
config --action set tools.write.enabled false
|
||||
config --action list
|
||||
config --action toggle index.scope --path ./project"""
|
||||
|
||||
@override
|
||||
@auto_timeout("config")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[ConfigParams],
|
||||
) -> str:
|
||||
"""Execute config operation."""
|
||||
tool_ctx = self.create_tool_context(ctx)
|
||||
|
||||
# Extract parameters
|
||||
action = params.get("action", "get")
|
||||
key = params.get("key")
|
||||
value = params.get("value")
|
||||
scope = params.get("scope", "local")
|
||||
path = params.get("path")
|
||||
|
||||
# Route to handler
|
||||
if action == "get":
|
||||
return await self._handle_get(key, scope, path, tool_ctx)
|
||||
elif action == "set":
|
||||
return await self._handle_set(key, value, scope, path, tool_ctx)
|
||||
elif action == "list":
|
||||
return await self._handle_list(scope, path, tool_ctx)
|
||||
elif action == "toggle":
|
||||
return await self._handle_toggle(key, scope, path, tool_ctx)
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: get, set, list, toggle"
|
||||
|
||||
async def _handle_get(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
|
||||
"""Get configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for get action"
|
||||
|
||||
# Handle index scope
|
||||
if key == "index.scope":
|
||||
current_scope = self.index_config.get_scope(path)
|
||||
return f"index.scope={current_scope.value}"
|
||||
|
||||
# tools.<name>.enabled → enabled_tools lookup
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
return f"{key}={settings.is_tool_enabled(tool_name)}"
|
||||
|
||||
# enabled_tools.<name>
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
val = settings.enabled_tools.get(tool_name)
|
||||
return f"{key}={val if val is not None else 'unset'}"
|
||||
|
||||
# Indexing (legacy) per-tool setting: <tool>.enabled
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
enabled = self.index_config.is_indexing_enabled(tool)
|
||||
return f"{key}={enabled}"
|
||||
|
||||
return f"Unknown key: {key}"
|
||||
|
||||
def _save_project_settings(self, settings, project_dir: Optional[str]) -> Path:
|
||||
"""Save to project config if path provided; else global."""
|
||||
if project_dir:
|
||||
project_path = Path(project_dir)
|
||||
project_path.mkdir(parents=True, exist_ok=True)
|
||||
cfg = project_path / ".hanzo-mcp.json"
|
||||
cfg.write_text(
|
||||
__import__("json").dumps(settings.__dict__ if hasattr(settings, "__dict__") else {}, indent=2)
|
||||
)
|
||||
return cfg
|
||||
# Fallback to global handler
|
||||
return save_settings(settings, global_config=True)
|
||||
|
||||
async def _handle_set(
|
||||
self,
|
||||
key: Optional[str],
|
||||
value: Optional[str],
|
||||
scope: str,
|
||||
path: Optional[str],
|
||||
tool_ctx,
|
||||
) -> str:
|
||||
"""Set configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for set action"
|
||||
if value is None:
|
||||
return "Error: value required for set action"
|
||||
|
||||
# Handle index scope
|
||||
if key == "index.scope":
|
||||
try:
|
||||
new_scope = IndexScope(value)
|
||||
self.index_config.set_scope(new_scope, path if scope == "local" else None)
|
||||
return f"Set {key}={value} ({'project' if path else 'global'})"
|
||||
except ValueError:
|
||||
return f"Error: Invalid scope value '{value}'. Valid: project, global, auto"
|
||||
|
||||
# tools.<name>.enabled → enabled_tools mapping
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
et = dict(settings.enabled_tools)
|
||||
et[tool_name] = parsed
|
||||
settings.enabled_tools = et
|
||||
# Save
|
||||
if scope == "local" and path:
|
||||
# Write a project .hanzo-mcp.json adjacent to the path
|
||||
# Note: save_settings(local) saves to CWD; we target specific path here
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Set {key}={parsed} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Set {key}={parsed} (global: {out})"
|
||||
|
||||
# enabled_tools.<name>
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
et = dict(settings.enabled_tools)
|
||||
et[tool_name] = parsed
|
||||
settings.enabled_tools = et
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Set {key}={parsed} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Set {key}={parsed} (global: {out})"
|
||||
|
||||
# Indexing (legacy) per-tool setting: <tool>.enabled (search indexers)
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
parsed = _parse_bool(value)
|
||||
if parsed is None:
|
||||
return "Error: value must be boolean (true/false)"
|
||||
self.index_config.set_indexing_enabled(tool, parsed)
|
||||
return f"Set {key}={parsed}"
|
||||
|
||||
return f"Unknown key: {key}"
|
||||
|
||||
async def _handle_list(self, scope: str, path: Optional[str], tool_ctx) -> str:
|
||||
"""List all configuration."""
|
||||
status = self.index_config.get_status()
|
||||
|
||||
output = ["=== Configuration ==="]
|
||||
output.append(f"\nDefault scope: {status['default_scope']}")
|
||||
|
||||
if path:
|
||||
current_scope = self.index_config.get_scope(path)
|
||||
output.append(f"Current path scope: {current_scope.value}")
|
||||
|
||||
output.append(f"\nProjects with custom config: {status['project_count']}")
|
||||
|
||||
output.append("\nTool settings (indexing):")
|
||||
for tool, settings in status["tools"].items():
|
||||
output.append(f" {tool}:")
|
||||
output.append(f" enabled: {settings['enabled']}")
|
||||
output.append(f" per_project: {settings['per_project']}")
|
||||
|
||||
# Also show enabled_tools snapshot
|
||||
settings_snapshot = load_settings(project_dir=path if scope == "local" else None)
|
||||
output.append("\nEnabled tools (execution):")
|
||||
for tool_name, enabled in sorted(settings_snapshot.enabled_tools.items()):
|
||||
output.append(f" {tool_name}: {enabled}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
async def _handle_toggle(self, key: Optional[str], scope: str, path: Optional[str], tool_ctx) -> str:
|
||||
"""Toggle configuration value."""
|
||||
if not key:
|
||||
return "Error: key required for toggle action"
|
||||
|
||||
# Handle index scope toggle
|
||||
if key == "index.scope":
|
||||
new_scope = self.index_config.toggle_scope(path if scope == "local" else None)
|
||||
return f"Toggled index.scope to {new_scope.value}"
|
||||
|
||||
# Handle execution tool enable/disable: tools.<name>.enabled or enabled_tools.<name>
|
||||
if key.startswith("tools.") and key.endswith(".enabled"):
|
||||
parts = key.split(".")
|
||||
if len(parts) == 3:
|
||||
tool_name = parts[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
current = bool(settings.enabled_tools.get(tool_name, True))
|
||||
settings.enabled_tools[tool_name] = not current
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Toggled {key} to {not current} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Toggled {key} to {not current} (global: {out})"
|
||||
|
||||
if key.startswith("enabled_tools."):
|
||||
tool_name = key.split(".", 1)[1]
|
||||
settings = load_settings(project_dir=path if scope == "local" else None)
|
||||
current = bool(settings.enabled_tools.get(tool_name, True))
|
||||
settings.enabled_tools[tool_name] = not current
|
||||
if scope == "local" and path:
|
||||
out = self._save_project_settings(settings, path)
|
||||
return f"Toggled {key} to {not current} (project: {out})"
|
||||
else:
|
||||
out = save_settings(settings, global_config=True)
|
||||
return f"Toggled {key} to {not current} (global: {out})"
|
||||
|
||||
# Handle indexing toggles (legacy)
|
||||
if "." in key:
|
||||
tool, setting = key.split(".", 1)
|
||||
if setting == "enabled":
|
||||
current = self.index_config.is_indexing_enabled(tool)
|
||||
self.index_config.set_indexing_enabled(tool, not current)
|
||||
return f"Toggled {key} to {not current}"
|
||||
|
||||
return f"Cannot toggle key: {key}"
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,177 @@
|
||||
"""Index configuration for per-project vs global indexing.
|
||||
|
||||
This module manages indexing configuration for different scopes.
|
||||
"""
|
||||
|
||||
import json
|
||||
from enum import Enum
|
||||
from typing import Any, Dict, Optional
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class IndexScope(Enum):
|
||||
"""Indexing scope options."""
|
||||
|
||||
PROJECT = "project" # Per-project indexing
|
||||
GLOBAL = "global" # Global indexing
|
||||
AUTO = "auto" # Auto-detect based on git root
|
||||
|
||||
|
||||
class IndexConfig:
|
||||
"""Manages indexing configuration."""
|
||||
|
||||
def __init__(self, config_dir: Optional[Path] = None):
|
||||
"""Initialize index configuration."""
|
||||
self.config_dir = config_dir or Path.home() / ".hanzo" / "mcp"
|
||||
self.config_file = self.config_dir / "index_config.json"
|
||||
self._config = self._load_config()
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from disk."""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default configuration
|
||||
return {
|
||||
"default_scope": IndexScope.AUTO.value,
|
||||
"project_configs": {},
|
||||
"global_index_paths": [],
|
||||
"index_settings": {
|
||||
"vector": {
|
||||
"enabled": True,
|
||||
"auto_index": True,
|
||||
"include_git_history": True,
|
||||
},
|
||||
"symbols": {
|
||||
"enabled": True,
|
||||
"auto_index": False,
|
||||
},
|
||||
"sql": {
|
||||
"enabled": True,
|
||||
"per_project": True,
|
||||
},
|
||||
"graph": {
|
||||
"enabled": True,
|
||||
"per_project": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
def save_config(self) -> None:
|
||||
"""Save configuration to disk."""
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(self._config, f, indent=2)
|
||||
|
||||
def get_scope(self, path: Optional[str] = None) -> IndexScope:
|
||||
"""Get indexing scope for a path."""
|
||||
if not path:
|
||||
return IndexScope(self._config["default_scope"])
|
||||
|
||||
# Check project-specific config
|
||||
project_root = self._find_project_root(path)
|
||||
if project_root:
|
||||
project_config = self._config["project_configs"].get(str(project_root))
|
||||
if project_config and "scope" in project_config:
|
||||
return IndexScope(project_config["scope"])
|
||||
|
||||
# Use default
|
||||
scope = IndexScope(self._config["default_scope"])
|
||||
|
||||
# Handle auto mode
|
||||
if scope == IndexScope.AUTO:
|
||||
if project_root:
|
||||
return IndexScope.PROJECT
|
||||
else:
|
||||
return IndexScope.GLOBAL
|
||||
|
||||
return scope
|
||||
|
||||
def set_scope(self, scope: IndexScope, path: Optional[str] = None) -> None:
|
||||
"""Set indexing scope."""
|
||||
if path:
|
||||
# Set for specific project
|
||||
project_root = self._find_project_root(path)
|
||||
if project_root:
|
||||
if str(project_root) not in self._config["project_configs"]:
|
||||
self._config["project_configs"][str(project_root)] = {}
|
||||
self._config["project_configs"][str(project_root)]["scope"] = scope.value
|
||||
else:
|
||||
# Set global default
|
||||
self._config["default_scope"] = scope.value
|
||||
|
||||
self.save_config()
|
||||
|
||||
def get_index_path(self, tool: str, path: Optional[str] = None) -> Path:
|
||||
"""Get index path for a tool and location."""
|
||||
scope = self.get_scope(path)
|
||||
|
||||
if scope == IndexScope.PROJECT and path:
|
||||
project_root = self._find_project_root(path)
|
||||
if project_root:
|
||||
return Path(project_root) / ".hanzo" / "index" / tool
|
||||
|
||||
# Global index
|
||||
return self.config_dir / "index" / tool
|
||||
|
||||
def is_indexing_enabled(self, tool: str) -> bool:
|
||||
"""Check if indexing is enabled for a tool."""
|
||||
return self._config["index_settings"].get(tool, {}).get("enabled", True)
|
||||
|
||||
def set_indexing_enabled(self, tool: str, enabled: bool) -> None:
|
||||
"""Enable/disable indexing for a tool."""
|
||||
if tool not in self._config["index_settings"]:
|
||||
self._config["index_settings"][tool] = {}
|
||||
self._config["index_settings"][tool]["enabled"] = enabled
|
||||
self.save_config()
|
||||
|
||||
def toggle_scope(self, path: Optional[str] = None) -> IndexScope:
|
||||
"""Toggle between project and global scope."""
|
||||
current = self.get_scope(path)
|
||||
|
||||
if current == IndexScope.PROJECT:
|
||||
new_scope = IndexScope.GLOBAL
|
||||
elif current == IndexScope.GLOBAL:
|
||||
new_scope = IndexScope.PROJECT
|
||||
else: # AUTO
|
||||
# Determine what auto resolves to and toggle
|
||||
if path and self._find_project_root(path):
|
||||
new_scope = IndexScope.GLOBAL
|
||||
else:
|
||||
new_scope = IndexScope.PROJECT
|
||||
|
||||
self.set_scope(new_scope, path)
|
||||
return new_scope
|
||||
|
||||
def _find_project_root(self, path: str) -> Optional[Path]:
|
||||
"""Find project root (git root or similar)."""
|
||||
current = Path(path).resolve()
|
||||
|
||||
# Walk up looking for markers
|
||||
markers = [".git", ".hg", "pyproject.toml", "package.json", "Cargo.toml"]
|
||||
|
||||
while current != current.parent:
|
||||
for marker in markers:
|
||||
if (current / marker).exists():
|
||||
return current
|
||||
current = current.parent
|
||||
|
||||
return None
|
||||
|
||||
def get_status(self) -> Dict[str, Any]:
|
||||
"""Get current configuration status."""
|
||||
return {
|
||||
"default_scope": self._config["default_scope"],
|
||||
"project_count": len(self._config["project_configs"]),
|
||||
"tools": {
|
||||
tool: {
|
||||
"enabled": settings.get("enabled", True),
|
||||
"per_project": settings.get("per_project", True),
|
||||
}
|
||||
for tool, settings in self._config["index_settings"].items()
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Tool for managing development modes with programmer personalities."""
|
||||
|
||||
from typing import Optional, override
|
||||
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_mcp.tools.common.mode import ModeRegistry, register_default_modes
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
|
||||
class ModeTool(BaseTool):
|
||||
"""Tool for managing development modes."""
|
||||
|
||||
name = "mode"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the mode tool."""
|
||||
super().__init__()
|
||||
# Register default modes on initialization
|
||||
register_default_modes()
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Manage development modes (programmer personalities). Actions: list (default), activate, show, current.
|
||||
|
||||
Usage:
|
||||
mode
|
||||
mode --action list
|
||||
mode --action activate guido
|
||||
mode --action show linus
|
||||
mode --action current"""
|
||||
|
||||
@override
|
||||
async def run(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
action: str = "list",
|
||||
name: Optional[str] = None,
|
||||
) -> str:
|
||||
"""Manage development modes.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
action: Action to perform (list, activate, show, current)
|
||||
name: Mode name (for activate/show actions)
|
||||
|
||||
Returns:
|
||||
Action result
|
||||
"""
|
||||
if action == "list":
|
||||
modes = ModeRegistry.list()
|
||||
if not modes:
|
||||
return "No modes registered"
|
||||
|
||||
output = ["Available development modes (100 programmer personalities):"]
|
||||
active = ModeRegistry.get_active()
|
||||
|
||||
# Group modes by category
|
||||
categories = {
|
||||
"Language Creators": [
|
||||
"guido",
|
||||
"matz",
|
||||
"brendan",
|
||||
"dennis",
|
||||
"bjarne",
|
||||
"james",
|
||||
"anders",
|
||||
"larry",
|
||||
"rasmus",
|
||||
"rich",
|
||||
],
|
||||
"Systems & Infrastructure": [
|
||||
"linus",
|
||||
"rob",
|
||||
"ken",
|
||||
"bill",
|
||||
"richard",
|
||||
"brian",
|
||||
"donald",
|
||||
"graydon",
|
||||
"ryan",
|
||||
"mitchell",
|
||||
],
|
||||
"Web & Frontend": [
|
||||
"tim",
|
||||
"douglas",
|
||||
"john",
|
||||
"evan",
|
||||
"jordan",
|
||||
"jeremy",
|
||||
"david",
|
||||
"taylor",
|
||||
"adrian",
|
||||
"matt",
|
||||
],
|
||||
"Database & Data": [
|
||||
"michael_s",
|
||||
"michael_w",
|
||||
"salvatore",
|
||||
"dwight",
|
||||
"edgar",
|
||||
"jim_gray",
|
||||
"jeff_dean",
|
||||
"sanjay",
|
||||
"mike",
|
||||
"matei",
|
||||
],
|
||||
"AI & Machine Learning": [
|
||||
"yann",
|
||||
"geoffrey",
|
||||
"yoshua",
|
||||
"andrew",
|
||||
"demis",
|
||||
"ilya",
|
||||
"andrej",
|
||||
"chris",
|
||||
"francois",
|
||||
"jeremy_howard",
|
||||
],
|
||||
"Security & Cryptography": [
|
||||
"bruce",
|
||||
"phil",
|
||||
"whitfield",
|
||||
"ralph",
|
||||
"daniel_b",
|
||||
"moxie",
|
||||
"theo",
|
||||
"dan_kaminsky",
|
||||
"katie",
|
||||
"matt_blaze",
|
||||
],
|
||||
"Gaming & Graphics": [
|
||||
"john_carmack",
|
||||
"sid",
|
||||
"shigeru",
|
||||
"gabe",
|
||||
"markus",
|
||||
"jonathan",
|
||||
"casey",
|
||||
"tim_sweeney",
|
||||
"hideo",
|
||||
"will",
|
||||
],
|
||||
"Open Source Leaders": [
|
||||
"miguel",
|
||||
"nat",
|
||||
"patrick",
|
||||
"ian",
|
||||
"mark_shuttleworth",
|
||||
"lennart",
|
||||
"bram",
|
||||
"daniel_r",
|
||||
"judd",
|
||||
"fabrice",
|
||||
],
|
||||
"Modern Innovators": [
|
||||
"vitalik",
|
||||
"satoshi",
|
||||
"chris_lattner",
|
||||
"joe",
|
||||
"jose",
|
||||
"sebastian",
|
||||
"palmer",
|
||||
"dylan",
|
||||
"guillermo",
|
||||
"tom",
|
||||
],
|
||||
"Special Configurations": [
|
||||
"fullstack",
|
||||
"minimal",
|
||||
"data_scientist",
|
||||
"devops",
|
||||
"security",
|
||||
"academic",
|
||||
"startup",
|
||||
"enterprise",
|
||||
"creative",
|
||||
"hanzo",
|
||||
],
|
||||
}
|
||||
|
||||
for category, mode_names in categories.items():
|
||||
output.append(f"\n{category}:")
|
||||
for mode_name in mode_names:
|
||||
mode = next((m for m in modes if m.name == mode_name), None)
|
||||
if mode:
|
||||
marker = " (active)" if active and active.name == mode.name else ""
|
||||
output.append(f" {mode.name}{marker}: {mode.programmer} - {mode.description}")
|
||||
|
||||
output.append("\nUse 'mode --action activate <name>' to activate a mode")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
elif action == "activate":
|
||||
if not name:
|
||||
return "Error: Mode name required for activate action"
|
||||
|
||||
try:
|
||||
ModeRegistry.set_active(name)
|
||||
mode = ModeRegistry.get(name)
|
||||
|
||||
output = [f"Activated mode: {mode.name}"]
|
||||
output.append(f"Programmer: {mode.programmer}")
|
||||
output.append(f"Description: {mode.description}")
|
||||
if mode.philosophy:
|
||||
output.append(f"Philosophy: {mode.philosophy}")
|
||||
output.append(f"\nEnabled tools ({len(mode.tools)}):")
|
||||
|
||||
# Group tools by category
|
||||
core_tools = []
|
||||
package_tools = []
|
||||
ai_tools = []
|
||||
search_tools = []
|
||||
other_tools = []
|
||||
|
||||
for tool in sorted(mode.tools):
|
||||
if tool in [
|
||||
"read",
|
||||
"write",
|
||||
"edit",
|
||||
"multi_edit",
|
||||
"bash",
|
||||
"tree",
|
||||
"grep",
|
||||
]:
|
||||
core_tools.append(tool)
|
||||
elif tool in ["npx", "uvx", "pip", "cargo", "gem"]:
|
||||
package_tools.append(tool)
|
||||
elif tool in ["agent", "consensus", "critic", "think"]:
|
||||
ai_tools.append(tool)
|
||||
elif tool in ["search", "symbols", "git_search"]:
|
||||
search_tools.append(tool)
|
||||
else:
|
||||
other_tools.append(tool)
|
||||
|
||||
if core_tools:
|
||||
output.append(f" Core: {', '.join(core_tools)}")
|
||||
if package_tools:
|
||||
output.append(f" Package managers: {', '.join(package_tools)}")
|
||||
if ai_tools:
|
||||
output.append(f" AI tools: {', '.join(ai_tools)}")
|
||||
if search_tools:
|
||||
output.append(f" Search: {', '.join(search_tools)}")
|
||||
if other_tools:
|
||||
output.append(f" Specialized: {', '.join(other_tools)}")
|
||||
|
||||
if mode.environment:
|
||||
output.append("\nEnvironment variables:")
|
||||
for key, value in mode.environment.items():
|
||||
output.append(f" {key}={value}")
|
||||
|
||||
output.append("\nNote: Restart MCP session for changes to take full effect")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
elif action == "show":
|
||||
if not name:
|
||||
return "Error: Mode name required for show action"
|
||||
|
||||
mode = ModeRegistry.get(name)
|
||||
if not mode:
|
||||
return f"Mode '{name}' not found"
|
||||
|
||||
output = [f"Mode: {mode.name}"]
|
||||
output.append(f"Programmer: {mode.programmer}")
|
||||
output.append(f"Description: {mode.description}")
|
||||
if mode.philosophy:
|
||||
output.append(f"Philosophy: {mode.philosophy}")
|
||||
output.append(f"\nTools ({len(mode.tools)}):")
|
||||
|
||||
for tool in sorted(mode.tools):
|
||||
output.append(f" - {tool}")
|
||||
|
||||
if mode.environment:
|
||||
output.append("\nEnvironment:")
|
||||
for key, value in mode.environment.items():
|
||||
output.append(f" {key}={value}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
elif action == "current":
|
||||
active = ModeRegistry.get_active()
|
||||
if not active:
|
||||
return "No mode currently active\nUse 'mode --action activate <name>' to activate one"
|
||||
|
||||
output = [f"Current mode: {active.name}"]
|
||||
output.append(f"Programmer: {active.programmer}")
|
||||
output.append(f"Description: {active.description}")
|
||||
if active.philosophy:
|
||||
output.append(f"Philosophy: {active.philosophy}")
|
||||
output.append(f"Enabled tools: {len(active.tools)}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
else:
|
||||
return f"Unknown action: {action}. Use 'list', 'activate', 'show', or 'current'"
|
||||
|
||||
def register(self, server: FastMCP) -> None:
|
||||
"""Register the tool with the MCP server."""
|
||||
tool_self = self
|
||||
|
||||
@server.tool(name=self.name, description=self.description)
|
||||
async def mode_handler(ctx: MCPContext, action: str = "list", name: Optional[str] = None) -> str:
|
||||
"""Handle mode tool calls."""
|
||||
return await tool_self.run(ctx, action=action, name=name)
|
||||
|
||||
@auto_timeout("mode")
|
||||
async def call(self, ctx: MCPContext, **params) -> str:
|
||||
"""Call the tool with arguments."""
|
||||
return await self.run(ctx, action=params.get("action", "list"), name=params.get("name"))
|
||||
|
||||
|
||||
# Create tool instance
|
||||
mode_tool = ModeTool()
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-config"
|
||||
version = "0.1.0"
|
||||
description = "Configuration tools for Hanzo AI - mode, settings management"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "tools", "config", "mcp", "ai"]
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"mcp>=1.25.0",
|
||||
"fastmcp>=2.14.1",
|
||||
"pydantic>=2.12.5",
|
||||
]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
config = "hanzo_tools.config:TOOLS"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["hanzo_tools*"]
|
||||
@@ -1,18 +1,79 @@
|
||||
"""LLM provider tools.
|
||||
"""LLM tools for Hanzo AI.
|
||||
|
||||
Tools:
|
||||
- llm: Unified LLM interface supporting 100+ providers
|
||||
- consensus: Multi-model consensus for improved accuracy
|
||||
- llm: Unified LLM interface
|
||||
- llm_manage: Model management
|
||||
- consensus: Multi-model consensus
|
||||
|
||||
Install: pip install hanzo-tools-llm
|
||||
Install:
|
||||
pip install hanzo-tools-llm[full]
|
||||
"""
|
||||
|
||||
# Placeholder - full implementation to be migrated from hanzo-mcp
|
||||
TOOLS = []
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["TOOLS", "register_tools"]
|
||||
# Lazy imports for heavy dependencies
|
||||
_tools = []
|
||||
|
||||
def register_tools(mcp_server, enabled_tools=None):
|
||||
"""Register LLM tools with the MCP server."""
|
||||
# TODO: Migrate from hanzo_mcp.tools.llm
|
||||
return []
|
||||
try:
|
||||
from .llm_tool import LLMTool
|
||||
_tools.append(LLMTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLMTool not available: {e}")
|
||||
LLMTool = None
|
||||
|
||||
try:
|
||||
from .llm_unified import UnifiedLLMTool
|
||||
_tools.append(UnifiedLLMTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"UnifiedLLMTool not available: {e}")
|
||||
UnifiedLLMTool = None
|
||||
|
||||
try:
|
||||
from .consensus_tool import ConsensusTool
|
||||
_tools.append(ConsensusTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"ConsensusTool not available: {e}")
|
||||
ConsensusTool = None
|
||||
|
||||
try:
|
||||
from .llm_manage import LLMManageTool
|
||||
_tools.append(LLMManageTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"LLMManageTool not available: {e}")
|
||||
LLMManageTool = None
|
||||
|
||||
TOOLS = _tools
|
||||
LLM_AVAILABLE = len(_tools) > 0
|
||||
|
||||
__all__ = [
|
||||
"TOOLS",
|
||||
"LLM_AVAILABLE",
|
||||
"LLMTool",
|
||||
"UnifiedLLMTool",
|
||||
"ConsensusTool",
|
||||
"LLMManageTool",
|
||||
"register_tools",
|
||||
]
|
||||
|
||||
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register LLM tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
return registered
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
"""Consensus tool for querying multiple LLMs in parallel."""
|
||||
|
||||
import asyncio
|
||||
from typing import (
|
||||
Dict,
|
||||
List,
|
||||
Unpack,
|
||||
Optional,
|
||||
Annotated,
|
||||
TypedDict,
|
||||
final,
|
||||
override,
|
||||
)
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from .llm_tool import LLMTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
Prompt = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The prompt to send to all models",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Models = Annotated[
|
||||
Optional[List[str]],
|
||||
Field(
|
||||
description="List of models to query (defaults to a diverse set)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
SystemPrompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="System prompt for all models",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Temperature = Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="Temperature for all models",
|
||||
default=0.7,
|
||||
),
|
||||
]
|
||||
|
||||
MaxTokens = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Maximum tokens per response",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
AggregationModel = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model to use for aggregating responses (defaults to gpt-4)",
|
||||
default="gpt-4",
|
||||
),
|
||||
]
|
||||
|
||||
IncludeRaw = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Include raw responses from each model",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
Timeout = Annotated[
|
||||
int,
|
||||
Field(
|
||||
description="Timeout in seconds for each model",
|
||||
default=30,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ConsensusToolParams(TypedDict, total=False):
|
||||
"""Parameters for consensus tool."""
|
||||
|
||||
prompt: str
|
||||
models: Optional[List[str]]
|
||||
system_prompt: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: Optional[int]
|
||||
aggregation_model: Optional[str]
|
||||
include_raw: bool
|
||||
timeout: int
|
||||
|
||||
|
||||
@final
|
||||
class ConsensusTool(BaseTool):
|
||||
"""Tool for getting consensus from multiple LLMs."""
|
||||
|
||||
# Default models to use if none specified - mix of fast and powerful models
|
||||
DEFAULT_MODELS = [
|
||||
"gpt-4o-mini", # OpenAI's fast model
|
||||
"claude-3-opus-20240229", # Claude's most capable model
|
||||
"gemini/gemini-1.5-pro", # Google's largest model
|
||||
"groq/llama3-70b-8192", # Fast inference via Groq
|
||||
"mistral/mistral-large-latest", # Mistral's best model
|
||||
]
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the consensus tool."""
|
||||
self.llm_tool = LLMTool()
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "consensus"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
available_providers = list(self.llm_tool.available_providers.keys())
|
||||
|
||||
return f"""Query multiple LLMs in parallel and get a consensus response.
|
||||
|
||||
Queries multiple models simultaneously, then uses another model to
|
||||
synthesize and analyze the responses for consensus, disagreements, and insights.
|
||||
|
||||
Available providers: {", ".join(available_providers)}
|
||||
|
||||
Default models (if available):
|
||||
- GPT-4 (OpenAI)
|
||||
- Claude 3 Sonnet (Anthropic)
|
||||
- Gemini Pro (Google)
|
||||
- Mixtral 8x7B (Groq)
|
||||
- Mistral Medium (Mistral)
|
||||
|
||||
Examples:
|
||||
- consensus --prompt "What are the key principles of good software design?"
|
||||
- consensus --prompt "Analyze this code for security issues" --models '["gpt-4", "claude-3-opus-20240229"]'
|
||||
- consensus --prompt "Is this implementation correct?" --include-raw
|
||||
- consensus --prompt "What's the best approach?" --aggregation-model "claude-3-opus-20240229"
|
||||
|
||||
The tool will:
|
||||
1. Query all specified models in parallel
|
||||
2. Collect and analyze responses
|
||||
3. Use the aggregation model to synthesize findings
|
||||
4. Highlight areas of agreement and disagreement
|
||||
5. Provide a balanced consensus view
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("consensus")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[ConsensusToolParams],
|
||||
) -> str:
|
||||
"""Get consensus from multiple LLMs.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Consensus analysis
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Extract parameters
|
||||
prompt = params.get("prompt")
|
||||
if not prompt:
|
||||
return "Error: prompt is required"
|
||||
|
||||
models = params.get("models") or self.DEFAULT_MODELS
|
||||
system_prompt = params.get("system_prompt")
|
||||
temperature = params.get("temperature", 0.7)
|
||||
max_tokens = params.get("max_tokens")
|
||||
aggregation_model = params.get("aggregation_model", "gpt-4")
|
||||
include_raw = params.get("include_raw", False)
|
||||
timeout = params.get("timeout", 30)
|
||||
|
||||
# Filter models to only those with available API keys
|
||||
available_models = []
|
||||
skipped_models = []
|
||||
|
||||
for model in models:
|
||||
provider = self.llm_tool._get_provider_for_model(model)
|
||||
if provider in self.llm_tool.available_providers:
|
||||
available_models.append(model)
|
||||
else:
|
||||
skipped_models.append((model, provider))
|
||||
|
||||
if not available_models:
|
||||
return "Error: No models available with configured API keys. Please set API keys for at least one provider."
|
||||
|
||||
await tool_ctx.info(f"Querying {len(available_models)} models in parallel...")
|
||||
|
||||
if skipped_models:
|
||||
skipped_info = ", ".join([f"{m[0]} ({m[1]})" for m in skipped_models])
|
||||
await tool_ctx.info(f"Skipping models without API keys: {skipped_info}")
|
||||
|
||||
# Query all models in parallel
|
||||
results = await self._query_models_parallel(
|
||||
available_models, prompt, system_prompt, temperature, max_tokens, timeout
|
||||
)
|
||||
|
||||
# Prepare summary of results
|
||||
successful_responses = [(m, r) for m, r in results.items() if not r.startswith("Error:")]
|
||||
failed_responses = [(m, r) for m, r in results.items() if r.startswith("Error:")]
|
||||
|
||||
if not successful_responses:
|
||||
return "Error: All model queries failed:\n\n" + "\n".join([f"{m}: {r}" for m, r in failed_responses])
|
||||
|
||||
# Use aggregation model to synthesize responses
|
||||
consensus = await self._aggregate_responses(successful_responses, prompt, aggregation_model)
|
||||
|
||||
# Format output
|
||||
output = ["=== LLM Consensus Analysis ==="]
|
||||
output.append(f"Query: {prompt}")
|
||||
output.append(f"Models queried: {len(available_models)}")
|
||||
output.append(f"Successful responses: {len(successful_responses)}")
|
||||
|
||||
if failed_responses:
|
||||
output.append(f"Failed responses: {len(failed_responses)}")
|
||||
|
||||
output.append("")
|
||||
output.append("=== Consensus Summary ===")
|
||||
output.append(consensus)
|
||||
|
||||
if include_raw:
|
||||
output.append("\n=== Individual Responses ===")
|
||||
for model, response in successful_responses:
|
||||
output.append(f"\n--- {model} ---")
|
||||
output.append(response[:500] + "..." if len(response) > 500 else response)
|
||||
|
||||
if failed_responses:
|
||||
output.append("\n=== Failed Queries ===")
|
||||
for model, error in failed_responses:
|
||||
output.append(f"{model}: {error}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
async def _query_models_parallel(
|
||||
self,
|
||||
models: List[str],
|
||||
prompt: str,
|
||||
system_prompt: Optional[str],
|
||||
temperature: float,
|
||||
max_tokens: Optional[int],
|
||||
timeout: int,
|
||||
) -> Dict[str, str]:
|
||||
"""Query multiple models in parallel."""
|
||||
|
||||
async def query_with_timeout(model: str) -> tuple[str, str]:
|
||||
try:
|
||||
params = {
|
||||
"model": model,
|
||||
"prompt": prompt,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if system_prompt:
|
||||
params["system_prompt"] = system_prompt
|
||||
if max_tokens:
|
||||
params["max_tokens"] = max_tokens
|
||||
|
||||
# Create a proper context for the LLM tool
|
||||
tool_ctx = create_tool_context("consensus", self.server)
|
||||
|
||||
result = await asyncio.wait_for(self.llm_tool.call(tool_ctx, **params), timeout=timeout)
|
||||
return (model, result)
|
||||
except asyncio.TimeoutError:
|
||||
return (model, f"Error: Timeout after {timeout} seconds")
|
||||
except Exception as e:
|
||||
return (model, f"Error: {str(e)}")
|
||||
|
||||
# Run all queries in parallel
|
||||
tasks = [query_with_timeout(model) for model in models]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
return dict(results)
|
||||
|
||||
async def _aggregate_responses(
|
||||
self,
|
||||
responses: List[tuple[str, str]],
|
||||
original_prompt: str,
|
||||
aggregation_model: str,
|
||||
) -> str:
|
||||
"""Use an LLM to aggregate and analyze responses."""
|
||||
# Prepare the aggregation prompt
|
||||
response_summary = "\n\n".join([f"Model: {model}\nResponse: {response}" for model, response in responses])
|
||||
|
||||
aggregation_prompt = f"""You are analyzing responses from multiple AI models to the following prompt:
|
||||
|
||||
<original_prompt>
|
||||
{original_prompt}
|
||||
</original_prompt>
|
||||
|
||||
<model_responses>
|
||||
{response_summary}
|
||||
</model_responses>
|
||||
|
||||
Please provide a comprehensive analysis that includes:
|
||||
|
||||
1. **Consensus Points**: What do most or all models agree on?
|
||||
2. **Divergent Views**: Where do the models disagree or offer different perspectives?
|
||||
3. **Key Insights**: What are the most valuable insights across all responses?
|
||||
4. **Unique Contributions**: Did any model provide unique valuable information?
|
||||
5. **Synthesis**: Provide a balanced, synthesized answer that incorporates the best elements from all responses.
|
||||
|
||||
Be concise but thorough. Focus on providing actionable insights."""
|
||||
|
||||
try:
|
||||
# Use the LLM tool to get the aggregation
|
||||
tool_ctx = create_tool_context("consensus_aggregation", self.server)
|
||||
|
||||
aggregation_params = {
|
||||
"model": aggregation_model,
|
||||
"prompt": aggregation_prompt,
|
||||
"temperature": 0.3, # Lower temperature for more consistent analysis
|
||||
"system_prompt": "You are an expert at analyzing and synthesizing multiple AI responses to provide balanced, insightful consensus.",
|
||||
}
|
||||
|
||||
result = await self.llm_tool.call(tool_ctx, **aggregation_params)
|
||||
return result
|
||||
|
||||
except Exception:
|
||||
# Fallback to simple aggregation if LLM fails
|
||||
return self._simple_aggregate(responses)
|
||||
|
||||
def _simple_aggregate(self, responses: List[tuple[str, str]]) -> str:
|
||||
"""Simple fallback aggregation without LLM."""
|
||||
output = []
|
||||
output.append("Summary of responses:")
|
||||
output.append("")
|
||||
|
||||
# Find common themes (very basic)
|
||||
all_text = " ".join([r[1] for r in responses]).lower()
|
||||
|
||||
output.append("Response lengths:")
|
||||
for model, response in responses:
|
||||
output.append(f"- {model}: {len(response)} characters")
|
||||
|
||||
output.append("\nNote: Advanced consensus analysis unavailable. Showing basic summary only.")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,452 @@
|
||||
"""LLM management tool for enabling/disabling LLM providers."""
|
||||
|
||||
import json
|
||||
from typing import Unpack, Optional, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from .llm_tool import LLMTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action to perform: list, enable, disable, test",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Provider = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Provider name (for enable/disable/test actions)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Model = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model to test (for test action)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class LLMManageParams(TypedDict, total=False):
|
||||
"""Parameters for LLM management tool."""
|
||||
|
||||
action: str
|
||||
provider: Optional[str]
|
||||
model: Optional[str]
|
||||
|
||||
|
||||
@final
|
||||
class LLMManageTool(BaseTool):
|
||||
"""Tool for managing LLM providers."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the LLM management tool."""
|
||||
self.llm_tool = LLMTool()
|
||||
self.config_file = Path.home() / ".hanzo" / "llm" / "providers.json"
|
||||
self.config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._load_config()
|
||||
|
||||
def _load_config(self):
|
||||
"""Load provider configuration."""
|
||||
if self.config_file.exists():
|
||||
try:
|
||||
with open(self.config_file, "r") as f:
|
||||
self.config = json.load(f)
|
||||
except Exception:
|
||||
self.config = {"disabled_providers": []}
|
||||
else:
|
||||
self.config = {"disabled_providers": []}
|
||||
|
||||
def _save_config(self):
|
||||
"""Save provider configuration."""
|
||||
with open(self.config_file, "w") as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "llm_manage"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Manage LLM providers and test configurations.
|
||||
|
||||
Actions:
|
||||
- list: Show all providers and their status
|
||||
- models: List all available models (optionally filtered by provider)
|
||||
- enable: Enable a provider's tools
|
||||
- disable: Disable a provider's tools
|
||||
- test: Test a model to verify it works
|
||||
|
||||
Examples:
|
||||
- llm_manage --action list
|
||||
- llm_manage --action models
|
||||
- llm_manage --action models --provider openai
|
||||
- llm_manage --action enable --provider openai
|
||||
- llm_manage --action disable --provider perplexity
|
||||
- llm_manage --action test --model "gpt-4"
|
||||
- llm_manage --action test --provider groq --model "mixtral"
|
||||
|
||||
Providers are automatically detected based on environment variables:
|
||||
- OpenAI: OPENAI_API_KEY
|
||||
- Anthropic: ANTHROPIC_API_KEY or CLAUDE_API_KEY
|
||||
- Google: GOOGLE_API_KEY or GEMINI_API_KEY
|
||||
- Groq: GROQ_API_KEY
|
||||
- Mistral: MISTRAL_API_KEY
|
||||
- Perplexity: PERPLEXITY_API_KEY
|
||||
- And many more...
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("llm_manage")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[LLMManageParams],
|
||||
) -> str:
|
||||
"""Manage LLM providers.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Result of the management action
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Extract parameters
|
||||
action = params.get("action")
|
||||
if not action:
|
||||
return "Error: action is required (list, enable, disable, test, models)"
|
||||
|
||||
provider = params.get("provider")
|
||||
model = params.get("model")
|
||||
|
||||
# Handle different actions
|
||||
if action == "list":
|
||||
return self._list_providers()
|
||||
elif action == "models":
|
||||
return self._list_all_models(provider)
|
||||
elif action == "enable":
|
||||
return self._enable_provider(provider)
|
||||
elif action == "disable":
|
||||
return self._disable_provider(provider)
|
||||
elif action == "test":
|
||||
return await self._test_model(ctx, provider, model)
|
||||
else:
|
||||
return f"Error: Invalid action '{action}'. Must be one of: list, models, enable, disable, test"
|
||||
|
||||
def _list_providers(self) -> str:
|
||||
"""List all providers and their status."""
|
||||
output = ["=== LLM Providers ==="]
|
||||
output.append("")
|
||||
|
||||
# Get all possible providers
|
||||
all_providers = sorted(LLMTool.API_KEY_ENV_VARS.keys())
|
||||
available_providers = self.llm_tool.available_providers
|
||||
disabled_providers = self.config.get("disabled_providers", [])
|
||||
|
||||
# Categorize providers
|
||||
active = []
|
||||
available_but_disabled = []
|
||||
no_api_key = []
|
||||
|
||||
for provider in all_providers:
|
||||
if provider in available_providers:
|
||||
if provider in disabled_providers:
|
||||
available_but_disabled.append(provider)
|
||||
else:
|
||||
active.append(provider)
|
||||
else:
|
||||
no_api_key.append(provider)
|
||||
|
||||
# Show active providers
|
||||
if active:
|
||||
output.append("✅ Active Providers (API key found, enabled):")
|
||||
for provider in active:
|
||||
env_vars = available_providers.get(provider, [])
|
||||
output.append(f" - {provider}: {', '.join(env_vars)}")
|
||||
|
||||
# Show example models
|
||||
examples = self._get_example_models(provider)
|
||||
if examples:
|
||||
output.append(f" Models: {', '.join(examples[:3])}")
|
||||
output.append("")
|
||||
|
||||
# Show disabled providers
|
||||
if available_but_disabled:
|
||||
output.append("⚠️ Available but Disabled (API key found, disabled):")
|
||||
for provider in available_but_disabled:
|
||||
env_vars = available_providers.get(provider, [])
|
||||
output.append(f" - {provider}: {', '.join(env_vars)}")
|
||||
output.append(f" Use: llm_manage --action enable --provider {provider}")
|
||||
output.append("")
|
||||
|
||||
# Show providers without API keys
|
||||
if no_api_key:
|
||||
output.append("❌ No API Key Found:")
|
||||
for provider in no_api_key[:10]: # Show first 10
|
||||
env_vars = LLMTool.API_KEY_ENV_VARS.get(provider, [])
|
||||
output.append(f" - {provider}: Set one of {', '.join(env_vars)}")
|
||||
if len(no_api_key) > 10:
|
||||
output.append(f" ... and {len(no_api_key) - 10} more")
|
||||
output.append("")
|
||||
|
||||
# Summary
|
||||
output.append("=== Summary ===")
|
||||
output.append(f"Total providers: {len(all_providers)}")
|
||||
output.append(f"Active: {len(active)}")
|
||||
output.append(f"Disabled: {len(available_but_disabled)}")
|
||||
output.append(f"No API key: {len(no_api_key)}")
|
||||
|
||||
# Show available tools
|
||||
if active:
|
||||
output.append("\n=== Available LLM Tools ===")
|
||||
output.append("- llm: Universal LLM tool (all providers)")
|
||||
output.append("- consensus: Query multiple models in parallel")
|
||||
|
||||
provider_tools = []
|
||||
for provider in active:
|
||||
if provider in [
|
||||
"openai",
|
||||
"anthropic",
|
||||
"google",
|
||||
"groq",
|
||||
"mistral",
|
||||
"perplexity",
|
||||
]:
|
||||
tool_name = "gemini" if provider == "google" else provider
|
||||
provider_tools.append(tool_name)
|
||||
|
||||
if provider_tools:
|
||||
output.append(f"- Provider tools: {', '.join(provider_tools)}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _enable_provider(self, provider: Optional[str]) -> str:
|
||||
"""Enable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for enable action"
|
||||
|
||||
if provider not in self.llm_tool.available_providers:
|
||||
env_vars = LLMTool.API_KEY_ENV_VARS.get(provider, [])
|
||||
if env_vars:
|
||||
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
|
||||
else:
|
||||
return f"Error: Unknown provider '{provider}'"
|
||||
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
if provider in disabled:
|
||||
disabled.remove(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully enabled {provider}"
|
||||
else:
|
||||
return f"{provider} is already enabled"
|
||||
|
||||
def _disable_provider(self, provider: Optional[str]) -> str:
|
||||
"""Disable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for disable action"
|
||||
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
if provider not in disabled:
|
||||
disabled.append(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully disabled {provider}. Its tools will no longer be available."
|
||||
else:
|
||||
return f"{provider} is already disabled"
|
||||
|
||||
def _list_all_models(self, provider: Optional[str] = None) -> str:
|
||||
"""List all available models from LiteLLM."""
|
||||
try:
|
||||
from .llm_tool import LLMTool
|
||||
|
||||
all_models = LLMTool.get_all_models()
|
||||
|
||||
if not all_models:
|
||||
return "No models available or LiteLLM not installed"
|
||||
|
||||
output = ["=== Available LLM Models ==="]
|
||||
|
||||
if provider:
|
||||
# Show models for specific provider
|
||||
provider_lower = provider.lower()
|
||||
models = all_models.get(provider_lower, [])
|
||||
|
||||
if not models:
|
||||
return f"No models found for provider '{provider}'"
|
||||
|
||||
output.append(f"\n{provider.upper()} ({len(models)} models):")
|
||||
output.append("-" * 40)
|
||||
|
||||
# Show first 50 models
|
||||
for model in models[:50]:
|
||||
output.append(f" {model}")
|
||||
|
||||
if len(models) > 50:
|
||||
output.append(f" ... and {len(models) - 50} more")
|
||||
else:
|
||||
# Show summary of all providers
|
||||
total_models = sum(len(models) for models in all_models.values())
|
||||
output.append(f"Total models available: {total_models}")
|
||||
output.append("")
|
||||
|
||||
# Show providers with counts
|
||||
for provider_name, models in sorted(all_models.items()):
|
||||
if models:
|
||||
output.append(f"{provider_name}: {len(models)} models")
|
||||
|
||||
output.append(
|
||||
"\nUse 'llm_manage --action models --provider <name>' to see models for a specific provider"
|
||||
)
|
||||
|
||||
# Show recommended models
|
||||
output.append("\n=== Recommended Models ===")
|
||||
recommended = {
|
||||
"OpenAI": ["gpt-4o", "gpt-4o-mini", "gpt-3.5-turbo"],
|
||||
"Anthropic": [
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-5-sonnet-20241022",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
"Google": ["gemini/gemini-1.5-pro", "gemini/gemini-1.5-flash"],
|
||||
"Groq": [
|
||||
"groq/llama3-70b-8192",
|
||||
"groq/llama3-8b-8192",
|
||||
"groq/gemma2-9b-it",
|
||||
],
|
||||
"Mistral": [
|
||||
"mistral/mistral-large-latest",
|
||||
"mistral/mistral-medium",
|
||||
],
|
||||
}
|
||||
|
||||
for provider_name, models in recommended.items():
|
||||
available = LLMTool().available_providers
|
||||
provider_key = provider_name.lower()
|
||||
|
||||
if provider_key in available:
|
||||
output.append(f"\n{provider_name} (✅ API key found):")
|
||||
for model in models:
|
||||
output.append(f" - {model}")
|
||||
else:
|
||||
output.append(f"\n{provider_name} (❌ No API key):")
|
||||
for model in models:
|
||||
output.append(f" - {model}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing models: {str(e)}"
|
||||
|
||||
async def _test_model(self, ctx: MCPContext, provider: Optional[str], model: Optional[str]) -> str:
|
||||
"""Test a model to verify it works."""
|
||||
if not model and not provider:
|
||||
return "Error: Either model or provider is required for test action"
|
||||
|
||||
# Determine model to test
|
||||
if model:
|
||||
test_model = model
|
||||
else:
|
||||
# Use default model for provider
|
||||
default_models = {
|
||||
"openai": "gpt-3.5-turbo",
|
||||
"anthropic": "claude-3-haiku-20240307",
|
||||
"google": "gemini/gemini-pro",
|
||||
"groq": "groq/mixtral-8x7b-32768",
|
||||
"mistral": "mistral/mistral-tiny",
|
||||
"perplexity": "perplexity/sonar-small-online",
|
||||
}
|
||||
test_model = default_models.get(provider)
|
||||
if not test_model:
|
||||
return f"Error: No default model for provider '{provider}'. Please specify a model."
|
||||
|
||||
# Test the model
|
||||
test_prompt = "Hello! Please respond with 'OK' if you can hear me."
|
||||
|
||||
output = [f"Testing model: {test_model}"]
|
||||
output.append(f"Prompt: {test_prompt}")
|
||||
output.append("")
|
||||
|
||||
try:
|
||||
# Call the LLM
|
||||
params = {
|
||||
"model": test_model,
|
||||
"prompt": test_prompt,
|
||||
"max_tokens": 10,
|
||||
"temperature": 0,
|
||||
}
|
||||
|
||||
response = await self.llm_tool.call(ctx, **params)
|
||||
|
||||
if response.startswith("Error:"):
|
||||
output.append("❌ Test failed:")
|
||||
output.append(response)
|
||||
else:
|
||||
output.append("✅ Test successful!")
|
||||
output.append(f"Response: {response}")
|
||||
output.append("")
|
||||
output.append(f"Model '{test_model}' is working correctly.")
|
||||
|
||||
# Show provider info
|
||||
detected_provider = self.llm_tool._get_provider_for_model(test_model)
|
||||
if detected_provider:
|
||||
output.append(f"Provider: {detected_provider}")
|
||||
|
||||
except Exception as e:
|
||||
output.append("❌ Test failed with exception:")
|
||||
output.append(str(e))
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _get_example_models(self, provider: str) -> list[str]:
|
||||
"""Get example models for a provider."""
|
||||
examples = {
|
||||
"openai": ["gpt-4o", "gpt-4", "gpt-3.5-turbo", "o1-preview"],
|
||||
"anthropic": [
|
||||
"claude-3-opus-20240229",
|
||||
"claude-3-sonnet-20240229",
|
||||
"claude-3-haiku-20240307",
|
||||
],
|
||||
"google": [
|
||||
"gemini/gemini-pro",
|
||||
"gemini/gemini-1.5-pro",
|
||||
"gemini/gemini-1.5-flash",
|
||||
],
|
||||
"groq": [
|
||||
"groq/mixtral-8x7b-32768",
|
||||
"groq/llama3-70b-8192",
|
||||
"groq/llama3-8b-8192",
|
||||
],
|
||||
"mistral": [
|
||||
"mistral/mistral-large-latest",
|
||||
"mistral/mistral-medium",
|
||||
"mistral/mistral-small",
|
||||
],
|
||||
"perplexity": [
|
||||
"perplexity/sonar-medium-online",
|
||||
"perplexity/sonar-small-online",
|
||||
],
|
||||
}
|
||||
return examples.get(provider, [])
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,885 @@
|
||||
"""Unified LLM tool with multiple actions including consensus mode."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Unpack,
|
||||
Optional,
|
||||
Annotated,
|
||||
TypedDict,
|
||||
final,
|
||||
override,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
# Check if litellm is available
|
||||
try:
|
||||
import litellm
|
||||
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
|
||||
|
||||
# Parameter types
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action to perform: query, consensus, list, models, enable, disable, test",
|
||||
default="query",
|
||||
),
|
||||
]
|
||||
|
||||
Model = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Models = Annotated[
|
||||
Optional[List[str]],
|
||||
Field(
|
||||
description="List of models for consensus mode",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Prompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="The prompt to send to the LLM",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
SystemPrompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="System prompt to set context",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Temperature = Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="Temperature for response randomness (0-2)",
|
||||
default=0.7,
|
||||
),
|
||||
]
|
||||
|
||||
MaxTokens = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Maximum tokens in response",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
JsonMode = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Request JSON formatted response",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
Stream = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Stream the response",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
Provider = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Provider name for list/enable/disable actions",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
IncludeRaw = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Include raw responses in consensus mode",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
JudgeModel = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model to use as judge/aggregator in consensus",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
DevilsAdvocate = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Enable devil's advocate mode (10th model critiques others)",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
ConsensusSize = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Number of models to use in consensus (default: 3)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class LLMParams(TypedDict, total=False):
|
||||
"""Parameters for LLM tool."""
|
||||
|
||||
action: str
|
||||
model: Optional[str]
|
||||
models: Optional[List[str]]
|
||||
prompt: Optional[str]
|
||||
system_prompt: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: Optional[int]
|
||||
json_mode: bool
|
||||
stream: bool
|
||||
provider: Optional[str]
|
||||
include_raw: bool
|
||||
judge_model: Optional[str]
|
||||
devils_advocate: bool
|
||||
consensus_size: Optional[int]
|
||||
|
||||
|
||||
@final
|
||||
class LLMTool(BaseTool):
|
||||
"""Unified LLM tool with multiple actions."""
|
||||
|
||||
# Config file for settings
|
||||
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
|
||||
|
||||
# Default consensus models in order of preference
|
||||
DEFAULT_CONSENSUS_MODELS = [
|
||||
"gpt-4o", # OpenAI's latest
|
||||
"claude-3-opus-20240229", # Claude's most capable
|
||||
"gemini/gemini-1.5-pro", # Google's best
|
||||
"groq/llama3-70b-8192", # Fast Groq
|
||||
"mistral/mistral-large-latest", # Mistral's best
|
||||
"perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
|
||||
]
|
||||
|
||||
# API key environment variables
|
||||
API_KEY_ENV_VARS = {
|
||||
"openai": ["OPENAI_API_KEY"],
|
||||
"anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
|
||||
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
"groq": ["GROQ_API_KEY"],
|
||||
"mistral": ["MISTRAL_API_KEY"],
|
||||
"perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
|
||||
"together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
|
||||
"cohere": ["COHERE_API_KEY"],
|
||||
"replicate": ["REPLICATE_API_KEY"],
|
||||
"huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
|
||||
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
"vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
"azure": ["AZURE_API_KEY"],
|
||||
"voyage": ["VOYAGE_API_KEY"],
|
||||
"deepseek": ["DEEPSEEK_API_KEY"],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the unified LLM tool."""
|
||||
self.available_providers = self._detect_available_providers()
|
||||
self.config = self._load_config()
|
||||
|
||||
def _detect_available_providers(self) -> Dict[str, List[str]]:
|
||||
"""Detect which providers have API keys configured."""
|
||||
available = {}
|
||||
|
||||
for provider, env_vars in self.API_KEY_ENV_VARS.items():
|
||||
for var in env_vars:
|
||||
if os.getenv(var):
|
||||
available[provider] = env_vars
|
||||
break
|
||||
|
||||
return available
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if self.CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(self.CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default config
|
||||
return {
|
||||
"disabled_providers": [],
|
||||
"consensus_models": None, # Use defaults if None
|
||||
"default_judge_model": "gpt-4o",
|
||||
"consensus_size": 3,
|
||||
}
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration to file."""
|
||||
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.CONFIG_FILE, "w") as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "llm"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
available = list(self.available_providers.keys())
|
||||
|
||||
return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
|
||||
|
||||
Usage:
|
||||
llm "What is the capital of France?"
|
||||
llm "Explain this code" --model gpt-4o
|
||||
llm --action consensus "Is this approach correct?" --devils-advocate
|
||||
llm --action models --provider openai
|
||||
|
||||
Available: {", ".join(available) if available else "None"}"""
|
||||
|
||||
@override
|
||||
@auto_timeout("llm")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[LLMParams],
|
||||
) -> str:
|
||||
"""Execute LLM action."""
|
||||
# Create tool context only if we have a proper MCP context
|
||||
tool_ctx = None
|
||||
try:
|
||||
if hasattr(ctx, "client") and ctx.client and hasattr(ctx.client, "server"):
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
if tool_ctx:
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
except Exception:
|
||||
# Running in test mode without MCP context
|
||||
pass
|
||||
|
||||
# Fast test path: allow offline deterministic responses
|
||||
if os.getenv("HANZO_MCP_FAST_TESTS") == "1":
|
||||
action = params.get("action", "query")
|
||||
if action == "query":
|
||||
prompt = params.get("prompt", "") or ""
|
||||
# Simple keyword routing for tests
|
||||
if "generate tasks" in prompt.lower():
|
||||
return '{"project": "Demo", "tasks": [{"title": "Init repo", "slug": "init-repo"}, {"title": "Add CI", "slug": "add-ci"}]}'
|
||||
return "Architecture Proposal: minimal service layers and tests"
|
||||
elif action == "consensus":
|
||||
return "Consensus: aligned"
|
||||
# Other actions can fall through to regular path if available
|
||||
|
||||
if not LITELLM_AVAILABLE:
|
||||
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
|
||||
|
||||
# Extract action
|
||||
action = params.get("action", "query")
|
||||
|
||||
# Route to appropriate handler
|
||||
if action == "query":
|
||||
return await self._handle_query(tool_ctx, params)
|
||||
elif action == "consensus":
|
||||
return await self._handle_consensus(tool_ctx, params)
|
||||
elif action == "list":
|
||||
return self._handle_list()
|
||||
elif action == "models":
|
||||
return self._handle_models(params.get("provider"))
|
||||
elif action == "enable":
|
||||
return self._handle_enable(params.get("provider"))
|
||||
elif action == "disable":
|
||||
return self._handle_disable(params.get("provider"))
|
||||
elif action == "test":
|
||||
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
|
||||
|
||||
async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
|
||||
"""Handle single model query."""
|
||||
model = params.get("model")
|
||||
prompt = params.get("prompt")
|
||||
|
||||
if not prompt:
|
||||
return "Error: prompt is required for query action"
|
||||
|
||||
# Auto-select model if not specified
|
||||
if not model:
|
||||
if self.available_providers:
|
||||
# Use first available model
|
||||
if "openai" in self.available_providers:
|
||||
model = "gpt-4o-mini"
|
||||
elif "anthropic" in self.available_providers:
|
||||
model = "claude-3-haiku-20240307"
|
||||
elif "google" in self.available_providers:
|
||||
model = "gemini/gemini-1.5-flash"
|
||||
else:
|
||||
# Use first provider's default
|
||||
provider = list(self.available_providers.keys())[0]
|
||||
model = f"{provider}/default"
|
||||
else:
|
||||
return "Error: No model specified and no API keys found"
|
||||
|
||||
# Check if we have API key for this model
|
||||
provider = self._get_provider_for_model(model)
|
||||
if provider and provider not in self.available_providers:
|
||||
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
|
||||
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
|
||||
|
||||
# Build messages
|
||||
messages = []
|
||||
if params.get("system_prompt"):
|
||||
messages.append({"role": "system", "content": params["system_prompt"]})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Build kwargs
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": params.get("temperature", 0.7),
|
||||
}
|
||||
|
||||
if params.get("max_tokens"):
|
||||
kwargs["max_tokens"] = params["max_tokens"]
|
||||
|
||||
if params.get("json_mode"):
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
if params.get("stream"):
|
||||
kwargs["stream"] = True
|
||||
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Querying {model}...")
|
||||
|
||||
if kwargs.get("stream"):
|
||||
# Handle streaming response
|
||||
response_text = ""
|
||||
async for chunk in await litellm.acompletion(**kwargs):
|
||||
if chunk.choices[0].delta.content:
|
||||
response_text += chunk.choices[0].delta.content
|
||||
return response_text
|
||||
else:
|
||||
# Regular response
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "model_not_found" in error_msg or "does not exist" in error_msg:
|
||||
return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
|
||||
else:
|
||||
return f"Error calling LLM: {error_msg}"
|
||||
|
||||
async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
|
||||
"""Handle consensus mode with multiple models."""
|
||||
prompt = params.get("prompt")
|
||||
if not prompt:
|
||||
return "Error: prompt is required for consensus action"
|
||||
|
||||
# Determine models to use
|
||||
models = params.get("models")
|
||||
if not models:
|
||||
# Use configured or default models
|
||||
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
|
||||
models = self._get_consensus_models(consensus_size)
|
||||
|
||||
if not models:
|
||||
return "Error: No models available for consensus. Set API keys for at least 2 providers."
|
||||
|
||||
if len(models) < 2:
|
||||
return "Error: Consensus requires at least 2 models"
|
||||
|
||||
# Check for devil's advocate mode
|
||||
devils_advocate = params.get("devils_advocate", False)
|
||||
if devils_advocate and len(models) < 3:
|
||||
return "Error: Devil's advocate mode requires at least 3 models"
|
||||
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Running consensus with {len(models)} models...")
|
||||
|
||||
# Query models in parallel
|
||||
system_prompt = params.get("system_prompt")
|
||||
temperature = params.get("temperature", 0.7)
|
||||
max_tokens = params.get("max_tokens")
|
||||
|
||||
# Split models if using devil's advocate
|
||||
if devils_advocate:
|
||||
consensus_models = models[:-1]
|
||||
devil_model = models[-1]
|
||||
else:
|
||||
consensus_models = models
|
||||
devil_model = None
|
||||
|
||||
# Query consensus models
|
||||
responses = await self._query_models_parallel(
|
||||
consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
|
||||
)
|
||||
|
||||
# Get devil's advocate response if enabled
|
||||
devil_response = None
|
||||
if devil_model:
|
||||
# Create devil's advocate prompt
|
||||
responses_text = "\n\n".join(
|
||||
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
|
||||
)
|
||||
|
||||
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
|
||||
|
||||
Original Question: {prompt}
|
||||
|
||||
Responses from other models:
|
||||
{responses_text}
|
||||
|
||||
Provide your critical analysis:"""
|
||||
|
||||
devil_result = await self._query_single_model(
|
||||
devil_model, devil_prompt, system_prompt, temperature, max_tokens
|
||||
)
|
||||
|
||||
if devil_result["success"]:
|
||||
devil_response = {
|
||||
"model": devil_model,
|
||||
"response": devil_result["response"],
|
||||
"time_ms": devil_result["time_ms"],
|
||||
}
|
||||
|
||||
# Aggregate responses
|
||||
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
|
||||
include_raw = params.get("include_raw", False)
|
||||
|
||||
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
|
||||
|
||||
def _handle_list(self) -> str:
|
||||
"""List available providers."""
|
||||
output = ["=== LLM Providers ==="]
|
||||
|
||||
# Get all possible providers
|
||||
all_providers = sorted(self.API_KEY_ENV_VARS.keys())
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
output.append(f"Total providers: {len(all_providers)}")
|
||||
output.append(f"Available: {len(self.available_providers)}")
|
||||
output.append(f"Disabled: {len(disabled)}\n")
|
||||
|
||||
for provider in all_providers:
|
||||
status_parts = []
|
||||
|
||||
# Check if API key exists
|
||||
if provider in self.available_providers:
|
||||
status_parts.append("✅ API key found")
|
||||
else:
|
||||
status_parts.append("❌ No API key")
|
||||
|
||||
# Check if disabled
|
||||
if provider in disabled:
|
||||
status_parts.append("🚫 Disabled")
|
||||
|
||||
# Show environment variables
|
||||
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
|
||||
status = " | ".join(status_parts)
|
||||
|
||||
output.append(f"{provider}: {status}")
|
||||
output.append(f" Environment variables: {', '.join(env_vars)}")
|
||||
|
||||
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _handle_models(self, provider: Optional[str] = None) -> str:
|
||||
"""List available models."""
|
||||
try:
|
||||
all_models = self._get_all_models()
|
||||
|
||||
if not all_models:
|
||||
return "No models available or LiteLLM not properly initialized"
|
||||
|
||||
output = ["=== Available LLM Models ==="]
|
||||
|
||||
if provider:
|
||||
# Show models for specific provider
|
||||
provider_lower = provider.lower()
|
||||
models = all_models.get(provider_lower, [])
|
||||
|
||||
if not models:
|
||||
return f"No models found for provider '{provider}'"
|
||||
|
||||
output.append(f"\n{provider.upper()} ({len(models)} models):")
|
||||
output.append("-" * 40)
|
||||
|
||||
# Show first 50 models
|
||||
for model in models[:50]:
|
||||
output.append(f" {model}")
|
||||
|
||||
if len(models) > 50:
|
||||
output.append(f" ... and {len(models) - 50} more")
|
||||
else:
|
||||
# Show summary of all providers
|
||||
total_models = sum(len(models) for models in all_models.values())
|
||||
output.append(f"Total models available: {total_models}")
|
||||
output.append("")
|
||||
|
||||
# Show providers with counts
|
||||
for provider_name, models in sorted(all_models.items()):
|
||||
if models:
|
||||
available = "✅" if provider_name in self.available_providers else "❌"
|
||||
output.append(f"{available} {provider_name}: {len(models)} models")
|
||||
|
||||
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing models: {str(e)}"
|
||||
|
||||
def _handle_enable(self, provider: Optional[str]) -> str:
|
||||
"""Enable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for enable action"
|
||||
|
||||
provider = provider.lower()
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
if provider in disabled:
|
||||
disabled.remove(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully enabled {provider}"
|
||||
else:
|
||||
return f"{provider} is already enabled"
|
||||
|
||||
def _handle_disable(self, provider: Optional[str]) -> str:
|
||||
"""Disable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for disable action"
|
||||
|
||||
provider = provider.lower()
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
if provider not in disabled:
|
||||
disabled.append(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully disabled {provider}"
|
||||
else:
|
||||
return f"{provider} is already disabled"
|
||||
|
||||
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
|
||||
"""Test a model or provider."""
|
||||
if not model and not provider:
|
||||
return "Error: Either model or provider is required for test action"
|
||||
|
||||
# If provider specified, test its default model
|
||||
if provider and not model:
|
||||
provider = provider.lower()
|
||||
if provider == "openai":
|
||||
model = "gpt-3.5-turbo"
|
||||
elif provider == "anthropic":
|
||||
model = "claude-3-haiku-20240307"
|
||||
elif provider == "google":
|
||||
model = "gemini/gemini-1.5-flash"
|
||||
elif provider == "groq":
|
||||
model = "groq/llama3-8b-8192"
|
||||
else:
|
||||
model = f"{provider}/default"
|
||||
|
||||
# Test the model
|
||||
test_prompt = "Say 'Hello from Hanzo AI!' in exactly 5 words."
|
||||
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Testing {model}...")
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": test_prompt}],
|
||||
temperature=0,
|
||||
max_tokens=20,
|
||||
)
|
||||
|
||||
result = response.choices[0].message.content
|
||||
return f"✅ {model} is working!\nResponse: {result}"
|
||||
|
||||
except Exception as e:
|
||||
return f"❌ {model} failed: {str(e)}"
|
||||
|
||||
def _get_consensus_models(self, size: int) -> List[str]:
|
||||
"""Get models for consensus based on availability."""
|
||||
# Use configured models if set
|
||||
configured = self.config.get("consensus_models")
|
||||
if configured:
|
||||
return configured[:size]
|
||||
|
||||
# Otherwise, build list from available providers
|
||||
models = []
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
# Try default models first
|
||||
for model in self.DEFAULT_CONSENSUS_MODELS:
|
||||
if len(models) >= size:
|
||||
break
|
||||
|
||||
provider = self._get_provider_for_model(model)
|
||||
if provider and provider in self.available_providers and provider not in disabled:
|
||||
models.append(model)
|
||||
|
||||
# If still need more, add from available providers
|
||||
if len(models) < size:
|
||||
for provider in self.available_providers:
|
||||
if provider in disabled:
|
||||
continue
|
||||
|
||||
if provider == "openai" and "gpt-4o" not in models:
|
||||
models.append("gpt-4o")
|
||||
elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
|
||||
models.append("claude-3-opus-20240229")
|
||||
elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
|
||||
models.append("gemini/gemini-1.5-pro")
|
||||
|
||||
if len(models) >= size:
|
||||
break
|
||||
|
||||
return models
|
||||
|
||||
async def _query_models_parallel(
|
||||
self,
|
||||
models: List[str],
|
||||
prompt: str,
|
||||
system_prompt: Optional[str],
|
||||
temperature: float,
|
||||
max_tokens: Optional[int],
|
||||
tool_ctx,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query multiple models in parallel."""
|
||||
|
||||
async def query_with_info(model: str) -> Dict[str, Any]:
|
||||
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
|
||||
return {
|
||||
"model": model,
|
||||
"response": result.get("response"),
|
||||
"success": result.get("success", False),
|
||||
"error": result.get("error"),
|
||||
"time_ms": result.get("time_ms", 0),
|
||||
}
|
||||
|
||||
# Run all queries in parallel
|
||||
tasks = [query_with_info(model) for model in models]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Report results
|
||||
successful = sum(1 for r in results if r["success"])
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
|
||||
|
||||
return results
|
||||
|
||||
async def _query_single_model(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str],
|
||||
temperature: float,
|
||||
max_tokens: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
"""Query a single model and return result with metadata."""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"response": response.choices[0].message.content,
|
||||
"time_ms": int((time.time() - start_time) * 1000),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"time_ms": int((time.time() - start_time) * 1000),
|
||||
}
|
||||
|
||||
async def _aggregate_consensus(
|
||||
self,
|
||||
responses: List[Dict[str, Any]],
|
||||
original_prompt: str,
|
||||
judge_model: str,
|
||||
include_raw: bool,
|
||||
devil_response: Optional[Dict[str, Any]],
|
||||
tool_ctx,
|
||||
) -> str:
|
||||
"""Aggregate consensus responses using a judge model."""
|
||||
# Prepare response data
|
||||
successful_responses = [r for r in responses if r["success"]]
|
||||
|
||||
if not successful_responses:
|
||||
return "Error: All models failed to respond"
|
||||
|
||||
# Format responses for aggregation
|
||||
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
|
||||
|
||||
if devil_response:
|
||||
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
|
||||
|
||||
# Create aggregation prompt
|
||||
aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
|
||||
|
||||
<original_question>
|
||||
{original_prompt}
|
||||
</original_question>
|
||||
|
||||
<model_responses>
|
||||
{responses_text}
|
||||
</model_responses>
|
||||
|
||||
Please provide:
|
||||
1. A synthesis of the key points where models agree
|
||||
2. Notable differences or disagreements between responses
|
||||
3. A balanced conclusion incorporating the best insights
|
||||
{f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
|
||||
|
||||
Be concise and highlight the most important findings."""
|
||||
|
||||
# Get aggregation
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
|
||||
|
||||
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
|
||||
|
||||
if not judge_result["success"]:
|
||||
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
|
||||
|
||||
# Format output
|
||||
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
|
||||
output.append(judge_result["response"])
|
||||
|
||||
# Add model list
|
||||
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
|
||||
if devil_response:
|
||||
output.append(f"Devil's Advocate: {devil_response['model']}")
|
||||
|
||||
# Add timing info
|
||||
avg_time = sum(r["time_ms"] for r in responses) / len(responses)
|
||||
output.append(f"\nAverage response time: {avg_time:.0f}ms")
|
||||
|
||||
# Include raw responses if requested
|
||||
if include_raw:
|
||||
output.append("\n\n=== Raw Responses ===")
|
||||
for r in successful_responses:
|
||||
output.append(f"\n{r['model']}:")
|
||||
output.append("-" * 40)
|
||||
output.append(r["response"])
|
||||
|
||||
if devil_response:
|
||||
output.append(f"\nDevil's Advocate ({devil_response['model']}):")
|
||||
output.append("-" * 40)
|
||||
output.append(devil_response["response"])
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error during aggregation: {str(e)}"
|
||||
|
||||
def _get_provider_for_model(self, model: str) -> Optional[str]:
|
||||
"""Determine the provider for a given model."""
|
||||
model_lower = model.lower()
|
||||
|
||||
# Check explicit provider prefix
|
||||
if "/" in model:
|
||||
return model.split("/")[0]
|
||||
|
||||
# Check model prefixes
|
||||
if model_lower.startswith("gpt"):
|
||||
return "openai"
|
||||
elif model_lower.startswith("claude"):
|
||||
return "anthropic"
|
||||
elif model_lower.startswith("gemini"):
|
||||
return "google"
|
||||
elif model_lower.startswith("command"):
|
||||
return "cohere"
|
||||
|
||||
# Default to OpenAI
|
||||
return "openai"
|
||||
|
||||
def _get_all_models(self) -> Dict[str, List[str]]:
|
||||
"""Get all available models from LiteLLM."""
|
||||
try:
|
||||
import litellm
|
||||
|
||||
# Get all models
|
||||
all_models = litellm.model_list
|
||||
|
||||
# Organize by provider
|
||||
providers = {}
|
||||
|
||||
for model in all_models:
|
||||
# Extract provider
|
||||
if "/" in model:
|
||||
provider = model.split("/")[0]
|
||||
elif model.startswith("gpt"):
|
||||
provider = "openai"
|
||||
elif model.startswith("claude"):
|
||||
provider = "anthropic"
|
||||
elif model.startswith("gemini"):
|
||||
provider = "google"
|
||||
elif model.startswith("command"):
|
||||
provider = "cohere"
|
||||
else:
|
||||
provider = "other"
|
||||
|
||||
if provider not in providers:
|
||||
providers[provider] = []
|
||||
providers[provider].append(model)
|
||||
|
||||
# Sort models within each provider
|
||||
for provider in providers:
|
||||
providers[provider] = sorted(providers[provider])
|
||||
|
||||
return providers
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,872 @@
|
||||
"""Unified LLM tool with multiple actions including consensus mode."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Unpack,
|
||||
Optional,
|
||||
Annotated,
|
||||
TypedDict,
|
||||
final,
|
||||
override,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
# Check if litellm is available
|
||||
try:
|
||||
import litellm
|
||||
|
||||
LITELLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
LITELLM_AVAILABLE = False
|
||||
|
||||
|
||||
# Parameter types
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action to perform: query, consensus, list, models, enable, disable, test",
|
||||
default="query",
|
||||
),
|
||||
]
|
||||
|
||||
Model = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model name (e.g., gpt-4, claude-3-opus-20240229)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Models = Annotated[
|
||||
Optional[List[str]],
|
||||
Field(
|
||||
description="List of models for consensus mode",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Prompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="The prompt to send to the LLM",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
SystemPrompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="System prompt to set context",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Temperature = Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="Temperature for response randomness (0-2)",
|
||||
default=0.7,
|
||||
),
|
||||
]
|
||||
|
||||
MaxTokens = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Maximum tokens in response",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
JsonMode = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Request JSON formatted response",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
Stream = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Stream the response",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
Provider = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Provider name for list/enable/disable actions",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
IncludeRaw = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Include raw responses in consensus mode",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
JudgeModel = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Model to use as judge/aggregator in consensus",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
DevilsAdvocate = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Enable devil's advocate mode (10th model critiques others)",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
ConsensusSize = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Number of models to use in consensus (default: 3)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class LLMParams(TypedDict, total=False):
|
||||
"""Parameters for LLM tool."""
|
||||
|
||||
action: str
|
||||
model: Optional[str]
|
||||
models: Optional[List[str]]
|
||||
prompt: Optional[str]
|
||||
system_prompt: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: Optional[int]
|
||||
json_mode: bool
|
||||
stream: bool
|
||||
provider: Optional[str]
|
||||
include_raw: bool
|
||||
judge_model: Optional[str]
|
||||
devils_advocate: bool
|
||||
consensus_size: Optional[int]
|
||||
|
||||
|
||||
@final
|
||||
class UnifiedLLMTool(BaseTool):
|
||||
"""Unified LLM tool with multiple actions."""
|
||||
|
||||
# Config file for settings
|
||||
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "llm_config.json"
|
||||
|
||||
# Default consensus models in order of preference
|
||||
DEFAULT_CONSENSUS_MODELS = [
|
||||
"gpt-4o", # OpenAI's latest
|
||||
"claude-3-opus-20240229", # Claude's most capable
|
||||
"gemini/gemini-1.5-pro", # Google's best
|
||||
"groq/llama3-70b-8192", # Fast Groq
|
||||
"mistral/mistral-large-latest", # Mistral's best
|
||||
"perplexity/llama-3.1-sonar-large-128k-chat", # Perplexity with search
|
||||
]
|
||||
|
||||
# API key environment variables
|
||||
API_KEY_ENV_VARS = {
|
||||
"openai": ["OPENAI_API_KEY"],
|
||||
"anthropic": ["ANTHROPIC_API_KEY", "CLAUDE_API_KEY"],
|
||||
"google": ["GOOGLE_API_KEY", "GEMINI_API_KEY"],
|
||||
"groq": ["GROQ_API_KEY"],
|
||||
"mistral": ["MISTRAL_API_KEY"],
|
||||
"perplexity": ["PERPLEXITY_API_KEY", "PERPLEXITYAI_API_KEY"],
|
||||
"together": ["TOGETHER_API_KEY", "TOGETHERAI_API_KEY"],
|
||||
"cohere": ["COHERE_API_KEY"],
|
||||
"replicate": ["REPLICATE_API_KEY"],
|
||||
"huggingface": ["HUGGINGFACE_API_KEY", "HF_TOKEN"],
|
||||
"bedrock": ["AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"],
|
||||
"vertex": ["GOOGLE_APPLICATION_CREDENTIALS"],
|
||||
"azure": ["AZURE_API_KEY"],
|
||||
"voyage": ["VOYAGE_API_KEY"],
|
||||
"deepseek": ["DEEPSEEK_API_KEY"],
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the unified LLM tool."""
|
||||
self.available_providers = self._detect_available_providers()
|
||||
self.config = self._load_config()
|
||||
|
||||
def _detect_available_providers(self) -> Dict[str, List[str]]:
|
||||
"""Detect which providers have API keys configured."""
|
||||
available = {}
|
||||
|
||||
for provider, env_vars in self.API_KEY_ENV_VARS.items():
|
||||
for var in env_vars:
|
||||
if os.getenv(var):
|
||||
available[provider] = env_vars
|
||||
break
|
||||
|
||||
return available
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load configuration from file."""
|
||||
if self.CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(self.CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default config
|
||||
return {
|
||||
"disabled_providers": [],
|
||||
"consensus_models": None, # Use defaults if None
|
||||
"default_judge_model": "gpt-4o",
|
||||
"consensus_size": 3,
|
||||
}
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration to file."""
|
||||
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.CONFIG_FILE, "w") as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "llm"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
available = list(self.available_providers.keys())
|
||||
|
||||
return f"""Query LLMs. Default: single query. Actions: consensus, list, models, test.
|
||||
|
||||
Usage:
|
||||
llm "What is the capital of France?"
|
||||
llm "Explain this code" --model gpt-4o
|
||||
llm --action consensus "Is this approach correct?" --devils-advocate
|
||||
llm --action models --provider openai
|
||||
|
||||
Available: {", ".join(available) if available else "None"}"""
|
||||
|
||||
@override
|
||||
@auto_timeout("llm_unified")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[LLMParams],
|
||||
) -> str:
|
||||
"""Execute LLM action."""
|
||||
# Create tool context only if we have a proper MCP context
|
||||
tool_ctx = None
|
||||
try:
|
||||
if hasattr(ctx, "client") and ctx.client and hasattr(ctx.client, "server"):
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
if tool_ctx:
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
except Exception:
|
||||
# Running in test mode without MCP context
|
||||
pass
|
||||
|
||||
if not LITELLM_AVAILABLE:
|
||||
return "Error: LiteLLM is not installed. Install it with: pip install litellm"
|
||||
|
||||
# Extract action
|
||||
action = params.get("action", "query")
|
||||
|
||||
# Route to appropriate handler
|
||||
if action == "query":
|
||||
return await self._handle_query(tool_ctx, params)
|
||||
elif action == "consensus":
|
||||
return await self._handle_consensus(tool_ctx, params)
|
||||
elif action == "list":
|
||||
return self._handle_list()
|
||||
elif action == "models":
|
||||
return self._handle_models(params.get("provider"))
|
||||
elif action == "enable":
|
||||
return self._handle_enable(params.get("provider"))
|
||||
elif action == "disable":
|
||||
return self._handle_disable(params.get("provider"))
|
||||
elif action == "test":
|
||||
return await self._handle_test(tool_ctx, params.get("model"), params.get("provider"))
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: query, consensus, list, models, enable, disable, test"
|
||||
|
||||
async def _handle_query(self, tool_ctx, params: Dict[str, Any]) -> str:
|
||||
"""Handle single model query."""
|
||||
model = params.get("model")
|
||||
prompt = params.get("prompt")
|
||||
|
||||
if not prompt:
|
||||
return "Error: prompt is required for query action"
|
||||
|
||||
# Auto-select model if not specified
|
||||
if not model:
|
||||
if self.available_providers:
|
||||
# Use first available model
|
||||
if "openai" in self.available_providers:
|
||||
model = "gpt-4o-mini"
|
||||
elif "anthropic" in self.available_providers:
|
||||
model = "claude-3-haiku-20240307"
|
||||
elif "google" in self.available_providers:
|
||||
model = "gemini/gemini-1.5-flash"
|
||||
else:
|
||||
# Use first provider's default
|
||||
provider = list(self.available_providers.keys())[0]
|
||||
model = f"{provider}/default"
|
||||
else:
|
||||
return "Error: No model specified and no API keys found"
|
||||
|
||||
# Check if we have API key for this model
|
||||
provider = self._get_provider_for_model(model)
|
||||
if provider and provider not in self.available_providers:
|
||||
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
|
||||
return f"Error: No API key found for {provider}. Set one of: {', '.join(env_vars)}"
|
||||
|
||||
# Build messages
|
||||
messages = []
|
||||
if params.get("system_prompt"):
|
||||
messages.append({"role": "system", "content": params["system_prompt"]})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
# Build kwargs
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": params.get("temperature", 0.7),
|
||||
}
|
||||
|
||||
if params.get("max_tokens"):
|
||||
kwargs["max_tokens"] = params["max_tokens"]
|
||||
|
||||
if params.get("json_mode"):
|
||||
kwargs["response_format"] = {"type": "json_object"}
|
||||
|
||||
if params.get("stream"):
|
||||
kwargs["stream"] = True
|
||||
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Querying {model}...")
|
||||
|
||||
if kwargs.get("stream"):
|
||||
# Handle streaming response
|
||||
response_text = ""
|
||||
async for chunk in await litellm.acompletion(**kwargs):
|
||||
if chunk.choices[0].delta.content:
|
||||
response_text += chunk.choices[0].delta.content
|
||||
return response_text
|
||||
else:
|
||||
# Regular response
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
return response.choices[0].message.content
|
||||
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if "model_not_found" in error_msg or "does not exist" in error_msg:
|
||||
return f"Error: Model '{model}' not found. Use 'llm --action models' to see available models."
|
||||
else:
|
||||
return f"Error calling LLM: {error_msg}"
|
||||
|
||||
async def _handle_consensus(self, tool_ctx, params: Dict[str, Any]) -> str:
|
||||
"""Handle consensus mode with multiple models."""
|
||||
prompt = params.get("prompt")
|
||||
if not prompt:
|
||||
return "Error: prompt is required for consensus action"
|
||||
|
||||
# Determine models to use
|
||||
models = params.get("models")
|
||||
if not models:
|
||||
# Use configured or default models
|
||||
consensus_size = params.get("consensus_size") or self.config.get("consensus_size", 3)
|
||||
models = self._get_consensus_models(consensus_size)
|
||||
|
||||
if not models:
|
||||
return "Error: No models available for consensus. Set API keys for at least 2 providers."
|
||||
|
||||
if len(models) < 2:
|
||||
return "Error: Consensus requires at least 2 models"
|
||||
|
||||
# Check for devil's advocate mode
|
||||
devils_advocate = params.get("devils_advocate", False)
|
||||
if devils_advocate and len(models) < 3:
|
||||
return "Error: Devil's advocate mode requires at least 3 models"
|
||||
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Running consensus with {len(models)} models...")
|
||||
|
||||
# Query models in parallel
|
||||
system_prompt = params.get("system_prompt")
|
||||
temperature = params.get("temperature", 0.7)
|
||||
max_tokens = params.get("max_tokens")
|
||||
|
||||
# Split models if using devil's advocate
|
||||
if devils_advocate:
|
||||
consensus_models = models[:-1]
|
||||
devil_model = models[-1]
|
||||
else:
|
||||
consensus_models = models
|
||||
devil_model = None
|
||||
|
||||
# Query consensus models
|
||||
responses = await self._query_models_parallel(
|
||||
consensus_models, prompt, system_prompt, temperature, max_tokens, tool_ctx
|
||||
)
|
||||
|
||||
# Get devil's advocate response if enabled
|
||||
devil_response = None
|
||||
if devil_model:
|
||||
# Create devil's advocate prompt
|
||||
responses_text = "\n\n".join(
|
||||
[f"Model {i + 1}: {resp['response']}" for i, resp in enumerate(responses) if resp["response"]]
|
||||
)
|
||||
|
||||
devil_prompt = f"""You are a critical analyst. Review these responses to the question below and provide a devil's advocate perspective. Challenge assumptions, point out weaknesses, and suggest alternative viewpoints.
|
||||
|
||||
Original Question: {prompt}
|
||||
|
||||
Responses from other models:
|
||||
{responses_text}
|
||||
|
||||
Provide your critical analysis:"""
|
||||
|
||||
devil_result = await self._query_single_model(
|
||||
devil_model, devil_prompt, system_prompt, temperature, max_tokens
|
||||
)
|
||||
|
||||
if devil_result["success"]:
|
||||
devil_response = {
|
||||
"model": devil_model,
|
||||
"response": devil_result["response"],
|
||||
"time_ms": devil_result["time_ms"],
|
||||
}
|
||||
|
||||
# Aggregate responses
|
||||
judge_model = params.get("judge_model") or self.config.get("default_judge_model", "gpt-4o")
|
||||
include_raw = params.get("include_raw", False)
|
||||
|
||||
return await self._aggregate_consensus(responses, prompt, judge_model, include_raw, devil_response, tool_ctx)
|
||||
|
||||
def _handle_list(self) -> str:
|
||||
"""List available providers."""
|
||||
output = ["=== LLM Providers ==="]
|
||||
|
||||
# Get all possible providers
|
||||
all_providers = sorted(self.API_KEY_ENV_VARS.keys())
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
output.append(f"Total providers: {len(all_providers)}")
|
||||
output.append(f"Available: {len(self.available_providers)}")
|
||||
output.append(f"Disabled: {len(disabled)}\n")
|
||||
|
||||
for provider in all_providers:
|
||||
status_parts = []
|
||||
|
||||
# Check if API key exists
|
||||
if provider in self.available_providers:
|
||||
status_parts.append("✅ API key found")
|
||||
else:
|
||||
status_parts.append("❌ No API key")
|
||||
|
||||
# Check if disabled
|
||||
if provider in disabled:
|
||||
status_parts.append("🚫 Disabled")
|
||||
|
||||
# Show environment variables
|
||||
env_vars = self.API_KEY_ENV_VARS.get(provider, [])
|
||||
status = " | ".join(status_parts)
|
||||
|
||||
output.append(f"{provider}: {status}")
|
||||
output.append(f" Environment variables: {', '.join(env_vars)}")
|
||||
|
||||
output.append("\nUse 'llm --action enable/disable --provider <name>' to manage providers")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _handle_models(self, provider: Optional[str] = None) -> str:
|
||||
"""List available models."""
|
||||
try:
|
||||
all_models = self._get_all_models()
|
||||
|
||||
if not all_models:
|
||||
return "No models available or LiteLLM not properly initialized"
|
||||
|
||||
output = ["=== Available LLM Models ==="]
|
||||
|
||||
if provider:
|
||||
# Show models for specific provider
|
||||
provider_lower = provider.lower()
|
||||
models = all_models.get(provider_lower, [])
|
||||
|
||||
if not models:
|
||||
return f"No models found for provider '{provider}'"
|
||||
|
||||
output.append(f"\n{provider.upper()} ({len(models)} models):")
|
||||
output.append("-" * 40)
|
||||
|
||||
# Show first 50 models
|
||||
for model in models[:50]:
|
||||
output.append(f" {model}")
|
||||
|
||||
if len(models) > 50:
|
||||
output.append(f" ... and {len(models) - 50} more")
|
||||
else:
|
||||
# Show summary of all providers
|
||||
total_models = sum(len(models) for models in all_models.values())
|
||||
output.append(f"Total models available: {total_models}")
|
||||
output.append("")
|
||||
|
||||
# Show providers with counts
|
||||
for provider_name, models in sorted(all_models.items()):
|
||||
if models:
|
||||
available = "✅" if provider_name in self.available_providers else "❌"
|
||||
output.append(f"{available} {provider_name}: {len(models)} models")
|
||||
|
||||
output.append("\nUse 'llm --action models --provider <name>' to see specific models")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error listing models: {str(e)}"
|
||||
|
||||
def _handle_enable(self, provider: Optional[str]) -> str:
|
||||
"""Enable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for enable action"
|
||||
|
||||
provider = provider.lower()
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
if provider in disabled:
|
||||
disabled.remove(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully enabled {provider}"
|
||||
else:
|
||||
return f"{provider} is already enabled"
|
||||
|
||||
def _handle_disable(self, provider: Optional[str]) -> str:
|
||||
"""Disable a provider."""
|
||||
if not provider:
|
||||
return "Error: provider is required for disable action"
|
||||
|
||||
provider = provider.lower()
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
if provider not in disabled:
|
||||
disabled.append(provider)
|
||||
self.config["disabled_providers"] = disabled
|
||||
self._save_config()
|
||||
return f"Successfully disabled {provider}"
|
||||
else:
|
||||
return f"{provider} is already disabled"
|
||||
|
||||
async def _handle_test(self, tool_ctx, model: Optional[str], provider: Optional[str]) -> str:
|
||||
"""Test a model or provider."""
|
||||
if not model and not provider:
|
||||
return "Error: Either model or provider is required for test action"
|
||||
|
||||
# If provider specified, test its default model
|
||||
if provider and not model:
|
||||
provider = provider.lower()
|
||||
if provider == "openai":
|
||||
model = "gpt-3.5-turbo"
|
||||
elif provider == "anthropic":
|
||||
model = "claude-3-haiku-20240307"
|
||||
elif provider == "google":
|
||||
model = "gemini/gemini-1.5-flash"
|
||||
elif provider == "groq":
|
||||
model = "groq/llama3-8b-8192"
|
||||
else:
|
||||
model = f"{provider}/default"
|
||||
|
||||
# Test the model
|
||||
test_prompt = "Say 'Hello from Hanzo MCP!' in exactly 5 words."
|
||||
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Testing {model}...")
|
||||
|
||||
response = await litellm.acompletion(
|
||||
model=model,
|
||||
messages=[{"role": "user", "content": test_prompt}],
|
||||
temperature=0,
|
||||
max_tokens=20,
|
||||
)
|
||||
|
||||
result = response.choices[0].message.content
|
||||
return f"✅ {model} is working!\nResponse: {result}"
|
||||
|
||||
except Exception as e:
|
||||
return f"❌ {model} failed: {str(e)}"
|
||||
|
||||
def _get_consensus_models(self, size: int) -> List[str]:
|
||||
"""Get models for consensus based on availability."""
|
||||
# Use configured models if set
|
||||
configured = self.config.get("consensus_models")
|
||||
if configured:
|
||||
return configured[:size]
|
||||
|
||||
# Otherwise, build list from available providers
|
||||
models = []
|
||||
disabled = self.config.get("disabled_providers", [])
|
||||
|
||||
# Try default models first
|
||||
for model in self.DEFAULT_CONSENSUS_MODELS:
|
||||
if len(models) >= size:
|
||||
break
|
||||
|
||||
provider = self._get_provider_for_model(model)
|
||||
if provider and provider in self.available_providers and provider not in disabled:
|
||||
models.append(model)
|
||||
|
||||
# If still need more, add from available providers
|
||||
if len(models) < size:
|
||||
for provider in self.available_providers:
|
||||
if provider in disabled:
|
||||
continue
|
||||
|
||||
if provider == "openai" and "gpt-4o" not in models:
|
||||
models.append("gpt-4o")
|
||||
elif provider == "anthropic" and "claude-3-opus-20240229" not in models:
|
||||
models.append("claude-3-opus-20240229")
|
||||
elif provider == "google" and "gemini/gemini-1.5-pro" not in models:
|
||||
models.append("gemini/gemini-1.5-pro")
|
||||
|
||||
if len(models) >= size:
|
||||
break
|
||||
|
||||
return models
|
||||
|
||||
async def _query_models_parallel(
|
||||
self,
|
||||
models: List[str],
|
||||
prompt: str,
|
||||
system_prompt: Optional[str],
|
||||
temperature: float,
|
||||
max_tokens: Optional[int],
|
||||
tool_ctx,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Query multiple models in parallel."""
|
||||
|
||||
async def query_with_info(model: str) -> Dict[str, Any]:
|
||||
result = await self._query_single_model(model, prompt, system_prompt, temperature, max_tokens)
|
||||
return {
|
||||
"model": model,
|
||||
"response": result.get("response"),
|
||||
"success": result.get("success", False),
|
||||
"error": result.get("error"),
|
||||
"time_ms": result.get("time_ms", 0),
|
||||
}
|
||||
|
||||
# Run all queries in parallel
|
||||
tasks = [query_with_info(model) for model in models]
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
# Report results
|
||||
successful = sum(1 for r in results if r["success"])
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Completed {successful}/{len(models)} model queries")
|
||||
|
||||
return results
|
||||
|
||||
async def _query_single_model(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: Optional[str],
|
||||
temperature: float,
|
||||
max_tokens: Optional[int],
|
||||
) -> Dict[str, Any]:
|
||||
"""Query a single model and return result with metadata."""
|
||||
import time
|
||||
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
messages = []
|
||||
if system_prompt:
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
messages.append({"role": "user", "content": prompt})
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if max_tokens:
|
||||
kwargs["max_tokens"] = max_tokens
|
||||
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
"response": response.choices[0].message.content,
|
||||
"time_ms": int((time.time() - start_time) * 1000),
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
return {
|
||||
"success": False,
|
||||
"error": str(e),
|
||||
"time_ms": int((time.time() - start_time) * 1000),
|
||||
}
|
||||
|
||||
async def _aggregate_consensus(
|
||||
self,
|
||||
responses: List[Dict[str, Any]],
|
||||
original_prompt: str,
|
||||
judge_model: str,
|
||||
include_raw: bool,
|
||||
devil_response: Optional[Dict[str, Any]],
|
||||
tool_ctx,
|
||||
) -> str:
|
||||
"""Aggregate consensus responses using a judge model."""
|
||||
# Prepare response data
|
||||
successful_responses = [r for r in responses if r["success"]]
|
||||
|
||||
if not successful_responses:
|
||||
return "Error: All models failed to respond"
|
||||
|
||||
# Format responses for aggregation
|
||||
responses_text = "\n\n".join([f"Model: {r['model']}\nResponse: {r['response']}" for r in successful_responses])
|
||||
|
||||
if devil_response:
|
||||
responses_text += f"\n\nDevil's Advocate ({devil_response['model']}):\n{devil_response['response']}"
|
||||
|
||||
# Create aggregation prompt
|
||||
aggregation_prompt = f"""Analyze the following responses from multiple AI models to this question:
|
||||
|
||||
<original_question>
|
||||
{original_prompt}
|
||||
</original_question>
|
||||
|
||||
<model_responses>
|
||||
{responses_text}
|
||||
</model_responses>
|
||||
|
||||
Please provide:
|
||||
1. A synthesis of the key points where models agree
|
||||
2. Notable differences or disagreements between responses
|
||||
3. A balanced conclusion incorporating the best insights
|
||||
{f"4. Evaluation of the devil's advocate critique" if devil_response else ""}
|
||||
|
||||
Be concise and highlight the most important findings."""
|
||||
|
||||
# Get aggregation
|
||||
try:
|
||||
if tool_ctx:
|
||||
await tool_ctx.info(f"Aggregating responses with {judge_model}...")
|
||||
|
||||
judge_result = await self._query_single_model(judge_model, aggregation_prompt, None, 0.3, None)
|
||||
|
||||
if not judge_result["success"]:
|
||||
return f"Error: Judge model failed: {judge_result.get('error', 'Unknown error')}"
|
||||
|
||||
# Format output
|
||||
output = [f"=== Consensus Analysis ({len(successful_responses)} models) ===\n"]
|
||||
output.append(judge_result["response"])
|
||||
|
||||
# Add model list
|
||||
output.append(f"\nModels consulted: {', '.join([r['model'] for r in successful_responses])}")
|
||||
if devil_response:
|
||||
output.append(f"Devil's Advocate: {devil_response['model']}")
|
||||
|
||||
# Add timing info
|
||||
avg_time = sum(r["time_ms"] for r in responses) / len(responses)
|
||||
output.append(f"\nAverage response time: {avg_time:.0f}ms")
|
||||
|
||||
# Include raw responses if requested
|
||||
if include_raw:
|
||||
output.append("\n\n=== Raw Responses ===")
|
||||
for r in successful_responses:
|
||||
output.append(f"\n{r['model']}:")
|
||||
output.append("-" * 40)
|
||||
output.append(r["response"])
|
||||
|
||||
if devil_response:
|
||||
output.append(f"\nDevil's Advocate ({devil_response['model']}):")
|
||||
output.append("-" * 40)
|
||||
output.append(devil_response["response"])
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error during aggregation: {str(e)}"
|
||||
|
||||
def _get_provider_for_model(self, model: str) -> Optional[str]:
|
||||
"""Determine the provider for a given model."""
|
||||
model_lower = model.lower()
|
||||
|
||||
# Check explicit provider prefix
|
||||
if "/" in model:
|
||||
return model.split("/")[0]
|
||||
|
||||
# Check model prefixes
|
||||
if model_lower.startswith("gpt"):
|
||||
return "openai"
|
||||
elif model_lower.startswith("claude"):
|
||||
return "anthropic"
|
||||
elif model_lower.startswith("gemini"):
|
||||
return "google"
|
||||
elif model_lower.startswith("command"):
|
||||
return "cohere"
|
||||
|
||||
# Default to OpenAI
|
||||
return "openai"
|
||||
|
||||
def _get_all_models(self) -> Dict[str, List[str]]:
|
||||
"""Get all available models from LiteLLM."""
|
||||
try:
|
||||
import litellm
|
||||
|
||||
# Get all models
|
||||
all_models = litellm.model_list
|
||||
|
||||
# Organize by provider
|
||||
providers = {}
|
||||
|
||||
for model in all_models:
|
||||
# Extract provider
|
||||
if "/" in model:
|
||||
provider = model.split("/")[0]
|
||||
elif model.startswith("gpt"):
|
||||
provider = "openai"
|
||||
elif model.startswith("claude"):
|
||||
provider = "anthropic"
|
||||
elif model.startswith("gemini"):
|
||||
provider = "google"
|
||||
elif model.startswith("command"):
|
||||
provider = "cohere"
|
||||
else:
|
||||
provider = "other"
|
||||
|
||||
if provider not in providers:
|
||||
providers[provider] = []
|
||||
providers[provider].append(model)
|
||||
|
||||
# Sort models within each provider
|
||||
for provider in providers:
|
||||
providers[provider] = sorted(providers[provider])
|
||||
|
||||
return providers
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,411 @@
|
||||
"""Provider-specific LLM tools."""
|
||||
|
||||
from typing import Dict, Unpack, Optional, Annotated, TypedDict, final, override
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from .llm_tool import LLMTool
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
Prompt = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The prompt or question to send to the model",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Model = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Specific model variant (defaults to provider's best model)",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
SystemPrompt = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="System prompt to set context",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Temperature = Annotated[
|
||||
float,
|
||||
Field(
|
||||
description="Temperature for response randomness (0.0-2.0)",
|
||||
default=0.7,
|
||||
),
|
||||
]
|
||||
|
||||
MaxTokens = Annotated[
|
||||
Optional[int],
|
||||
Field(
|
||||
description="Maximum tokens in response",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
JsonMode = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Request JSON formatted response",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class ProviderToolParams(TypedDict, total=False):
|
||||
"""Parameters for provider-specific tools."""
|
||||
|
||||
prompt: str
|
||||
model: Optional[str]
|
||||
system_prompt: Optional[str]
|
||||
temperature: float
|
||||
max_tokens: Optional[int]
|
||||
json_mode: bool
|
||||
|
||||
|
||||
class BaseProviderTool(BaseTool):
|
||||
"""Base class for provider-specific LLM tools."""
|
||||
|
||||
def __init__(self, provider: str, default_model: str, model_variants: Dict[str, str]):
|
||||
"""Initialize provider tool.
|
||||
|
||||
Args:
|
||||
provider: Provider name
|
||||
default_model: Default model to use
|
||||
model_variants: Map of short names to full model names
|
||||
"""
|
||||
self.provider = provider
|
||||
self.default_model = default_model
|
||||
self.model_variants = model_variants
|
||||
self.llm_tool = LLMTool()
|
||||
self.is_available = provider in self.llm_tool.available_providers
|
||||
|
||||
def get_full_model_name(self, model: Optional[str]) -> str:
|
||||
"""Get full model name from short name or default."""
|
||||
if not model:
|
||||
return self.default_model
|
||||
|
||||
# Check if it's a short name
|
||||
if model in self.model_variants:
|
||||
return self.model_variants[model]
|
||||
|
||||
# Return as-is if not found (assume full name)
|
||||
return model
|
||||
|
||||
@override
|
||||
@auto_timeout("provider_tools")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[ProviderToolParams],
|
||||
) -> str:
|
||||
"""Call the provider's LLM."""
|
||||
if not self.is_available:
|
||||
env_vars = LLMTool.API_KEY_ENV_VARS.get(self.provider, [])
|
||||
return f"Error: {self.provider.title()} API key not found. Set one of: {', '.join(env_vars)}"
|
||||
|
||||
# Get full model name
|
||||
model = self.get_full_model_name(params.get("model"))
|
||||
|
||||
# Prepare LLM tool parameters
|
||||
llm_params = {
|
||||
"model": model,
|
||||
"prompt": params["prompt"],
|
||||
}
|
||||
|
||||
# Add optional parameters
|
||||
if "system_prompt" in params:
|
||||
llm_params["system_prompt"] = params["system_prompt"]
|
||||
if "temperature" in params:
|
||||
llm_params["temperature"] = params["temperature"]
|
||||
if "max_tokens" in params:
|
||||
llm_params["max_tokens"] = params["max_tokens"]
|
||||
if "json_mode" in params:
|
||||
llm_params["json_mode"] = params["json_mode"]
|
||||
|
||||
# Call the LLM tool
|
||||
return await self.llm_tool.call(ctx, **llm_params)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
|
||||
|
||||
@final
|
||||
class OpenAITool(BaseProviderTool):
|
||||
"""OpenAI-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="openai",
|
||||
default_model="gpt-4o",
|
||||
model_variants={
|
||||
"4o": "gpt-4o",
|
||||
"4": "gpt-4",
|
||||
"3.5": "gpt-3.5-turbo",
|
||||
"o1": "o1-preview",
|
||||
"o1-mini": "o1-mini",
|
||||
"4-turbo": "gpt-4-turbo-preview",
|
||||
"4-vision": "gpt-4-vision-preview",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "openai"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query OpenAI models directly ({status}).
|
||||
|
||||
Models:
|
||||
- 4o (default): GPT-4o - Latest and most capable
|
||||
- 4: GPT-4 - Advanced reasoning
|
||||
- 3.5: GPT-3.5 Turbo - Fast and efficient
|
||||
- o1: O1 Preview - Chain of thought reasoning
|
||||
- o1-mini: O1 Mini - Smaller reasoning model
|
||||
|
||||
Examples:
|
||||
- openai --prompt "Explain quantum computing"
|
||||
- openai --model 4 --prompt "Write a Python function"
|
||||
- openai --model o1 --prompt "Solve this step by step"
|
||||
"""
|
||||
|
||||
|
||||
@final
|
||||
class AnthropicTool(BaseProviderTool):
|
||||
"""Anthropic-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="anthropic",
|
||||
default_model="claude-3-sonnet-20240229",
|
||||
model_variants={
|
||||
"opus": "claude-3-opus-20240229",
|
||||
"sonnet": "claude-3-sonnet-20240229",
|
||||
"haiku": "claude-3-haiku-20240307",
|
||||
"2.1": "claude-2.1",
|
||||
"2": "claude-2",
|
||||
"instant": "claude-instant-1.2",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "anthropic"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query Anthropic Claude models directly ({status}).
|
||||
|
||||
Models:
|
||||
- sonnet (default): Claude 3 Sonnet - Balanced performance
|
||||
- opus: Claude 3 Opus - Most capable
|
||||
- haiku: Claude 3 Haiku - Fast and efficient
|
||||
- 2.1: Claude 2.1 - Previous generation
|
||||
- instant: Claude Instant - Very fast
|
||||
|
||||
Examples:
|
||||
- anthropic --prompt "Analyze this code"
|
||||
- anthropic --model opus --prompt "Write a detailed essay"
|
||||
- anthropic --model haiku --prompt "Quick question"
|
||||
"""
|
||||
|
||||
|
||||
@final
|
||||
class GeminiTool(BaseProviderTool):
|
||||
"""Google Gemini-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="google",
|
||||
default_model="gemini/gemini-pro",
|
||||
model_variants={
|
||||
"pro": "gemini/gemini-pro",
|
||||
"pro-vision": "gemini/gemini-pro-vision",
|
||||
"1.5-pro": "gemini/gemini-1.5-pro-latest",
|
||||
"1.5-flash": "gemini/gemini-1.5-flash-latest",
|
||||
"ultra": "gemini/gemini-ultra",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "gemini"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query Google Gemini models directly ({status}).
|
||||
|
||||
Models:
|
||||
- pro (default): Gemini Pro - Balanced model
|
||||
- 1.5-pro: Gemini 1.5 Pro - Advanced with long context
|
||||
- 1.5-flash: Gemini 1.5 Flash - Fast and efficient
|
||||
- pro-vision: Gemini Pro Vision - Multimodal
|
||||
- ultra: Gemini Ultra - Most capable (if available)
|
||||
|
||||
Examples:
|
||||
- gemini --prompt "Explain this concept"
|
||||
- gemini --model 1.5-pro --prompt "Analyze this long document"
|
||||
- gemini --model 1.5-flash --prompt "Quick task"
|
||||
"""
|
||||
|
||||
|
||||
@final
|
||||
class GroqTool(BaseProviderTool):
|
||||
"""Groq-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="groq",
|
||||
default_model="groq/mixtral-8x7b-32768",
|
||||
model_variants={
|
||||
"mixtral": "groq/mixtral-8x7b-32768",
|
||||
"llama3-70b": "groq/llama3-70b-8192",
|
||||
"llama3-8b": "groq/llama3-8b-8192",
|
||||
"llama2-70b": "groq/llama2-70b-4096",
|
||||
"gemma-7b": "groq/gemma-7b-it",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "groq"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query Groq LPU models - ultra-fast inference ({status}).
|
||||
|
||||
Models:
|
||||
- mixtral (default): Mixtral 8x7B - High quality
|
||||
- llama3-70b: Llama 3 70B - Very capable
|
||||
- llama3-8b: Llama 3 8B - Fast and efficient
|
||||
- llama2-70b: Llama 2 70B - Previous gen
|
||||
- gemma-7b: Google Gemma 7B - Efficient
|
||||
|
||||
Examples:
|
||||
- groq --prompt "Fast response needed"
|
||||
- groq --model llama3-70b --prompt "Complex reasoning"
|
||||
- groq --model gemma-7b --prompt "Quick task"
|
||||
"""
|
||||
|
||||
|
||||
@final
|
||||
class MistralTool(BaseProviderTool):
|
||||
"""Mistral-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="mistral",
|
||||
default_model="mistral/mistral-medium",
|
||||
model_variants={
|
||||
"tiny": "mistral/mistral-tiny",
|
||||
"small": "mistral/mistral-small-latest",
|
||||
"medium": "mistral/mistral-medium-latest",
|
||||
"large": "mistral/mistral-large-latest",
|
||||
"embed": "mistral/mistral-embed",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "mistral"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query Mistral AI models directly ({status}).
|
||||
|
||||
Models:
|
||||
- medium (default): Mistral Medium - Balanced
|
||||
- large: Mistral Large - Most capable
|
||||
- small: Mistral Small - Efficient
|
||||
- tiny: Mistral Tiny - Very fast
|
||||
|
||||
Examples:
|
||||
- mistral --prompt "Explain this"
|
||||
- mistral --model large --prompt "Complex analysis"
|
||||
- mistral --model tiny --prompt "Quick response"
|
||||
"""
|
||||
|
||||
|
||||
@final
|
||||
class PerplexityTool(BaseProviderTool):
|
||||
"""Perplexity-specific LLM tool."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
provider="perplexity",
|
||||
default_model="perplexity/sonar-medium-online",
|
||||
model_variants={
|
||||
"sonar-small": "perplexity/sonar-small-online",
|
||||
"sonar-medium": "perplexity/sonar-medium-online",
|
||||
"sonar-small-chat": "perplexity/sonar-small-chat",
|
||||
"sonar-medium-chat": "perplexity/sonar-medium-chat",
|
||||
},
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
return "perplexity"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
status = "✓ Available" if self.is_available else "✗ No API key"
|
||||
return f"""Query Perplexity models with internet access ({status}).
|
||||
|
||||
Models:
|
||||
- sonar-medium (default): Online search + reasoning
|
||||
- sonar-small: Faster online search
|
||||
- sonar-medium-chat: Chat without search
|
||||
- sonar-small-chat: Fast chat without search
|
||||
|
||||
Examples:
|
||||
- perplexity --prompt "Latest news about AI"
|
||||
- perplexity --model sonar-small --prompt "Quick fact check"
|
||||
- perplexity --model sonar-medium-chat --prompt "Explain without search"
|
||||
"""
|
||||
|
||||
|
||||
# Export all provider tools
|
||||
PROVIDER_TOOLS = [
|
||||
OpenAITool,
|
||||
AnthropicTool,
|
||||
GeminiTool,
|
||||
GroqTool,
|
||||
MistralTool,
|
||||
PerplexityTool,
|
||||
]
|
||||
|
||||
|
||||
def create_provider_tools() -> list[BaseTool]:
|
||||
"""Create instances of all provider tools."""
|
||||
tools = []
|
||||
for tool_class in PROVIDER_TOOLS:
|
||||
tool = tool_class()
|
||||
# Only include tools with available API keys
|
||||
if tool.is_available:
|
||||
tools.append(tool)
|
||||
return tools
|
||||
@@ -5,20 +5,21 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "hanzo-tools-llm"
|
||||
version = "0.1.0"
|
||||
description = "LLM provider tools - unified interface to 100+ providers"
|
||||
readme = "README.md"
|
||||
description = "LLM tools for Hanzo AI - model management, consensus, providers"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "tools", "llm", "openai", "anthropic", "mcp", "ai"]
|
||||
keywords = ["hanzo", "tools", "llm", "mcp", "ai"]
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"mcp>=1.25.0",
|
||||
"fastmcp>=2.14.1",
|
||||
"pydantic>=2.12.5",
|
||||
"litellm>=1.80.0",
|
||||
"openai>=1.82.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
full = ["litellm>=1.0.0", "openai>=1.0.0", "anthropic>=0.40.0"]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
llm = "hanzo_tools.llm:TOOLS"
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Hanzo tools namespace package."""
|
||||
import pkgutil
|
||||
__path__ = pkgutil.extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,77 @@
|
||||
"""MCP management tools for Hanzo AI.
|
||||
|
||||
Tools:
|
||||
- mcp: MCP server management
|
||||
- mcp_add: Add MCP servers
|
||||
- mcp_remove: Remove MCP servers
|
||||
- mcp_stats: MCP statistics
|
||||
|
||||
Install:
|
||||
pip install hanzo-tools-mcp
|
||||
"""
|
||||
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_tools = []
|
||||
|
||||
try:
|
||||
from .mcp_tool import MCPTool
|
||||
_tools.append(MCPTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"MCPTool not available: {e}")
|
||||
MCPTool = None
|
||||
|
||||
try:
|
||||
from .mcp_add import McpAddTool
|
||||
_tools.append(McpAddTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpAddTool not available: {e}")
|
||||
McpAddTool = None
|
||||
|
||||
try:
|
||||
from .mcp_remove import McpRemoveTool
|
||||
_tools.append(McpRemoveTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpRemoveTool not available: {e}")
|
||||
McpRemoveTool = None
|
||||
|
||||
try:
|
||||
from .mcp_stats import McpStatsTool
|
||||
_tools.append(McpStatsTool)
|
||||
except ImportError as e:
|
||||
logger.debug(f"McpStatsTool not available: {e}")
|
||||
McpStatsTool = None
|
||||
|
||||
TOOLS = _tools
|
||||
|
||||
__all__ = [
|
||||
"TOOLS",
|
||||
"MCPTool",
|
||||
"McpAddTool",
|
||||
"McpRemoveTool",
|
||||
"McpStatsTool",
|
||||
"register_tools",
|
||||
]
|
||||
|
||||
|
||||
def register_tools(mcp_server, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register MCP tools with MCP server."""
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
if tool_class is None:
|
||||
continue
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
return registered
|
||||
@@ -0,0 +1,265 @@
|
||||
"""Add MCP servers dynamically."""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
from typing import Any, Dict, Unpack, Optional, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
ServerCommand = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Server command (e.g., 'uvx mcp-server-git', 'npx @modelcontextprotocol/server-filesystem')",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
ServerName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Unique name for the server",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Args = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Additional arguments for the server",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Env = Annotated[
|
||||
Optional[Dict[str, str]],
|
||||
Field(
|
||||
description="Environment variables for the server",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
AutoStart = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Automatically start the server after adding",
|
||||
default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class McpAddParams(TypedDict, total=False):
|
||||
"""Parameters for MCP add tool."""
|
||||
|
||||
command: str
|
||||
name: str
|
||||
args: Optional[str]
|
||||
env: Optional[Dict[str, str]]
|
||||
auto_start: bool
|
||||
|
||||
|
||||
@final
|
||||
class McpAddTool(BaseTool):
|
||||
"""Tool for adding MCP servers dynamically."""
|
||||
|
||||
# Class variable to store added servers
|
||||
_mcp_servers: Dict[str, Dict[str, Any]] = {}
|
||||
_config_file = Path.home() / ".hanzo" / "mcp" / "servers.json"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the MCP add tool."""
|
||||
# Load existing servers from config
|
||||
self._load_servers()
|
||||
|
||||
@classmethod
|
||||
def _load_servers(cls):
|
||||
"""Load servers from config file."""
|
||||
if cls._config_file.exists():
|
||||
try:
|
||||
with open(cls._config_file, "r") as f:
|
||||
cls._mcp_servers = json.load(f)
|
||||
except Exception:
|
||||
cls._mcp_servers = {}
|
||||
|
||||
@classmethod
|
||||
def _save_servers(cls):
|
||||
"""Save servers to config file."""
|
||||
cls._config_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(cls._config_file, "w") as f:
|
||||
json.dump(cls._mcp_servers, f, indent=2)
|
||||
|
||||
@classmethod
|
||||
def get_servers(cls) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get all registered MCP servers."""
|
||||
return cls._mcp_servers.copy()
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "mcp_add"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Add MCP (Model Context Protocol) servers dynamically.
|
||||
|
||||
This allows adding new MCP servers that provide additional tools.
|
||||
Servers can be from npm packages or Python packages.
|
||||
|
||||
Common MCP servers:
|
||||
- @modelcontextprotocol/server-filesystem - File system access
|
||||
- @modelcontextprotocol/server-github - GitHub integration
|
||||
- @modelcontextprotocol/server-gitlab - GitLab integration
|
||||
- @modelcontextprotocol/server-postgres - PostgreSQL access
|
||||
- @modelcontextprotocol/server-sqlite - SQLite access
|
||||
- mcp-server-git - Git operations
|
||||
- mcp-server-docker - Docker management
|
||||
- mcp-server-kubernetes - K8s management
|
||||
|
||||
Examples:
|
||||
- mcp_add --command "npx @modelcontextprotocol/server-filesystem" --name filesystem --args "/path/to/allow"
|
||||
- mcp_add --command "uvx mcp-server-git" --name git --args "--repository /path/to/repo"
|
||||
- mcp_add --command "npx @modelcontextprotocol/server-github" --name github --env '{"GITHUB_TOKEN": "..."}'
|
||||
|
||||
Use 'mcp_stats' to see all added servers and their status.
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("mcp_add")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[McpAddParams],
|
||||
) -> str:
|
||||
"""Add an MCP server.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Result of adding the server
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Extract parameters
|
||||
command = params.get("command")
|
||||
if not command:
|
||||
return "Error: command is required"
|
||||
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return "Error: name is required"
|
||||
|
||||
args = params.get("args")
|
||||
env = params.get("env", {})
|
||||
auto_start = params.get("auto_start", True)
|
||||
|
||||
# Check if server already exists
|
||||
if name in self._mcp_servers:
|
||||
return f"Error: Server '{name}' already exists. Use mcp_remove to remove it first."
|
||||
|
||||
# Parse command to determine type
|
||||
server_type = "unknown"
|
||||
if command.startswith("npx"):
|
||||
server_type = "node"
|
||||
elif command.startswith("uvx") or command.startswith("python"):
|
||||
server_type = "python"
|
||||
elif command.startswith("node"):
|
||||
server_type = "node"
|
||||
|
||||
# Build full command
|
||||
full_command = [command]
|
||||
if args:
|
||||
import shlex
|
||||
|
||||
# If command contains spaces, split it first
|
||||
if " " in command:
|
||||
full_command = shlex.split(command)
|
||||
full_command.extend(shlex.split(args))
|
||||
else:
|
||||
if " " in command:
|
||||
import shlex
|
||||
|
||||
full_command = shlex.split(command)
|
||||
|
||||
await tool_ctx.info(f"Adding MCP server '{name}' with command: {' '.join(full_command)}")
|
||||
|
||||
# Create server configuration
|
||||
server_config = {
|
||||
"command": full_command,
|
||||
"name": name,
|
||||
"type": server_type,
|
||||
"env": env,
|
||||
"status": "stopped",
|
||||
"process_id": None,
|
||||
"tools": [],
|
||||
"resources": [],
|
||||
"prompts": [],
|
||||
}
|
||||
|
||||
# Test if command is valid
|
||||
if auto_start:
|
||||
try:
|
||||
# Try to start the server briefly to validate
|
||||
test_env = {**env} if env else {}
|
||||
|
||||
# Quick test to see if command exists
|
||||
test_cmd = full_command[0]
|
||||
if test_cmd == "npx":
|
||||
if not shutil.which("npx"):
|
||||
return "Error: npx not found. Install Node.js first."
|
||||
elif test_cmd == "uvx":
|
||||
if not shutil.which("uvx"):
|
||||
return "Error: uvx not found. Install uv first."
|
||||
|
||||
# Server is validated and ready to be used
|
||||
# The actual connection happens when tools are invoked
|
||||
server_config["status"] = "ready"
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Failed to validate server: {str(e)}")
|
||||
server_config["status"] = "error"
|
||||
server_config["error"] = str(e)
|
||||
|
||||
# Add server to registry
|
||||
self._mcp_servers[name] = server_config
|
||||
self._save_servers()
|
||||
|
||||
output = [
|
||||
f"Successfully added MCP server '{name}':",
|
||||
f" Type: {server_type}",
|
||||
f" Command: {' '.join(full_command)}",
|
||||
f" Status: {server_config['status']}",
|
||||
]
|
||||
|
||||
if env:
|
||||
output.append(f" Environment: {list(env.keys())}")
|
||||
|
||||
output.extend(
|
||||
[
|
||||
"",
|
||||
"Use 'mcp_stats' to see server details.",
|
||||
f"Use 'mcp_remove --name {name}' to remove this server.",
|
||||
]
|
||||
)
|
||||
|
||||
# Note: In a real implementation, we would:
|
||||
# 1. Start the MCP server process
|
||||
# 2. Connect to it via stdio/HTTP
|
||||
# 3. Query its capabilities (tools, resources, prompts)
|
||||
# 4. Register those with our MCP server
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,137 @@
|
||||
"""Remove MCP servers."""
|
||||
|
||||
from typing import Unpack, Annotated, TypedDict, final, override
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from .mcp_add import McpAddTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
ServerName = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Name of the server to remove",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
Force = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Force removal even if server is running",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class McpRemoveParams(TypedDict, total=False):
|
||||
"""Parameters for MCP remove tool."""
|
||||
|
||||
name: str
|
||||
force: bool
|
||||
|
||||
|
||||
@final
|
||||
class McpRemoveTool(BaseTool):
|
||||
"""Tool for removing MCP servers."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the MCP remove tool."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "mcp_remove"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Remove previously added MCP servers.
|
||||
|
||||
This removes MCP servers that were added with mcp_add.
|
||||
If the server is running, it will be stopped first.
|
||||
|
||||
Examples:
|
||||
- mcp_remove --name filesystem
|
||||
- mcp_remove --name github --force
|
||||
|
||||
Use 'mcp_stats' to see all servers before removing.
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("mcp_remove")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[McpRemoveParams],
|
||||
) -> str:
|
||||
"""Remove an MCP server.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Result of removing the server
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Extract parameters
|
||||
name = params.get("name")
|
||||
if not name:
|
||||
return "Error: name is required"
|
||||
|
||||
force = params.get("force", False)
|
||||
|
||||
# Get current servers
|
||||
servers = McpAddTool.get_servers()
|
||||
|
||||
if name not in servers:
|
||||
return f"Error: Server '{name}' not found. Use 'mcp_stats' to see available servers."
|
||||
|
||||
server = servers[name]
|
||||
|
||||
await tool_ctx.info(f"Removing MCP server '{name}'")
|
||||
|
||||
# Check if server is running
|
||||
if server.get("status") == "running" and server.get("process_id"):
|
||||
if not force:
|
||||
return f"Error: Server '{name}' is currently running. Use --force to remove anyway."
|
||||
else:
|
||||
# Stop the server process if it's running
|
||||
process_id = server.get("process_id")
|
||||
if process_id:
|
||||
try:
|
||||
import os
|
||||
import signal
|
||||
|
||||
os.kill(process_id, signal.SIGTERM)
|
||||
await tool_ctx.info(f"Stopped running server '{name}' (PID: {process_id})")
|
||||
except ProcessLookupError:
|
||||
await tool_ctx.info(f"Server '{name}' process not found (already stopped)")
|
||||
|
||||
# Remove from registry
|
||||
del McpAddTool._mcp_servers[name]
|
||||
McpAddTool._save_servers()
|
||||
|
||||
output = [
|
||||
f"Successfully removed MCP server '{name}'",
|
||||
f" Type: {server.get('type', 'unknown')}",
|
||||
f" Command: {' '.join(server.get('command', []))}",
|
||||
]
|
||||
|
||||
if server.get("tools"):
|
||||
output.append(f" Tools removed: {len(server['tools'])}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,166 @@
|
||||
"""MCP server statistics."""
|
||||
|
||||
from typing import Unpack, TypedDict, final, override
|
||||
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from .mcp_add import McpAddTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
|
||||
class McpStatsParams(TypedDict, total=False):
|
||||
"""Parameters for MCP stats tool."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
@final
|
||||
class McpStatsTool(BaseTool):
|
||||
"""Tool for showing MCP server statistics."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the MCP stats tool."""
|
||||
pass
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "mcp_stats"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Show statistics about added MCP servers.
|
||||
|
||||
Displays:
|
||||
- Total number of servers
|
||||
- Server types (Python, Node.js)
|
||||
- Server status (running, stopped, error)
|
||||
- Available tools from each server
|
||||
- Resource usage per server
|
||||
|
||||
Example:
|
||||
- mcp_stats
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("mcp_stats")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[McpStatsParams],
|
||||
) -> str:
|
||||
"""Get MCP server statistics.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
MCP server statistics
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Get all servers
|
||||
servers = McpAddTool.get_servers()
|
||||
|
||||
if not servers:
|
||||
return "No MCP servers have been added yet.\n\nUse 'mcp_add' to add servers."
|
||||
|
||||
output = []
|
||||
output.append("=== MCP Server Statistics ===")
|
||||
output.append(f"Total Servers: {len(servers)}")
|
||||
output.append("")
|
||||
|
||||
# Count by type
|
||||
type_counts = {}
|
||||
status_counts = {}
|
||||
total_tools = 0
|
||||
total_resources = 0
|
||||
|
||||
for server in servers.values():
|
||||
# Count types
|
||||
server_type = server.get("type", "unknown")
|
||||
type_counts[server_type] = type_counts.get(server_type, 0) + 1
|
||||
|
||||
# Count status
|
||||
status = server.get("status", "unknown")
|
||||
status_counts[status] = status_counts.get(status, 0) + 1
|
||||
|
||||
# Count tools and resources
|
||||
total_tools += len(server.get("tools", []))
|
||||
total_resources += len(server.get("resources", []))
|
||||
|
||||
# Server types
|
||||
output.append("Server Types:")
|
||||
for stype, count in sorted(type_counts.items()):
|
||||
output.append(f" {stype}: {count}")
|
||||
output.append("")
|
||||
|
||||
# Server status
|
||||
output.append("Server Status:")
|
||||
for status, count in sorted(status_counts.items()):
|
||||
output.append(f" {status}: {count}")
|
||||
output.append("")
|
||||
|
||||
# Tools and resources
|
||||
output.append(f"Total Tools Available: {total_tools}")
|
||||
output.append(f"Total Resources Available: {total_resources}")
|
||||
output.append("")
|
||||
|
||||
# Individual server details
|
||||
output.append("=== Server Details ===")
|
||||
|
||||
for name, server in sorted(servers.items()):
|
||||
output.append(f"\n{name}:")
|
||||
output.append(f" Type: {server.get('type', 'unknown')}")
|
||||
output.append(f" Status: {server.get('status', 'unknown')}")
|
||||
output.append(f" Command: {' '.join(server.get('command', []))}")
|
||||
|
||||
if server.get("process_id"):
|
||||
output.append(f" Process ID: {server['process_id']}")
|
||||
|
||||
if server.get("error"):
|
||||
output.append(f" Error: {server['error']}")
|
||||
|
||||
tools = server.get("tools", [])
|
||||
if tools:
|
||||
output.append(f" Tools ({len(tools)}):")
|
||||
for tool in tools[:5]: # Show first 5
|
||||
output.append(f" - {tool}")
|
||||
if len(tools) > 5:
|
||||
output.append(f" ... and {len(tools) - 5} more")
|
||||
|
||||
resources = server.get("resources", [])
|
||||
if resources:
|
||||
output.append(f" Resources ({len(resources)}):")
|
||||
for resource in resources[:5]: # Show first 5
|
||||
output.append(f" - {resource}")
|
||||
if len(resources) > 5:
|
||||
output.append(f" ... and {len(resources) - 5} more")
|
||||
|
||||
if server.get("env"):
|
||||
output.append(f" Environment vars: {list(server['env'].keys())}")
|
||||
|
||||
# Common MCP servers hint
|
||||
output.append("\n=== Available MCP Servers ===")
|
||||
output.append("Common servers you can add:")
|
||||
output.append(" - @modelcontextprotocol/server-filesystem")
|
||||
output.append(" - @modelcontextprotocol/server-github")
|
||||
output.append(" - mcp-server-git")
|
||||
output.append(" - @modelcontextprotocol/server-postgres")
|
||||
output.append(" - @modelcontextprotocol/server-browser-use")
|
||||
output.append(" - @modelcontextprotocol/server-iterm2")
|
||||
output.append(" - @modelcontextprotocol/server-linear")
|
||||
output.append(" - @modelcontextprotocol/server-slack")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,522 @@
|
||||
"""Unified MCP tool for managing MCP servers."""
|
||||
|
||||
import os
|
||||
import json
|
||||
import signal
|
||||
import subprocess
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
List,
|
||||
Unpack,
|
||||
Optional,
|
||||
Annotated,
|
||||
TypedDict,
|
||||
final,
|
||||
override,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
# Parameter types
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action to perform: list, add, remove, enable, disable, restart, config",
|
||||
default="list",
|
||||
),
|
||||
]
|
||||
|
||||
Name = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="MCP server name",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Command = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Command to run the MCP server",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Args = Annotated[
|
||||
Optional[List[str]],
|
||||
Field(
|
||||
description="Arguments for the MCP server command",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Env = Annotated[
|
||||
Optional[Dict[str, str]],
|
||||
Field(
|
||||
description="Environment variables for the MCP server",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
ConfigKey = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Configuration key to get/set",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
ConfigValue = Annotated[
|
||||
Optional[Any],
|
||||
Field(
|
||||
description="Configuration value to set",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
AutoStart = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Auto-start server when Hanzo AI starts",
|
||||
default=True,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class MCPParams(TypedDict, total=False):
|
||||
"""Parameters for MCP tool."""
|
||||
|
||||
action: str
|
||||
name: Optional[str]
|
||||
command: Optional[str]
|
||||
args: Optional[List[str]]
|
||||
env: Optional[Dict[str, str]]
|
||||
config_key: Optional[str]
|
||||
config_value: Optional[Any]
|
||||
auto_start: bool
|
||||
|
||||
|
||||
@final
|
||||
class MCPTool(BaseTool):
|
||||
"""Tool for managing MCP servers."""
|
||||
|
||||
# Config file
|
||||
CONFIG_FILE = Path.home() / ".hanzo" / "mcp" / "servers.json"
|
||||
|
||||
# Running servers tracking
|
||||
_running_servers: Dict[str, subprocess.Popen] = {}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the MCP management tool."""
|
||||
self.config = self._load_config()
|
||||
|
||||
# Auto-start servers if configured
|
||||
self._auto_start_servers()
|
||||
|
||||
def _load_config(self) -> Dict[str, Any]:
|
||||
"""Load MCP server configuration."""
|
||||
if self.CONFIG_FILE.exists():
|
||||
try:
|
||||
with open(self.CONFIG_FILE, "r") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Default configuration with some examples
|
||||
return {
|
||||
"servers": {
|
||||
# Example configurations (disabled by default)
|
||||
"filesystem": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-filesystem", "/tmp"],
|
||||
"env": {},
|
||||
"enabled": False,
|
||||
"auto_start": False,
|
||||
"description": "MCP filesystem server for /tmp access",
|
||||
},
|
||||
"github": {
|
||||
"command": "npx",
|
||||
"args": ["@modelcontextprotocol/server-github"],
|
||||
"env": {"GITHUB_TOKEN": "${GITHUB_TOKEN}"},
|
||||
"enabled": False,
|
||||
"auto_start": False,
|
||||
"description": "GitHub API access via MCP",
|
||||
},
|
||||
"postgres": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@modelcontextprotocol/server-postgres",
|
||||
"postgresql://localhost/db",
|
||||
],
|
||||
"env": {},
|
||||
"enabled": False,
|
||||
"auto_start": False,
|
||||
"description": "PostgreSQL database access",
|
||||
},
|
||||
},
|
||||
"global_env": {},
|
||||
"log_dir": str(Path.home() / ".hanzo" / "mcp" / "logs"),
|
||||
}
|
||||
|
||||
def _save_config(self):
|
||||
"""Save configuration."""
|
||||
self.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(self.CONFIG_FILE, "w") as f:
|
||||
json.dump(self.config, f, indent=2)
|
||||
|
||||
def _auto_start_servers(self):
|
||||
"""Auto-start servers configured for auto-start."""
|
||||
for name, server_config in self.config.get("servers", {}).items():
|
||||
if server_config.get("enabled", False) and server_config.get("auto_start", False):
|
||||
self._start_server(name, server_config)
|
||||
|
||||
def _start_server(self, name: str, config: Dict[str, Any]) -> bool:
|
||||
"""Start an MCP server."""
|
||||
if name in self._running_servers:
|
||||
return False # Already running
|
||||
|
||||
try:
|
||||
# Prepare environment
|
||||
env = os.environ.copy()
|
||||
env.update(self.config.get("global_env", {}))
|
||||
|
||||
# Process server-specific env vars
|
||||
server_env = config.get("env", {})
|
||||
for key, value in server_env.items():
|
||||
# Replace ${VAR} with actual environment variable
|
||||
if value.startswith("${") and value.endswith("}"):
|
||||
var_name = value[2:-1]
|
||||
if var_name in os.environ:
|
||||
value = os.environ[var_name]
|
||||
env[key] = value
|
||||
|
||||
# Prepare command
|
||||
cmd = [config["command"]] + config.get("args", [])
|
||||
|
||||
# Create log directory
|
||||
log_dir = Path(self.config.get("log_dir", str(Path.home() / ".hanzo" / "mcp" / "logs")))
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Start process
|
||||
log_file = log_dir / f"{name}.log"
|
||||
with open(log_file, "a") as log:
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=log,
|
||||
stderr=subprocess.STDOUT,
|
||||
preexec_fn=os.setsid if os.name != "nt" else None,
|
||||
)
|
||||
|
||||
self._running_servers[name] = process
|
||||
return True
|
||||
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _stop_server(self, name: str) -> bool:
|
||||
"""Stop an MCP server."""
|
||||
if name not in self._running_servers:
|
||||
return False
|
||||
|
||||
process = self._running_servers[name]
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.terminate()
|
||||
else:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
|
||||
|
||||
process.wait(timeout=5)
|
||||
except Exception:
|
||||
# Force kill if needed
|
||||
try:
|
||||
if os.name == "nt":
|
||||
process.kill()
|
||||
else:
|
||||
os.killpg(os.getpgid(process.pid), signal.SIGKILL)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
del self._running_servers[name]
|
||||
return True
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "mcp"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
servers = self.config.get("servers", {})
|
||||
enabled = sum(1 for s in servers.values() if s.get("enabled", False))
|
||||
running = len(self._running_servers)
|
||||
|
||||
return f"""Manage MCP servers. Actions: list (default), add, remove, enable, disable, restart, config.
|
||||
|
||||
Usage:
|
||||
mcp
|
||||
mcp --action add --name github --command npx --args '["@modelcontextprotocol/server-github"]'
|
||||
mcp --action enable --name github
|
||||
|
||||
Status: {enabled} enabled, {running} running"""
|
||||
|
||||
@override
|
||||
@auto_timeout("mcp")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[MCPParams],
|
||||
) -> str:
|
||||
"""Execute MCP management action."""
|
||||
# Create tool context only if we have a proper MCP context
|
||||
tool_ctx = None
|
||||
try:
|
||||
if hasattr(ctx, "client") and ctx.client and hasattr(ctx.client, "server"):
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
if tool_ctx:
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Extract action
|
||||
action = params.get("action", "list")
|
||||
|
||||
# Route to appropriate handler
|
||||
if action == "list":
|
||||
return self._handle_list()
|
||||
elif action == "add":
|
||||
return self._handle_add(params)
|
||||
elif action == "remove":
|
||||
return self._handle_remove(params.get("name"))
|
||||
elif action == "enable":
|
||||
return self._handle_enable(params.get("name"))
|
||||
elif action == "disable":
|
||||
return self._handle_disable(params.get("name"))
|
||||
elif action == "restart":
|
||||
return self._handle_restart(params.get("name"))
|
||||
elif action == "config":
|
||||
return self._handle_config(params.get("config_key"), params.get("config_value"))
|
||||
else:
|
||||
return (
|
||||
f"Error: Unknown action '{action}'. Valid actions: list, add, remove, enable, disable, restart, config"
|
||||
)
|
||||
|
||||
def _handle_list(self) -> str:
|
||||
"""List all MCP servers."""
|
||||
servers = self.config.get("servers", {})
|
||||
|
||||
if not servers:
|
||||
return "No MCP servers configured. Use 'mcp --action add' to add one."
|
||||
|
||||
output = ["=== MCP Servers ==="]
|
||||
output.append(
|
||||
f"Total: {len(servers)} | Enabled: {sum(1 for s in servers.values() if s.get('enabled', False))} | Running: {len(self._running_servers)}"
|
||||
)
|
||||
output.append("")
|
||||
|
||||
for name, config in sorted(servers.items()):
|
||||
status_parts = []
|
||||
|
||||
# Check if enabled
|
||||
if config.get("enabled", False):
|
||||
status_parts.append("✅ Enabled")
|
||||
else:
|
||||
status_parts.append("❌ Disabled")
|
||||
|
||||
# Check if running
|
||||
if name in self._running_servers:
|
||||
process = self._running_servers[name]
|
||||
if process.poll() is None:
|
||||
status_parts.append("🟢 Running")
|
||||
else:
|
||||
status_parts.append("🔴 Stopped")
|
||||
del self._running_servers[name]
|
||||
else:
|
||||
status_parts.append("⚫ Not running")
|
||||
|
||||
# Auto-start status
|
||||
if config.get("auto_start", False):
|
||||
status_parts.append("🚀 Auto-start")
|
||||
|
||||
status = " | ".join(status_parts)
|
||||
|
||||
output.append(f"{name}: {status}")
|
||||
if config.get("description"):
|
||||
output.append(f" Description: {config['description']}")
|
||||
output.append(f" Command: {config['command']} {' '.join(config.get('args', []))}")
|
||||
|
||||
if config.get("env"):
|
||||
env_str = ", ".join([f"{k}={v}" for k, v in config["env"].items()])
|
||||
output.append(f" Environment: {env_str}")
|
||||
|
||||
output.append("\nUse 'mcp --action enable --name <server>' to enable a server")
|
||||
output.append("Use 'mcp --action add' to add a new server")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
def _handle_add(self, params: Dict[str, Any]) -> str:
|
||||
"""Add a new MCP server."""
|
||||
name = params.get("name")
|
||||
command = params.get("command")
|
||||
|
||||
if not name:
|
||||
return "Error: name is required for add action"
|
||||
if not command:
|
||||
return "Error: command is required for add action"
|
||||
|
||||
servers = self.config.get("servers", {})
|
||||
if name in servers:
|
||||
return f"Error: Server '{name}' already exists. Use a different name or remove it first."
|
||||
|
||||
# Create server config
|
||||
server_config = {
|
||||
"command": command,
|
||||
"args": params.get("args", []),
|
||||
"env": params.get("env", {}),
|
||||
"enabled": False,
|
||||
"auto_start": params.get("auto_start", True),
|
||||
"description": params.get("description", ""),
|
||||
}
|
||||
|
||||
servers[name] = server_config
|
||||
self.config["servers"] = servers
|
||||
self._save_config()
|
||||
|
||||
return f"Successfully added MCP server '{name}'. Use 'mcp --action enable --name {name}' to enable it."
|
||||
|
||||
def _handle_remove(self, name: Optional[str]) -> str:
|
||||
"""Remove an MCP server."""
|
||||
if not name:
|
||||
return "Error: name is required for remove action"
|
||||
|
||||
servers = self.config.get("servers", {})
|
||||
if name not in servers:
|
||||
return f"Error: Server '{name}' not found"
|
||||
|
||||
# Stop if running
|
||||
if name in self._running_servers:
|
||||
self._stop_server(name)
|
||||
|
||||
del servers[name]
|
||||
self.config["servers"] = servers
|
||||
self._save_config()
|
||||
|
||||
return f"Successfully removed MCP server '{name}'"
|
||||
|
||||
def _handle_enable(self, name: Optional[str]) -> str:
|
||||
"""Enable an MCP server."""
|
||||
if not name:
|
||||
return "Error: name is required for enable action"
|
||||
|
||||
servers = self.config.get("servers", {})
|
||||
if name not in servers:
|
||||
return f"Error: Server '{name}' not found"
|
||||
|
||||
servers[name]["enabled"] = True
|
||||
self.config["servers"] = servers
|
||||
self._save_config()
|
||||
|
||||
# Start if auto-start is enabled
|
||||
if servers[name].get("auto_start", False):
|
||||
if self._start_server(name, servers[name]):
|
||||
return f"Successfully enabled and started MCP server '{name}'"
|
||||
else:
|
||||
return f"Enabled MCP server '{name}' but failed to start it. Check the configuration."
|
||||
|
||||
return f"Successfully enabled MCP server '{name}'"
|
||||
|
||||
def _handle_disable(self, name: Optional[str]) -> str:
|
||||
"""Disable an MCP server."""
|
||||
if not name:
|
||||
return "Error: name is required for disable action"
|
||||
|
||||
servers = self.config.get("servers", {})
|
||||
if name not in servers:
|
||||
return f"Error: Server '{name}' not found"
|
||||
|
||||
# Stop if running
|
||||
if name in self._running_servers:
|
||||
self._stop_server(name)
|
||||
|
||||
servers[name]["enabled"] = False
|
||||
self.config["servers"] = servers
|
||||
self._save_config()
|
||||
|
||||
return f"Successfully disabled MCP server '{name}'"
|
||||
|
||||
def _handle_restart(self, name: Optional[str]) -> str:
|
||||
"""Restart an MCP server."""
|
||||
if not name:
|
||||
return "Error: name is required for restart action"
|
||||
|
||||
servers = self.config.get("servers", {})
|
||||
if name not in servers:
|
||||
return f"Error: Server '{name}' not found"
|
||||
|
||||
if not servers[name].get("enabled", False):
|
||||
return f"Error: Server '{name}' is not enabled"
|
||||
|
||||
# Stop if running
|
||||
if name in self._running_servers:
|
||||
self._stop_server(name)
|
||||
|
||||
# Start again
|
||||
if self._start_server(name, servers[name]):
|
||||
return f"Successfully restarted MCP server '{name}'"
|
||||
else:
|
||||
return f"Failed to restart MCP server '{name}'. Check the configuration."
|
||||
|
||||
def _handle_config(self, key: Optional[str], value: Optional[Any]) -> str:
|
||||
"""Get or set configuration values."""
|
||||
if not key:
|
||||
# Show all config
|
||||
return json.dumps(self.config, indent=2)
|
||||
|
||||
# Parse nested keys (e.g., "servers.github.auto_start")
|
||||
keys = key.split(".")
|
||||
|
||||
if value is None:
|
||||
# Get value
|
||||
current = self.config
|
||||
for k in keys:
|
||||
if isinstance(current, dict) and k in current:
|
||||
current = current[k]
|
||||
else:
|
||||
return f"Configuration key '{key}' not found"
|
||||
|
||||
return json.dumps(current, indent=2) if isinstance(current, (dict, list)) else str(current)
|
||||
else:
|
||||
# Set value
|
||||
# Navigate to parent
|
||||
current = self.config
|
||||
for k in keys[:-1]:
|
||||
if k not in current:
|
||||
current[k] = {}
|
||||
current = current[k]
|
||||
|
||||
# Parse value if it looks like JSON
|
||||
if isinstance(value, str) and value.startswith("{") or value.startswith("["):
|
||||
try:
|
||||
value = json.loads(value)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Set the value
|
||||
current[keys[-1]] = value
|
||||
self._save_config()
|
||||
|
||||
return f"Successfully set {key} = {json.dumps(value) if isinstance(value, (dict, list)) else value}"
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,25 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo-tools-mcp"
|
||||
version = "0.1.0"
|
||||
description = "MCP management tools for Hanzo AI - server add/remove/stats"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "tools", "mcp", "ai"]
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"mcp>=1.25.0",
|
||||
"fastmcp>=2.14.1",
|
||||
"pydantic>=2.12.5",
|
||||
]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
mcp_tools = "hanzo_tools.mcp_tools:TOOLS"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["hanzo_tools*"]
|
||||
@@ -1,16 +1,62 @@
|
||||
"""Vector search and embedding tools.
|
||||
"""Vector/embedding tools for Hanzo AI.
|
||||
|
||||
Tools:
|
||||
- vector_index: Index files for semantic search
|
||||
- vector_search: Search indexed content
|
||||
- vector_index: Index documents for semantic search
|
||||
- vector_search: Search indexed documents
|
||||
- index: Project indexing
|
||||
|
||||
Install: pip install hanzo-tools-vector
|
||||
Install:
|
||||
pip install hanzo-tools-vector[full]
|
||||
"""
|
||||
|
||||
TOOLS = []
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
__all__ = ["TOOLS", "register_tools"]
|
||||
_tools = []
|
||||
VECTOR_AVAILABLE = False
|
||||
|
||||
def register_tools(mcp_server, permission_manager=None, enabled_tools=None):
|
||||
"""Register vector tools with the MCP server."""
|
||||
return []
|
||||
try:
|
||||
from .index_tool import IndexTool
|
||||
_tools.append(IndexTool)
|
||||
from .vector_index import VectorIndexTool
|
||||
_tools.append(VectorIndexTool)
|
||||
from .vector_search import VectorSearchTool
|
||||
_tools.append(VectorSearchTool)
|
||||
VECTOR_AVAILABLE = True
|
||||
except ImportError as e:
|
||||
logger.debug(f"Vector tools not available: {e}")
|
||||
|
||||
TOOLS = _tools
|
||||
|
||||
__all__ = [
|
||||
"TOOLS",
|
||||
"VECTOR_AVAILABLE",
|
||||
"register_tools",
|
||||
]
|
||||
|
||||
if VECTOR_AVAILABLE:
|
||||
__all__.extend(["IndexTool", "VectorIndexTool", "VectorSearchTool"])
|
||||
|
||||
|
||||
def register_tools(mcp_server, permission_manager=None, enabled_tools: dict[str, bool] | None = None):
|
||||
"""Register vector tools with MCP server."""
|
||||
if not VECTOR_AVAILABLE:
|
||||
logger.warning("Vector tools not available - missing dependencies")
|
||||
return []
|
||||
|
||||
from hanzo_tools.core import ToolRegistry
|
||||
|
||||
enabled = enabled_tools or {}
|
||||
registered = []
|
||||
|
||||
for tool_class in TOOLS:
|
||||
tool_name = getattr(tool_class, 'name', tool_class.__name__.lower())
|
||||
if enabled.get(tool_name, True):
|
||||
try:
|
||||
tool = tool_class(permission_manager) if permission_manager else tool_class()
|
||||
ToolRegistry.register_tool(mcp_server, tool)
|
||||
registered.append(tool)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to register {tool_name}: {e}")
|
||||
|
||||
return registered
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""AST analysis and symbol extraction for code understanding."""
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import asdict, dataclass
|
||||
|
||||
try:
|
||||
import tree_sitter
|
||||
import tree_sitter_python as tspython
|
||||
|
||||
TREE_SITTER_AVAILABLE = True
|
||||
except ImportError:
|
||||
TREE_SITTER_AVAILABLE = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""Represents a code symbol (function, class, variable, etc.)."""
|
||||
|
||||
name: str
|
||||
type: str # function, class, variable, import, etc.
|
||||
file_path: str
|
||||
line_start: int
|
||||
line_end: int
|
||||
column_start: int
|
||||
column_end: int
|
||||
scope: str # global, class, function
|
||||
parent: Optional[str] = None # parent class/function
|
||||
docstring: Optional[str] = None
|
||||
signature: Optional[str] = None
|
||||
references: List[str] = None # Files that reference this symbol
|
||||
|
||||
def __post_init__(self):
|
||||
if self.references is None:
|
||||
self.references = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class ASTNode:
|
||||
"""Represents an AST node with metadata."""
|
||||
|
||||
type: str
|
||||
name: Optional[str]
|
||||
line_start: int
|
||||
line_end: int
|
||||
column_start: int
|
||||
column_end: int
|
||||
children: List["ASTNode"] = None
|
||||
parent: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.children is None:
|
||||
self.children = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class FileAST:
|
||||
"""Complete AST representation of a file."""
|
||||
|
||||
file_path: str
|
||||
file_hash: str
|
||||
language: str
|
||||
symbols: List[Symbol]
|
||||
ast_nodes: List[ASTNode]
|
||||
imports: List[str]
|
||||
exports: List[str]
|
||||
dependencies: List[str] # Files this file depends on
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for storage."""
|
||||
return {
|
||||
"file_path": self.file_path,
|
||||
"file_hash": self.file_hash,
|
||||
"language": self.language,
|
||||
"symbols": [asdict(s) for s in self.symbols],
|
||||
"ast_nodes": [asdict(n) for n in self.ast_nodes],
|
||||
"imports": self.imports,
|
||||
"exports": self.exports,
|
||||
"dependencies": self.dependencies,
|
||||
}
|
||||
|
||||
|
||||
class ASTAnalyzer:
|
||||
"""Analyzes code files and extracts AST information and symbols."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the AST analyzer."""
|
||||
self.parsers = {}
|
||||
self._setup_parsers()
|
||||
|
||||
def _setup_parsers(self):
|
||||
"""Set up tree-sitter parsers for different languages."""
|
||||
if TREE_SITTER_AVAILABLE:
|
||||
try:
|
||||
# Python parser
|
||||
self.parsers["python"] = tree_sitter.Language(tspython.language())
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Could not initialize Python parser: {e}")
|
||||
|
||||
def analyze_file(self, file_path: str) -> Optional[FileAST]:
|
||||
"""Analyze a file and extract AST information and symbols.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to analyze
|
||||
|
||||
Returns:
|
||||
FileAST object with extracted information, or None if analysis fails
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
return None
|
||||
|
||||
# Determine language
|
||||
language = self._detect_language(path)
|
||||
if not language:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Read file content
|
||||
content = path.read_text(encoding="utf-8")
|
||||
file_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
|
||||
# Extract symbols and AST
|
||||
if language == "python":
|
||||
return self._analyze_python_file(file_path, content, file_hash)
|
||||
else:
|
||||
# Generic analysis for other languages
|
||||
return self._analyze_generic_file(file_path, content, file_hash, language)
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error analyzing file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
def _detect_language(self, path: Path) -> Optional[str]:
|
||||
"""Detect programming language from file extension."""
|
||||
extension = path.suffix.lower()
|
||||
|
||||
language_map = {
|
||||
".py": "python",
|
||||
".js": "javascript",
|
||||
".ts": "typescript",
|
||||
".jsx": "javascript",
|
||||
".tsx": "typescript",
|
||||
".java": "java",
|
||||
".cpp": "cpp",
|
||||
".c": "c",
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
".rs": "rust",
|
||||
".go": "go",
|
||||
".rb": "ruby",
|
||||
".php": "php",
|
||||
".cs": "csharp",
|
||||
".swift": "swift",
|
||||
".kt": "kotlin",
|
||||
".scala": "scala",
|
||||
".clj": "clojure",
|
||||
".hs": "haskell",
|
||||
".ml": "ocaml",
|
||||
".elm": "elm",
|
||||
".dart": "dart",
|
||||
".lua": "lua",
|
||||
".r": "r",
|
||||
".m": "objective-c",
|
||||
".mm": "objective-cpp",
|
||||
}
|
||||
|
||||
return language_map.get(extension)
|
||||
|
||||
def _analyze_python_file(self, file_path: str, content: str, file_hash: str) -> FileAST:
|
||||
"""Analyze Python file using both AST and tree-sitter."""
|
||||
symbols = []
|
||||
ast_nodes = []
|
||||
imports = []
|
||||
exports = []
|
||||
dependencies = []
|
||||
|
||||
try:
|
||||
# Parse with Python AST
|
||||
tree = ast.parse(content)
|
||||
|
||||
# Extract symbols using AST visitor
|
||||
visitor = PythonSymbolExtractor(file_path)
|
||||
visitor.visit(tree)
|
||||
|
||||
symbols.extend(visitor.symbols)
|
||||
imports.extend(visitor.imports)
|
||||
exports.extend(visitor.exports)
|
||||
dependencies.extend(visitor.dependencies)
|
||||
|
||||
# If tree-sitter is available, get more detailed AST
|
||||
if TREE_SITTER_AVAILABLE and "python" in self.parsers:
|
||||
parser = tree_sitter.Parser(self.parsers["python"])
|
||||
ts_tree = parser.parse(content.encode())
|
||||
ast_nodes = self._extract_tree_sitter_nodes(ts_tree.root_node, content)
|
||||
|
||||
except SyntaxError as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Syntax error in {file_path}: {e}")
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error parsing Python file {file_path}: {e}")
|
||||
|
||||
return FileAST(
|
||||
file_path=file_path,
|
||||
file_hash=file_hash,
|
||||
language="python",
|
||||
symbols=symbols,
|
||||
ast_nodes=ast_nodes,
|
||||
imports=imports,
|
||||
exports=exports,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
def _analyze_generic_file(self, file_path: str, content: str, file_hash: str, language: str) -> FileAST:
|
||||
"""Generic analysis for non-Python files."""
|
||||
# For now, just basic line-based analysis
|
||||
# Could be enhanced with language-specific parsers
|
||||
|
||||
symbols = []
|
||||
ast_nodes = []
|
||||
imports = []
|
||||
exports = []
|
||||
dependencies = []
|
||||
|
||||
# Basic pattern matching for common constructs
|
||||
lines = content.split("\n")
|
||||
for i, line in enumerate(lines, 1):
|
||||
line = line.strip()
|
||||
|
||||
# Basic function detection (works for many C-style languages)
|
||||
if language in ["javascript", "typescript", "java", "cpp", "c"]:
|
||||
if "function " in line or line.startswith("def ") or " function(" in line:
|
||||
# Extract function name
|
||||
parts = line.split()
|
||||
for j, part in enumerate(parts):
|
||||
if part == "function" and j + 1 < len(parts):
|
||||
func_name = parts[j + 1].split("(")[0]
|
||||
symbols.append(
|
||||
Symbol(
|
||||
name=func_name,
|
||||
type="function",
|
||||
file_path=file_path,
|
||||
line_start=i,
|
||||
line_end=i,
|
||||
column_start=0,
|
||||
column_end=len(line),
|
||||
scope="global",
|
||||
)
|
||||
)
|
||||
break
|
||||
|
||||
# Basic import detection
|
||||
if "import " in line or "#include " in line or "require(" in line:
|
||||
imports.append(line)
|
||||
|
||||
return FileAST(
|
||||
file_path=file_path,
|
||||
file_hash=file_hash,
|
||||
language=language,
|
||||
symbols=symbols,
|
||||
ast_nodes=ast_nodes,
|
||||
imports=imports,
|
||||
exports=exports,
|
||||
dependencies=dependencies,
|
||||
)
|
||||
|
||||
def _extract_tree_sitter_nodes(self, node, content: str) -> List[ASTNode]:
|
||||
"""Extract AST nodes from tree-sitter parse tree."""
|
||||
nodes = []
|
||||
|
||||
def traverse(ts_node, parent_name=None):
|
||||
node_name = None
|
||||
|
||||
# Try to extract node name for named nodes
|
||||
if ts_node.type in [
|
||||
"function_definition",
|
||||
"class_definition",
|
||||
"identifier",
|
||||
]:
|
||||
for child in ts_node.children:
|
||||
if child.type == "identifier":
|
||||
start_byte = child.start_byte
|
||||
end_byte = child.end_byte
|
||||
node_name = content[start_byte:end_byte]
|
||||
break
|
||||
|
||||
ast_node = ASTNode(
|
||||
type=ts_node.type,
|
||||
name=node_name,
|
||||
line_start=ts_node.start_point[0] + 1,
|
||||
line_end=ts_node.end_point[0] + 1,
|
||||
column_start=ts_node.start_point[1],
|
||||
column_end=ts_node.end_point[1],
|
||||
parent=parent_name,
|
||||
)
|
||||
|
||||
nodes.append(ast_node)
|
||||
|
||||
# Recursively process children
|
||||
for child in ts_node.children:
|
||||
traverse(child, node_name or parent_name)
|
||||
|
||||
traverse(node)
|
||||
return nodes
|
||||
|
||||
|
||||
class PythonSymbolExtractor(ast.NodeVisitor):
|
||||
"""AST visitor for extracting Python symbols."""
|
||||
|
||||
def __init__(self, file_path: str):
|
||||
self.file_path = file_path
|
||||
self.symbols = []
|
||||
self.imports = []
|
||||
self.exports = []
|
||||
self.dependencies = []
|
||||
self.scope_stack = ["global"]
|
||||
|
||||
def visit_FunctionDef(self, node):
|
||||
"""Visit function definitions."""
|
||||
scope = ".".join(self.scope_stack)
|
||||
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
|
||||
|
||||
# Extract docstring
|
||||
docstring = None
|
||||
if (
|
||||
node.body
|
||||
and isinstance(node.body[0], ast.Expr)
|
||||
and isinstance(node.body[0].value, ast.Constant)
|
||||
and isinstance(node.body[0].value.value, str)
|
||||
):
|
||||
docstring = node.body[0].value.value
|
||||
|
||||
# Create function signature
|
||||
args = [arg.arg for arg in node.args.args]
|
||||
signature = f"{node.name}({', '.join(args)})"
|
||||
|
||||
symbol = Symbol(
|
||||
name=node.name,
|
||||
type="function",
|
||||
file_path=self.file_path,
|
||||
line_start=node.lineno,
|
||||
line_end=node.end_lineno or node.lineno,
|
||||
column_start=node.col_offset,
|
||||
column_end=node.end_col_offset or 0,
|
||||
scope=scope,
|
||||
parent=parent if parent != "global" else None,
|
||||
docstring=docstring,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
self.symbols.append(symbol)
|
||||
|
||||
# Enter function scope
|
||||
self.scope_stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.scope_stack.pop()
|
||||
|
||||
def visit_AsyncFunctionDef(self, node):
|
||||
"""Visit async function definitions."""
|
||||
self.visit_FunctionDef(node) # Same logic
|
||||
|
||||
def visit_ClassDef(self, node):
|
||||
"""Visit class definitions."""
|
||||
scope = ".".join(self.scope_stack)
|
||||
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
|
||||
|
||||
# Extract docstring
|
||||
docstring = None
|
||||
if (
|
||||
node.body
|
||||
and isinstance(node.body[0], ast.Expr)
|
||||
and isinstance(node.body[0].value, ast.Constant)
|
||||
and isinstance(node.body[0].value.value, str)
|
||||
):
|
||||
docstring = node.body[0].value.value
|
||||
|
||||
# Extract base classes
|
||||
bases = [self._get_name(base) for base in node.bases]
|
||||
signature = f"class {node.name}({', '.join(bases)})" if bases else f"class {node.name}"
|
||||
|
||||
symbol = Symbol(
|
||||
name=node.name,
|
||||
type="class",
|
||||
file_path=self.file_path,
|
||||
line_start=node.lineno,
|
||||
line_end=node.end_lineno or node.lineno,
|
||||
column_start=node.col_offset,
|
||||
column_end=node.end_col_offset or 0,
|
||||
scope=scope,
|
||||
parent=parent if parent != "global" else None,
|
||||
docstring=docstring,
|
||||
signature=signature,
|
||||
)
|
||||
|
||||
self.symbols.append(symbol)
|
||||
|
||||
# Enter class scope
|
||||
self.scope_stack.append(node.name)
|
||||
self.generic_visit(node)
|
||||
self.scope_stack.pop()
|
||||
|
||||
def visit_Import(self, node):
|
||||
"""Visit import statements."""
|
||||
for alias in node.names:
|
||||
import_name = alias.name
|
||||
self.imports.append(import_name)
|
||||
if "." not in import_name: # Top-level module
|
||||
self.dependencies.append(import_name)
|
||||
|
||||
def visit_ImportFrom(self, node):
|
||||
"""Visit from...import statements."""
|
||||
if node.module:
|
||||
self.imports.append(node.module)
|
||||
if "." not in node.module: # Top-level module
|
||||
self.dependencies.append(node.module)
|
||||
|
||||
for alias in node.names:
|
||||
if alias.name != "*":
|
||||
import_item = f"{node.module}.{alias.name}" if node.module else alias.name
|
||||
self.imports.append(import_item)
|
||||
|
||||
def visit_Assign(self, node):
|
||||
"""Visit variable assignments."""
|
||||
scope = ".".join(self.scope_stack)
|
||||
parent = self.scope_stack[-1] if len(self.scope_stack) > 1 else None
|
||||
|
||||
for target in node.targets:
|
||||
if isinstance(target, ast.Name):
|
||||
symbol = Symbol(
|
||||
name=target.id,
|
||||
type="variable",
|
||||
file_path=self.file_path,
|
||||
line_start=node.lineno,
|
||||
line_end=node.end_lineno or node.lineno,
|
||||
column_start=node.col_offset,
|
||||
column_end=node.end_col_offset or 0,
|
||||
scope=scope,
|
||||
parent=parent if parent != "global" else None,
|
||||
)
|
||||
self.symbols.append(symbol)
|
||||
|
||||
self.generic_visit(node)
|
||||
|
||||
def _get_name(self, node):
|
||||
"""Extract name from AST node."""
|
||||
if isinstance(node, ast.Name):
|
||||
return node.id
|
||||
elif isinstance(node, ast.Attribute):
|
||||
return f"{self._get_name(node.value)}.{node.attr}"
|
||||
elif isinstance(node, ast.Constant):
|
||||
return str(node.value)
|
||||
else:
|
||||
return str(node)
|
||||
|
||||
|
||||
def create_symbol_embedding_text(symbol: Symbol) -> str:
|
||||
"""Create text representation of symbol for vector embedding."""
|
||||
parts = [
|
||||
f"Symbol: {symbol.name}",
|
||||
f"Type: {symbol.type}",
|
||||
f"Scope: {symbol.scope}",
|
||||
]
|
||||
|
||||
if symbol.parent:
|
||||
parts.append(f"Parent: {symbol.parent}")
|
||||
|
||||
if symbol.signature:
|
||||
parts.append(f"Signature: {symbol.signature}")
|
||||
|
||||
if symbol.docstring:
|
||||
parts.append(f"Documentation: {symbol.docstring}")
|
||||
|
||||
return " | ".join(parts)
|
||||
@@ -0,0 +1,456 @@
|
||||
"""Git repository ingester for comprehensive code indexing.
|
||||
|
||||
This module provides functionality to ingest entire git repositories including:
|
||||
- Full git history and commit metadata
|
||||
- File contents at different points in time
|
||||
- AST analysis via tree-sitter
|
||||
- Symbol extraction and cross-references
|
||||
- Blame information for line-level attribution
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from typing import Any, Dict, List, Optional
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .ast_analyzer import ASTAnalyzer
|
||||
from .infinity_store import InfinityVectorStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitCommit:
|
||||
"""Represents a git commit."""
|
||||
|
||||
hash: str
|
||||
author: str
|
||||
author_email: str
|
||||
timestamp: int
|
||||
message: str
|
||||
files: List[Dict[str, str]] # [{'status': 'M', 'filename': 'main.py'}]
|
||||
parent_hashes: List[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class GitFileHistory:
|
||||
"""History of a single file."""
|
||||
|
||||
file_path: str
|
||||
commits: List[GitCommit]
|
||||
current_content: Optional[str]
|
||||
line_blame: Dict[int, Dict[str, Any]] # line_number -> blame info
|
||||
|
||||
|
||||
class GitIngester:
|
||||
"""Ingests git repositories into vector store."""
|
||||
|
||||
def __init__(self, vector_store: InfinityVectorStore):
|
||||
"""Initialize the git ingester.
|
||||
|
||||
Args:
|
||||
vector_store: The vector store to ingest into
|
||||
"""
|
||||
self.vector_store = vector_store
|
||||
self.ast_analyzer = ASTAnalyzer()
|
||||
self._commit_cache: Dict[str, GitCommit] = {}
|
||||
|
||||
def ingest_repository(
|
||||
self,
|
||||
repo_path: str,
|
||||
branch: str = "HEAD",
|
||||
include_history: bool = True,
|
||||
include_diffs: bool = True,
|
||||
include_blame: bool = True,
|
||||
file_patterns: Optional[List[str]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""Ingest an entire git repository.
|
||||
|
||||
Args:
|
||||
repo_path: Path to the git repository
|
||||
branch: Branch to ingest (default: HEAD)
|
||||
include_history: Whether to include commit history
|
||||
include_diffs: Whether to include diff information
|
||||
include_blame: Whether to include blame information
|
||||
file_patterns: List of file patterns to include (e.g., ["*.py", "*.js"])
|
||||
|
||||
Returns:
|
||||
Summary of ingestion results
|
||||
"""
|
||||
repo_path = Path(repo_path)
|
||||
if not (repo_path / ".git").exists():
|
||||
raise ValueError(f"Not a git repository: {repo_path}")
|
||||
|
||||
logger.info(f"Starting ingestion of repository: {repo_path}")
|
||||
|
||||
results = {
|
||||
"repository": str(repo_path),
|
||||
"branch": branch,
|
||||
"commits_processed": 0,
|
||||
"commits_indexed": 0,
|
||||
"files_indexed": 0,
|
||||
"symbols_extracted": 0,
|
||||
"diffs_indexed": 0,
|
||||
"blame_entries": 0,
|
||||
"errors": [],
|
||||
}
|
||||
|
||||
try:
|
||||
# Get current branch/commit
|
||||
current_commit = self._get_current_commit(repo_path)
|
||||
results["current_commit"] = current_commit
|
||||
|
||||
# Get list of files to process
|
||||
files = self._get_repository_files(repo_path, file_patterns)
|
||||
logger.info(f"Found {len(files)} files to process")
|
||||
|
||||
# Process each file
|
||||
for file_path in files:
|
||||
try:
|
||||
self._process_file(
|
||||
repo_path,
|
||||
file_path,
|
||||
include_history=include_history,
|
||||
include_blame=include_blame,
|
||||
results=results,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing {file_path}: {e}")
|
||||
results["errors"].append(f"{file_path}: {str(e)}")
|
||||
|
||||
# Process commit history if requested
|
||||
if include_history:
|
||||
commits = self._get_commit_history(repo_path, branch)
|
||||
results["commits_processed"] = len(commits)
|
||||
|
||||
for commit in commits:
|
||||
self._index_commit(commit, include_diffs=include_diffs)
|
||||
results["commits_indexed"] = results.get("commits_indexed", 0) + 1
|
||||
|
||||
if include_diffs:
|
||||
results["diffs_indexed"] += len(commit.files)
|
||||
|
||||
# Create repository metadata document
|
||||
self._index_repository_metadata(repo_path, results)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Repository ingestion failed: {e}")
|
||||
results["errors"].append(f"Fatal error: {str(e)}")
|
||||
|
||||
logger.info(f"Ingestion complete: {results}")
|
||||
return results
|
||||
|
||||
def _get_current_commit(self, repo_path: Path) -> str:
|
||||
"""Get the current commit hash."""
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
def _get_repository_files(self, repo_path: Path, patterns: Optional[List[str]] = None) -> List[Path]:
|
||||
"""Get list of files in repository matching patterns."""
|
||||
# Use git ls-files to respect .gitignore
|
||||
cmd = ["git", "ls-files"]
|
||||
|
||||
result = subprocess.run(cmd, cwd=repo_path, capture_output=True, text=True, check=True)
|
||||
|
||||
files = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
file_path = repo_path / line
|
||||
if file_path.exists():
|
||||
# Apply pattern filtering if specified
|
||||
if patterns:
|
||||
if any(file_path.match(pattern) for pattern in patterns):
|
||||
files.append(file_path)
|
||||
else:
|
||||
files.append(file_path)
|
||||
|
||||
return files
|
||||
|
||||
def _get_commit_history(self, repo_path: Path, branch: str = "HEAD", max_commits: int = 1000) -> List[GitCommit]:
|
||||
"""Get commit history for the repository."""
|
||||
# Get commit list with basic info
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
branch,
|
||||
f"--max-count={max_commits}",
|
||||
"--pretty=format:%H|%P|%an|%ae|%at|%s",
|
||||
],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
commits = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
parts = line.split("|", 5)
|
||||
if len(parts) >= 6:
|
||||
commit_hash = parts[0]
|
||||
parent_hashes = parts[1].split() if parts[1] else []
|
||||
|
||||
# Get file changes for this commit
|
||||
files = self._get_commit_files(repo_path, commit_hash)
|
||||
|
||||
commit = GitCommit(
|
||||
hash=commit_hash,
|
||||
parent_hashes=parent_hashes,
|
||||
author=parts[2],
|
||||
author_email=parts[3],
|
||||
timestamp=int(parts[4]),
|
||||
message=parts[5],
|
||||
files=files,
|
||||
)
|
||||
commits.append(commit)
|
||||
self._commit_cache[commit_hash] = commit
|
||||
|
||||
return commits
|
||||
|
||||
def _get_commit_files(self, repo_path: Path, commit_hash: str) -> List[Dict[str, str]]:
|
||||
"""Get list of files changed in a commit."""
|
||||
result = subprocess.run(
|
||||
["git", "show", "--name-status", "--format=", commit_hash],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
files = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line and "\t" in line:
|
||||
parts = line.split("\t", 1)
|
||||
if len(parts) == 2:
|
||||
files.append({"status": parts[0], "filename": parts[1]})
|
||||
|
||||
return files
|
||||
|
||||
def _process_file(
|
||||
self,
|
||||
repo_path: Path,
|
||||
file_path: Path,
|
||||
include_history: bool,
|
||||
include_blame: bool,
|
||||
results: Dict[str, Any],
|
||||
):
|
||||
"""Process a single file."""
|
||||
relative_path = file_path.relative_to(repo_path)
|
||||
|
||||
# Read current content
|
||||
try:
|
||||
content = file_path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
content = file_path.read_text(encoding="latin-1")
|
||||
|
||||
# Get file metadata
|
||||
metadata = {
|
||||
"repository": str(repo_path),
|
||||
"relative_path": str(relative_path),
|
||||
"file_type": file_path.suffix,
|
||||
"size": file_path.stat().st_size,
|
||||
}
|
||||
|
||||
# Add git history metadata if requested
|
||||
if include_history:
|
||||
history = self._get_file_history(repo_path, relative_path)
|
||||
metadata["commit_count"] = len(history)
|
||||
if history:
|
||||
metadata["first_commit"] = history[-1]["hash"]
|
||||
metadata["last_commit"] = history[0]["hash"]
|
||||
metadata["last_modified"] = datetime.fromtimestamp(history[0]["timestamp"]).isoformat()
|
||||
|
||||
# Add blame information if requested
|
||||
if include_blame:
|
||||
blame_data = self._get_file_blame(repo_path, relative_path)
|
||||
metadata["unique_authors"] = len(set(b["author"] for b in blame_data.values()))
|
||||
|
||||
# Index the file content
|
||||
doc_ids = self.vector_store.add_file(str(file_path), chunk_size=1000, chunk_overlap=200, metadata=metadata)
|
||||
results["files_indexed"] += 1
|
||||
|
||||
# Perform AST analysis for supported languages
|
||||
if file_path.suffix in [".py", ".js", ".ts", ".java", ".cpp", ".c"]:
|
||||
try:
|
||||
file_ast = self.ast_analyzer.analyze_file(str(file_path))
|
||||
if file_ast:
|
||||
# Store complete AST
|
||||
self.vector_store._store_file_ast(file_ast)
|
||||
|
||||
# Store individual symbols
|
||||
self.vector_store._store_symbols(file_ast.symbols)
|
||||
results["symbols_extracted"] += len(file_ast.symbols)
|
||||
|
||||
# Store cross-references
|
||||
self.vector_store._store_references(file_ast)
|
||||
except Exception as e:
|
||||
logger.warning(f"AST analysis failed for {file_path}: {e}")
|
||||
|
||||
def _get_file_history(self, repo_path: Path, file_path: Path) -> List[Dict[str, Any]]:
|
||||
"""Get commit history for a specific file."""
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"log",
|
||||
"--follow",
|
||||
"--pretty=format:%H|%at|%an|%s",
|
||||
"--",
|
||||
str(file_path),
|
||||
],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
|
||||
history = []
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line:
|
||||
parts = line.split("|", 3)
|
||||
if len(parts) >= 4:
|
||||
history.append(
|
||||
{
|
||||
"hash": parts[0],
|
||||
"timestamp": int(parts[1]),
|
||||
"author": parts[2],
|
||||
"message": parts[3],
|
||||
}
|
||||
)
|
||||
|
||||
return history
|
||||
|
||||
def _get_file_blame(self, repo_path: Path, file_path: Path) -> Dict[int, Dict[str, Any]]:
|
||||
"""Get blame information for a file."""
|
||||
result = subprocess.run(
|
||||
["git", "blame", "--line-porcelain", "--", str(file_path)],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
|
||||
blame_data = {}
|
||||
current_commit = None
|
||||
current_line = None
|
||||
author = None
|
||||
timestamp = None
|
||||
|
||||
for line in result.stdout.strip().split("\n"):
|
||||
if line and not line.startswith("\t"):
|
||||
parts = line.split(" ")
|
||||
if len(parts) >= 3 and len(parts[0]) == 40: # SHA-1 hash
|
||||
current_commit = parts[0]
|
||||
current_line = int(parts[2])
|
||||
elif line.startswith("author "):
|
||||
author = line[7:]
|
||||
elif line.startswith("author-time "):
|
||||
timestamp = int(line[12:])
|
||||
|
||||
# We have all the data for this line
|
||||
if current_line and author:
|
||||
blame_data[current_line] = {
|
||||
"commit": current_commit,
|
||||
"author": author,
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
return blame_data
|
||||
|
||||
def _index_commit(self, commit: GitCommit, include_diffs: bool = True):
|
||||
"""Index a single commit."""
|
||||
# Create commit document
|
||||
commit_doc = f"""Git Commit: {commit.hash}
|
||||
Author: {commit.author} <{commit.author_email}>
|
||||
Date: {datetime.fromtimestamp(commit.timestamp).isoformat()}
|
||||
Message: {commit.message}
|
||||
|
||||
Files changed: {len(commit.files)}
|
||||
"""
|
||||
|
||||
for file_info in commit.files:
|
||||
commit_doc += f"\n{file_info['status']}\t{file_info['filename']}"
|
||||
|
||||
# Index commit
|
||||
metadata = {
|
||||
"type": "git_commit",
|
||||
"commit_hash": commit.hash,
|
||||
"author": commit.author,
|
||||
"timestamp": commit.timestamp,
|
||||
"file_count": len(commit.files),
|
||||
}
|
||||
|
||||
self.vector_store.add_document(commit_doc, metadata)
|
||||
|
||||
# Index diffs if requested
|
||||
if include_diffs:
|
||||
for file_info in commit.files:
|
||||
self._index_commit_diff(commit, file_info["filename"])
|
||||
|
||||
def _index_commit_diff(self, commit: GitCommit, filename: str):
|
||||
"""Index the diff for a specific file in a commit."""
|
||||
# This is a simplified version - in practice you'd want to
|
||||
# parse the actual diff and store meaningful chunks
|
||||
metadata = {
|
||||
"type": "git_diff",
|
||||
"commit_hash": commit.hash,
|
||||
"filename": filename,
|
||||
"author": commit.author,
|
||||
"timestamp": commit.timestamp,
|
||||
}
|
||||
|
||||
# Create a document representing this change
|
||||
diff_doc = f"""File: {filename}
|
||||
Commit: {commit.hash}
|
||||
Author: {commit.author}
|
||||
Message: {commit.message}
|
||||
"""
|
||||
|
||||
self.vector_store.add_document(diff_doc, metadata)
|
||||
|
||||
def _index_repository_metadata(self, repo_path: Path, results: Dict[str, Any]):
|
||||
"""Index overall repository metadata."""
|
||||
# Get repository info
|
||||
remote_result = subprocess.run(
|
||||
["git", "remote", "get-url", "origin"],
|
||||
cwd=repo_path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
remote_url = remote_result.stdout.strip() if remote_result.returncode == 0 else None
|
||||
|
||||
# Create repository summary document
|
||||
repo_doc = f"""Repository: {repo_path.name}
|
||||
Path: {repo_path}
|
||||
Remote: {remote_url or "No remote"}
|
||||
Current Commit: {results.get("current_commit", "Unknown")}
|
||||
|
||||
Statistics:
|
||||
- Files indexed: {results["files_indexed"]}
|
||||
- Commits processed: {results["commits_processed"]}
|
||||
- Symbols extracted: {results["symbols_extracted"]}
|
||||
- Diffs indexed: {results["diffs_indexed"]}
|
||||
"""
|
||||
|
||||
metadata = {
|
||||
"type": "repository",
|
||||
"name": repo_path.name,
|
||||
"path": str(repo_path),
|
||||
"remote_url": remote_url,
|
||||
**results,
|
||||
}
|
||||
|
||||
self.vector_store.add_document(repo_doc, metadata)
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Index tool for managing vector store indexing."""
|
||||
|
||||
import os
|
||||
import time
|
||||
from typing import Unpack, Annotated, TypedDict, final, override
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import ToolContext, create_tool_context
|
||||
from hanzo_tools.core import PermissionManager
|
||||
from hanzo_tools.core import auto_timeout
|
||||
from .git_ingester import GitIngester
|
||||
from .project_manager import ProjectVectorManager
|
||||
|
||||
Path_str = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Path to index (defaults to current working directory)",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
|
||||
IncludeGitHistory = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Include git history in the index",
|
||||
default=True,
|
||||
),
|
||||
]
|
||||
|
||||
FilePatterns = Annotated[
|
||||
list[str] | None,
|
||||
Field(
|
||||
description="File patterns to include (e.g., ['*.py', '*.js'])",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
ShowStats = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Show detailed statistics after indexing",
|
||||
default=True,
|
||||
),
|
||||
]
|
||||
|
||||
Force = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Force re-indexing even if already indexed",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class IndexToolParams(TypedDict, total=False):
|
||||
"""Parameters for the index tool."""
|
||||
|
||||
path: str
|
||||
include_git_history: bool
|
||||
file_patterns: list[str] | None
|
||||
show_stats: bool
|
||||
force: bool
|
||||
|
||||
|
||||
@final
|
||||
class IndexTool(BaseTool):
|
||||
"""Tool for indexing files and git history into vector store."""
|
||||
|
||||
def __init__(self, permission_manager: PermissionManager):
|
||||
"""Initialize the index tool.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
"""
|
||||
self.permission_manager = permission_manager
|
||||
self.project_manager = ProjectVectorManager(permission_manager)
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "index"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Index files and git history into the vector store for semantic search.
|
||||
|
||||
This tool:
|
||||
- Indexes all project files into a vector database
|
||||
- Includes git history (commits, diffs, blame) when available
|
||||
- Supports incremental updates
|
||||
- Shows statistics about indexed content
|
||||
- Automatically creates project-specific databases
|
||||
|
||||
Usage:
|
||||
- index: Index the current directory
|
||||
- index --path /path/to/project: Index a specific path
|
||||
- index --file-patterns "*.py" "*.js": Index only specific file types
|
||||
- index --no-git-history: Skip git history indexing
|
||||
- index --force: Force re-indexing of all files"""
|
||||
|
||||
@override
|
||||
@auto_timeout("index")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[IndexToolParams],
|
||||
) -> str:
|
||||
"""Execute the index tool.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Indexing result and statistics
|
||||
"""
|
||||
start_time = time.time()
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
|
||||
# Extract parameters
|
||||
path = params.get("path", os.getcwd())
|
||||
include_git_history = params.get("include_git_history", True)
|
||||
file_patterns = params.get("file_patterns")
|
||||
show_stats = params.get("show_stats", True)
|
||||
force = params.get("force", False)
|
||||
|
||||
# Resolve absolute path
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
# Check permissions
|
||||
if not self.permission_manager.is_path_allowed(abs_path):
|
||||
return f"Permission denied: {abs_path}"
|
||||
|
||||
# Check if path exists
|
||||
if not os.path.exists(abs_path):
|
||||
return f"Path does not exist: {abs_path}"
|
||||
|
||||
await tool_ctx.info(f"Starting indexing of {abs_path}")
|
||||
|
||||
try:
|
||||
# Get or create vector store for this project
|
||||
vector_store = self.project_manager.get_project_store(abs_path)
|
||||
|
||||
# Check if already indexed (unless force)
|
||||
if not force:
|
||||
stats = await vector_store.get_stats()
|
||||
if stats and stats.get("document_count", 0) > 0:
|
||||
await tool_ctx.info("Project already indexed, use --force to re-index")
|
||||
if show_stats:
|
||||
return self._format_stats(stats, abs_path, time.time() - start_time)
|
||||
return "Project is already indexed. Use --force to re-index."
|
||||
|
||||
# Prepare file patterns
|
||||
if file_patterns is None:
|
||||
# Default patterns for code files
|
||||
file_patterns = [
|
||||
"*.py",
|
||||
"*.js",
|
||||
"*.ts",
|
||||
"*.jsx",
|
||||
"*.tsx",
|
||||
"*.java",
|
||||
"*.cpp",
|
||||
"*.c",
|
||||
"*.h",
|
||||
"*.hpp",
|
||||
"*.go",
|
||||
"*.rs",
|
||||
"*.rb",
|
||||
"*.php",
|
||||
"*.swift",
|
||||
"*.kt",
|
||||
"*.scala",
|
||||
"*.cs",
|
||||
"*.vb",
|
||||
"*.fs",
|
||||
"*.sh",
|
||||
"*.bash",
|
||||
"*.zsh",
|
||||
"*.fish",
|
||||
"*.md",
|
||||
"*.rst",
|
||||
"*.txt",
|
||||
"*.json",
|
||||
"*.yaml",
|
||||
"*.yml",
|
||||
"*.toml",
|
||||
"*.ini",
|
||||
"*.cfg",
|
||||
"*.conf",
|
||||
"*.html",
|
||||
"*.css",
|
||||
"*.scss",
|
||||
"*.sass",
|
||||
"*.less",
|
||||
"*.sql",
|
||||
"*.graphql",
|
||||
"*.proto",
|
||||
"Dockerfile",
|
||||
"Makefile",
|
||||
"*.mk",
|
||||
".gitignore",
|
||||
".dockerignore",
|
||||
"requirements.txt",
|
||||
"package.json",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
"pom.xml",
|
||||
]
|
||||
|
||||
# Clear existing index if force
|
||||
if force:
|
||||
await tool_ctx.info("Clearing existing index...")
|
||||
await vector_store.clear()
|
||||
|
||||
# Index files
|
||||
await tool_ctx.info("Indexing files...")
|
||||
indexed_files = 0
|
||||
total_size = 0
|
||||
errors = []
|
||||
|
||||
for pattern in file_patterns:
|
||||
pattern_files = await self._find_files(abs_path, pattern)
|
||||
for file_path in pattern_files:
|
||||
try:
|
||||
# Check file size (skip very large files)
|
||||
file_size = os.path.getsize(file_path)
|
||||
if file_size > 10 * 1024 * 1024: # 10MB
|
||||
await tool_ctx.warning(f"Skipping large file: {file_path}")
|
||||
continue
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except UnicodeDecodeError:
|
||||
# Skip binary files
|
||||
continue
|
||||
|
||||
# Index the file
|
||||
rel_path = os.path.relpath(file_path, abs_path)
|
||||
await vector_store.index_document(
|
||||
content=content,
|
||||
metadata={
|
||||
"type": "file",
|
||||
"path": rel_path,
|
||||
"absolute_path": file_path,
|
||||
"size": file_size,
|
||||
"extension": Path(file_path).suffix,
|
||||
},
|
||||
)
|
||||
indexed_files += 1
|
||||
total_size += file_size
|
||||
|
||||
if indexed_files % 100 == 0:
|
||||
await tool_ctx.info(f"Indexed {indexed_files} files...")
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"{file_path}: {str(e)}")
|
||||
|
||||
await tool_ctx.info(f"Indexed {indexed_files} files ({total_size / 1024 / 1024:.1f} MB)")
|
||||
|
||||
# Index git history if requested
|
||||
git_stats = {}
|
||||
if include_git_history and os.path.exists(os.path.join(abs_path, ".git")):
|
||||
await tool_ctx.info("Indexing git history...")
|
||||
|
||||
git_ingester = GitIngester(vector_store)
|
||||
git_stats = await git_ingester.ingest_repository(
|
||||
repo_path=abs_path,
|
||||
include_history=True,
|
||||
include_diffs=True,
|
||||
include_blame=True,
|
||||
file_patterns=file_patterns,
|
||||
)
|
||||
|
||||
await tool_ctx.info(
|
||||
f"Indexed {git_stats.get('commits_indexed', 0)} commits, {git_stats.get('diffs_indexed', 0)} diffs"
|
||||
)
|
||||
|
||||
# Get final statistics
|
||||
if show_stats:
|
||||
stats = await vector_store.get_stats()
|
||||
stats.update(
|
||||
{
|
||||
"files_indexed": indexed_files,
|
||||
"total_size_mb": total_size / 1024 / 1024,
|
||||
"errors": len(errors),
|
||||
**git_stats,
|
||||
}
|
||||
)
|
||||
result = self._format_stats(stats, abs_path, time.time() - start_time)
|
||||
|
||||
if errors:
|
||||
result += f"\n\nErrors ({len(errors)}):\n"
|
||||
result += "\n".join(errors[:10]) # Show first 10 errors
|
||||
if len(errors) > 10:
|
||||
result += f"\n... and {len(errors) - 10} more errors"
|
||||
|
||||
return result
|
||||
else:
|
||||
return f"Successfully indexed {indexed_files} files"
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Indexing failed: {str(e)}")
|
||||
return f"Error during indexing: {str(e)}"
|
||||
|
||||
async def _find_files(self, base_path: str, pattern: str) -> list[str]:
|
||||
"""Find files matching a pattern.
|
||||
|
||||
Args:
|
||||
base_path: Base directory to search
|
||||
pattern: File pattern to match
|
||||
|
||||
Returns:
|
||||
List of matching file paths
|
||||
"""
|
||||
import glob
|
||||
|
||||
# Use glob to find files
|
||||
if pattern.startswith("*."):
|
||||
# Extension pattern
|
||||
files = glob.glob(
|
||||
os.path.join(base_path, "**", pattern),
|
||||
recursive=True,
|
||||
)
|
||||
else:
|
||||
# Exact filename
|
||||
files = glob.glob(
|
||||
os.path.join(base_path, "**", pattern),
|
||||
recursive=True,
|
||||
)
|
||||
|
||||
# Filter out hidden directories and common ignore patterns
|
||||
filtered_files = []
|
||||
ignore_dirs = {
|
||||
".git",
|
||||
"__pycache__",
|
||||
"node_modules",
|
||||
".venv",
|
||||
"venv",
|
||||
"dist",
|
||||
"build",
|
||||
}
|
||||
|
||||
for file_path in files:
|
||||
# Check if any parent directory is in ignore list
|
||||
parts = Path(file_path).parts
|
||||
if any(part in ignore_dirs for part in parts):
|
||||
continue
|
||||
if any(part.startswith(".") and part != "." for part in parts[:-1]):
|
||||
continue # Skip hidden directories (but allow hidden files like .gitignore)
|
||||
filtered_files.append(file_path)
|
||||
|
||||
return filtered_files
|
||||
|
||||
def _format_stats(self, stats: dict, path: str, elapsed_time: float) -> str:
|
||||
"""Format statistics for display.
|
||||
|
||||
Args:
|
||||
stats: Statistics dictionary
|
||||
path: Indexed path
|
||||
elapsed_time: Time taken for indexing
|
||||
|
||||
Returns:
|
||||
Formatted statistics string
|
||||
"""
|
||||
result = f"=== Index Statistics for {path} ===\n\n"
|
||||
|
||||
# Basic stats
|
||||
result += f"Indexing completed in {elapsed_time:.1f} seconds\n\n"
|
||||
|
||||
result += "Content Statistics:\n"
|
||||
result += f" Documents: {stats.get('document_count', 0):,}\n"
|
||||
result += f" Files indexed: {stats.get('files_indexed', 0):,}\n"
|
||||
result += f" Total size: {stats.get('total_size_mb', 0):.1f} MB\n"
|
||||
|
||||
if stats.get("commits_indexed", 0) > 0:
|
||||
result += f"\nGit History:\n"
|
||||
result += f" Commits: {stats.get('commits_indexed', 0):,}\n"
|
||||
result += f" Diffs: {stats.get('diffs_indexed', 0):,}\n"
|
||||
result += f" Blame entries: {stats.get('blame_entries', 0):,}\n"
|
||||
|
||||
# Vector store info
|
||||
result += f"\nVector Store:\n"
|
||||
result += f" Database: {stats.get('database_name', 'default')}\n"
|
||||
result += f" Table: {stats.get('table_name', 'documents')}\n"
|
||||
result += f" Vectors: {stats.get('vector_count', stats.get('document_count', 0)):,}\n"
|
||||
|
||||
if stats.get("errors", 0) > 0:
|
||||
result += f"\nErrors: {stats.get('errors', 0)}\n"
|
||||
|
||||
return result
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
# Tool registration is handled by the ToolRegistry
|
||||
pass
|
||||
@@ -0,0 +1,870 @@
|
||||
"""Infinity vector database integration for Hanzo AI."""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
|
||||
try:
|
||||
import infinity_embedded
|
||||
|
||||
INFINITY_AVAILABLE = True
|
||||
except ImportError:
|
||||
# Use mock implementation when infinity_embedded is not available
|
||||
from . import mock_infinity as infinity_embedded
|
||||
|
||||
INFINITY_AVAILABLE = True # Mock is always available
|
||||
|
||||
from .ast_analyzer import Symbol, FileAST, ASTAnalyzer, create_symbol_embedding_text
|
||||
|
||||
|
||||
@dataclass
|
||||
class Document:
|
||||
"""Document representation for vector storage."""
|
||||
|
||||
id: str
|
||||
content: str
|
||||
metadata: Dict[str, Any]
|
||||
file_path: Optional[str] = None
|
||||
chunk_index: Optional[int] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
"""Search result from vector database."""
|
||||
|
||||
document: Document
|
||||
score: float
|
||||
distance: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class SymbolSearchResult:
|
||||
"""Search result for symbols."""
|
||||
|
||||
symbol: Symbol
|
||||
score: float
|
||||
context_document: Optional[Document] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class UnifiedSearchResult:
|
||||
"""Search result combining text, vector, and symbol search."""
|
||||
|
||||
type: str # 'document', 'symbol', 'reference'
|
||||
content: str
|
||||
file_path: str
|
||||
line_start: int
|
||||
line_end: int
|
||||
score: float
|
||||
search_type: str # 'text', 'vector', 'symbol', 'ast'
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class InfinityVectorStore:
|
||||
"""Local vector database using Infinity."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data_path: Optional[str] = None,
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
dimension: int = 1536, # Default for OpenAI text-embedding-3-small
|
||||
):
|
||||
"""Initialize the Infinity vector store.
|
||||
|
||||
Args:
|
||||
data_path: Path to store vector database (default: ~/.config/hanzo/vector-store)
|
||||
embedding_model: Embedding model to use
|
||||
dimension: Vector dimension (must match embedding model)
|
||||
"""
|
||||
if not INFINITY_AVAILABLE:
|
||||
raise ImportError("infinity_embedded is required for vector store functionality")
|
||||
|
||||
# Set up data path
|
||||
if data_path:
|
||||
self.data_path = Path(data_path)
|
||||
else:
|
||||
from hanzo_mcp.config.settings import get_config_dir
|
||||
|
||||
self.data_path = get_config_dir() / "vector-store"
|
||||
|
||||
self.data_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.embedding_model = embedding_model
|
||||
self.dimension = dimension
|
||||
|
||||
# Initialize AST analyzer
|
||||
self.ast_analyzer = ASTAnalyzer()
|
||||
|
||||
# Connect to Infinity
|
||||
self.infinity = infinity_embedded.connect(str(self.data_path))
|
||||
self.db = self.infinity.get_database("hanzo_mcp")
|
||||
|
||||
# Initialize tables
|
||||
self._initialize_tables()
|
||||
|
||||
def _initialize_tables(self):
|
||||
"""Initialize database tables if they don't exist."""
|
||||
# Documents table
|
||||
try:
|
||||
self.documents_table = self.db.get_table("documents")
|
||||
except Exception:
|
||||
self.documents_table = self.db.create_table(
|
||||
"documents",
|
||||
{
|
||||
"id": {"type": "varchar"},
|
||||
"content": {"type": "varchar"},
|
||||
"file_path": {"type": "varchar"},
|
||||
"chunk_index": {"type": "integer"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"embedding": {"type": f"vector,{self.dimension},float"},
|
||||
},
|
||||
)
|
||||
|
||||
# Symbols table for code symbols
|
||||
try:
|
||||
self.symbols_table = self.db.get_table("symbols")
|
||||
except Exception:
|
||||
self.symbols_table = self.db.create_table(
|
||||
"symbols",
|
||||
{
|
||||
"id": {"type": "varchar"},
|
||||
"name": {"type": "varchar"},
|
||||
"type": {"type": "varchar"}, # function, class, variable, etc.
|
||||
"file_path": {"type": "varchar"},
|
||||
"line_start": {"type": "integer"},
|
||||
"line_end": {"type": "integer"},
|
||||
"scope": {"type": "varchar"},
|
||||
"parent": {"type": "varchar"},
|
||||
"signature": {"type": "varchar"},
|
||||
"docstring": {"type": "varchar"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
"embedding": {"type": f"vector,{self.dimension},float"},
|
||||
},
|
||||
)
|
||||
|
||||
# AST table for storing complete file ASTs
|
||||
try:
|
||||
self.ast_table = self.db.get_table("ast_files")
|
||||
except Exception:
|
||||
self.ast_table = self.db.create_table(
|
||||
"ast_files",
|
||||
{
|
||||
"file_path": {"type": "varchar"},
|
||||
"file_hash": {"type": "varchar"},
|
||||
"language": {"type": "varchar"},
|
||||
"ast_data": {"type": "varchar"}, # JSON string of complete AST
|
||||
"last_updated": {"type": "varchar"}, # ISO timestamp
|
||||
},
|
||||
)
|
||||
|
||||
# References table for cross-file references
|
||||
try:
|
||||
self.references_table = self.db.get_table("references")
|
||||
except Exception:
|
||||
self.references_table = self.db.create_table(
|
||||
"references",
|
||||
{
|
||||
"id": {"type": "varchar"},
|
||||
"source_file": {"type": "varchar"},
|
||||
"target_file": {"type": "varchar"},
|
||||
"symbol_name": {"type": "varchar"},
|
||||
"reference_type": {"type": "varchar"}, # import, call, inheritance, etc.
|
||||
"line_number": {"type": "integer"},
|
||||
"metadata": {"type": "varchar"}, # JSON string
|
||||
},
|
||||
)
|
||||
|
||||
def _generate_doc_id(self, content: str, file_path: str = "", chunk_index: int = 0) -> str:
|
||||
"""Generate a unique document ID."""
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
|
||||
path_hash = hashlib.sha256(file_path.encode()).hexdigest()[:8]
|
||||
return f"doc_{path_hash}_{chunk_index}_{content_hash}"
|
||||
|
||||
def add_document(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Dict[str, Any] = None,
|
||||
file_path: Optional[str] = None,
|
||||
chunk_index: int = 0,
|
||||
embedding: Optional[List[float]] = None,
|
||||
) -> str:
|
||||
"""Add a document to the vector store.
|
||||
|
||||
Args:
|
||||
content: Document content
|
||||
metadata: Additional metadata
|
||||
file_path: Source file path
|
||||
chunk_index: Chunk index if document is part of larger file
|
||||
embedding: Pre-computed embedding (if None, will compute)
|
||||
|
||||
Returns:
|
||||
Document ID
|
||||
"""
|
||||
doc_id = self._generate_doc_id(content, file_path or "", chunk_index)
|
||||
|
||||
# Generate embedding if not provided
|
||||
if embedding is None:
|
||||
embedding = self._generate_embedding(content)
|
||||
|
||||
# Prepare metadata
|
||||
metadata = metadata or {}
|
||||
metadata_json = json.dumps(metadata)
|
||||
|
||||
# Insert document
|
||||
self.documents_table.insert(
|
||||
[
|
||||
{
|
||||
"id": doc_id,
|
||||
"content": content,
|
||||
"file_path": file_path or "",
|
||||
"chunk_index": chunk_index,
|
||||
"metadata": metadata_json,
|
||||
"embedding": embedding,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
return doc_id
|
||||
|
||||
def add_file(
|
||||
self,
|
||||
file_path: str,
|
||||
chunk_size: int = 1000,
|
||||
chunk_overlap: int = 200,
|
||||
metadata: Dict[str, Any] = None,
|
||||
) -> List[str]:
|
||||
"""Add a file to the vector store by chunking it.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to add
|
||||
chunk_size: Maximum characters per chunk
|
||||
chunk_overlap: Characters to overlap between chunks
|
||||
metadata: Additional metadata for all chunks
|
||||
|
||||
Returns:
|
||||
List of document IDs for all chunks
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# Read file content
|
||||
try:
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except UnicodeDecodeError:
|
||||
# Try with different encoding
|
||||
content = path.read_text(encoding="latin-1")
|
||||
|
||||
# Chunk the content
|
||||
chunks = self._chunk_text(content, chunk_size, chunk_overlap)
|
||||
|
||||
# Add metadata
|
||||
file_metadata = metadata or {}
|
||||
file_metadata.update(
|
||||
{
|
||||
"file_name": path.name,
|
||||
"file_extension": path.suffix,
|
||||
"file_size": path.stat().st_size,
|
||||
}
|
||||
)
|
||||
|
||||
# Add each chunk
|
||||
doc_ids = []
|
||||
for i, chunk in enumerate(chunks):
|
||||
chunk_metadata = file_metadata.copy()
|
||||
chunk_metadata["chunk_number"] = i
|
||||
chunk_metadata["total_chunks"] = len(chunks)
|
||||
|
||||
doc_id = self.add_document(
|
||||
content=chunk,
|
||||
metadata=chunk_metadata,
|
||||
file_path=str(path),
|
||||
chunk_index=i,
|
||||
)
|
||||
doc_ids.append(doc_id)
|
||||
|
||||
return doc_ids
|
||||
|
||||
def add_file_with_ast(
|
||||
self,
|
||||
file_path: str,
|
||||
chunk_size: int = 1000,
|
||||
chunk_overlap: int = 200,
|
||||
metadata: Dict[str, Any] = None,
|
||||
) -> Tuple[List[str], Optional[FileAST]]:
|
||||
"""Add a file with full AST analysis and symbol extraction.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to add
|
||||
chunk_size: Maximum characters per chunk for content
|
||||
chunk_overlap: Characters to overlap between chunks
|
||||
metadata: Additional metadata for all chunks
|
||||
|
||||
Returns:
|
||||
Tuple of (document IDs for content chunks, FileAST object)
|
||||
"""
|
||||
path = Path(file_path)
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"File not found: {file_path}")
|
||||
|
||||
# First add file content using existing method
|
||||
doc_ids = self.add_file(file_path, chunk_size, chunk_overlap, metadata)
|
||||
|
||||
# Analyze AST and symbols
|
||||
file_ast = self.ast_analyzer.analyze_file(file_path)
|
||||
if not file_ast:
|
||||
return doc_ids, None
|
||||
|
||||
# Store complete AST
|
||||
self._store_file_ast(file_ast)
|
||||
|
||||
# Store individual symbols with embeddings
|
||||
self._store_symbols(file_ast.symbols)
|
||||
|
||||
# Store cross-references
|
||||
self._store_references(file_ast)
|
||||
|
||||
return doc_ids, file_ast
|
||||
|
||||
def _store_file_ast(self, file_ast: FileAST):
|
||||
"""Store complete file AST information."""
|
||||
from datetime import datetime
|
||||
|
||||
# Remove existing AST for this file
|
||||
try:
|
||||
self.ast_table.delete(f"file_path = '{file_ast.file_path}'")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Insert new AST
|
||||
self.ast_table.insert(
|
||||
[
|
||||
{
|
||||
"file_path": file_ast.file_path,
|
||||
"file_hash": file_ast.file_hash,
|
||||
"language": file_ast.language,
|
||||
"ast_data": json.dumps(file_ast.to_dict()),
|
||||
"last_updated": datetime.now().isoformat(),
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
def _store_symbols(self, symbols: List[Symbol]):
|
||||
"""Store symbols with vector embeddings."""
|
||||
if not symbols:
|
||||
return
|
||||
|
||||
# Remove existing symbols for these files
|
||||
file_paths = list(set(symbol.file_path for symbol in symbols))
|
||||
for file_path in file_paths:
|
||||
try:
|
||||
self.symbols_table.delete(f"file_path = '{file_path}'")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Insert new symbols
|
||||
symbol_records = []
|
||||
for symbol in symbols:
|
||||
# Create embedding text for symbol
|
||||
embedding_text = create_symbol_embedding_text(symbol)
|
||||
embedding = self._generate_embedding(embedding_text)
|
||||
|
||||
# Generate symbol ID
|
||||
symbol_id = self._generate_symbol_id(symbol)
|
||||
|
||||
# Prepare metadata
|
||||
symbol_metadata = {
|
||||
"references": symbol.references,
|
||||
"embedding_text": embedding_text,
|
||||
}
|
||||
|
||||
symbol_records.append(
|
||||
{
|
||||
"id": symbol_id,
|
||||
"name": symbol.name,
|
||||
"type": symbol.type,
|
||||
"file_path": symbol.file_path,
|
||||
"line_start": symbol.line_start,
|
||||
"line_end": symbol.line_end,
|
||||
"scope": symbol.scope or "",
|
||||
"parent": symbol.parent or "",
|
||||
"signature": symbol.signature or "",
|
||||
"docstring": symbol.docstring or "",
|
||||
"metadata": json.dumps(symbol_metadata),
|
||||
"embedding": embedding,
|
||||
}
|
||||
)
|
||||
|
||||
if symbol_records:
|
||||
self.symbols_table.insert(symbol_records)
|
||||
|
||||
def _store_references(self, file_ast: FileAST):
|
||||
"""Store cross-file references."""
|
||||
if not file_ast.dependencies:
|
||||
return
|
||||
|
||||
# Remove existing references for this file
|
||||
try:
|
||||
self.references_table.delete(f"source_file = '{file_ast.file_path}'")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Insert new references
|
||||
reference_records = []
|
||||
for i, dependency in enumerate(file_ast.dependencies):
|
||||
ref_id = f"{file_ast.file_path}_{dependency}_{i}"
|
||||
reference_records.append(
|
||||
{
|
||||
"id": ref_id,
|
||||
"source_file": file_ast.file_path,
|
||||
"target_file": dependency,
|
||||
"symbol_name": dependency,
|
||||
"reference_type": "import",
|
||||
"line_number": 0, # Could be enhanced to track actual line numbers
|
||||
"metadata": json.dumps({}),
|
||||
}
|
||||
)
|
||||
|
||||
if reference_records:
|
||||
self.references_table.insert(reference_records)
|
||||
|
||||
def _generate_symbol_id(self, symbol: Symbol) -> str:
|
||||
"""Generate unique symbol ID."""
|
||||
text = f"{symbol.file_path}_{symbol.type}_{symbol.name}_{symbol.line_start}"
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
def search_symbols(
|
||||
self,
|
||||
query: str,
|
||||
symbol_type: Optional[str] = None,
|
||||
file_path: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.0,
|
||||
) -> List[SymbolSearchResult]:
|
||||
"""Search for symbols using vector similarity.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
symbol_type: Filter by symbol type (function, class, variable, etc.)
|
||||
file_path: Filter by file path
|
||||
limit: Maximum number of results
|
||||
score_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
List of symbol search results
|
||||
"""
|
||||
# Generate query embedding
|
||||
query_embedding = self._generate_embedding(query)
|
||||
|
||||
# Build search query
|
||||
search_query = self.symbols_table.output(["*"]).match_dense(
|
||||
"embedding",
|
||||
query_embedding,
|
||||
"float",
|
||||
"ip", # Inner product
|
||||
limit * 2, # Get more results for filtering
|
||||
)
|
||||
|
||||
# Apply filters
|
||||
if symbol_type:
|
||||
search_query = search_query.filter(f"type = '{symbol_type}'")
|
||||
if file_path:
|
||||
search_query = search_query.filter(f"file_path = '{file_path}'")
|
||||
|
||||
search_results = search_query.to_pl()
|
||||
|
||||
# Convert to SymbolSearchResult objects
|
||||
results = []
|
||||
for row in search_results.iter_rows(named=True):
|
||||
score = row.get("score", 0.0)
|
||||
if score >= score_threshold:
|
||||
# Parse metadata
|
||||
try:
|
||||
metadata = json.loads(row["metadata"])
|
||||
except Exception:
|
||||
metadata = {}
|
||||
|
||||
# Create Symbol object
|
||||
symbol = Symbol(
|
||||
name=row["name"],
|
||||
type=row["type"],
|
||||
file_path=row["file_path"],
|
||||
line_start=row["line_start"],
|
||||
line_end=row["line_end"],
|
||||
column_start=0, # Not stored in table
|
||||
column_end=0, # Not stored in table
|
||||
scope=row["scope"],
|
||||
parent=row["parent"] if row["parent"] else None,
|
||||
docstring=row["docstring"] if row["docstring"] else None,
|
||||
signature=row["signature"] if row["signature"] else None,
|
||||
references=metadata.get("references", []),
|
||||
)
|
||||
|
||||
results.append(
|
||||
SymbolSearchResult(
|
||||
symbol=symbol,
|
||||
score=score,
|
||||
)
|
||||
)
|
||||
|
||||
return results[:limit]
|
||||
|
||||
def search_ast_nodes(
|
||||
self,
|
||||
file_path: str,
|
||||
node_type: Optional[str] = None,
|
||||
node_name: Optional[str] = None,
|
||||
) -> Optional[FileAST]:
|
||||
"""Search AST nodes within a specific file.
|
||||
|
||||
Args:
|
||||
file_path: File to search in
|
||||
node_type: Filter by AST node type
|
||||
node_name: Filter by node name
|
||||
|
||||
Returns:
|
||||
FileAST object if file found, None otherwise
|
||||
"""
|
||||
try:
|
||||
results = self.ast_table.output(["*"]).filter(f"file_path = '{file_path}'").to_pl()
|
||||
|
||||
if len(results) == 0:
|
||||
return None
|
||||
|
||||
row = next(results.iter_rows(named=True))
|
||||
ast_data = json.loads(row["ast_data"])
|
||||
|
||||
# Reconstruct FileAST object
|
||||
file_ast = FileAST(
|
||||
file_path=ast_data["file_path"],
|
||||
file_hash=ast_data["file_hash"],
|
||||
language=ast_data["language"],
|
||||
symbols=[Symbol(**s) for s in ast_data["symbols"]],
|
||||
ast_nodes=[], # Would need custom deserialization for ASTNode
|
||||
imports=ast_data["imports"],
|
||||
exports=ast_data["exports"],
|
||||
dependencies=ast_data["dependencies"],
|
||||
)
|
||||
|
||||
return file_ast
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error searching AST nodes: {e}")
|
||||
return None
|
||||
|
||||
def get_file_references(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
"""Get all files that reference the given file.
|
||||
|
||||
Args:
|
||||
file_path: File to find references for
|
||||
|
||||
Returns:
|
||||
List of reference information
|
||||
"""
|
||||
try:
|
||||
results = self.references_table.output(["*"]).filter(f"target_file = '{file_path}'").to_pl()
|
||||
|
||||
references = []
|
||||
for row in results.iter_rows(named=True):
|
||||
references.append(
|
||||
{
|
||||
"source_file": row["source_file"],
|
||||
"symbol_name": row["symbol_name"],
|
||||
"reference_type": row["reference_type"],
|
||||
"line_number": row["line_number"],
|
||||
}
|
||||
)
|
||||
|
||||
return references
|
||||
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error getting file references: {e}")
|
||||
return []
|
||||
|
||||
def search(
|
||||
self,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.0,
|
||||
filters: Dict[str, Any] = None,
|
||||
) -> List[SearchResult]:
|
||||
"""Search for similar documents.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit: Maximum number of results
|
||||
score_threshold: Minimum similarity score
|
||||
filters: Metadata filters (not yet implemented)
|
||||
|
||||
Returns:
|
||||
List of search results
|
||||
"""
|
||||
# Generate query embedding
|
||||
query_embedding = self._generate_embedding(query)
|
||||
|
||||
# Perform vector search
|
||||
search_results = (
|
||||
self.documents_table.output(["*"])
|
||||
.match_dense(
|
||||
"embedding",
|
||||
query_embedding,
|
||||
"float",
|
||||
"ip", # Inner product (cosine similarity)
|
||||
limit,
|
||||
)
|
||||
.to_pl()
|
||||
)
|
||||
|
||||
# Convert to SearchResult objects
|
||||
results = []
|
||||
for row in search_results.iter_rows(named=True):
|
||||
# Parse metadata
|
||||
try:
|
||||
metadata = json.loads(row["metadata"])
|
||||
except Exception:
|
||||
metadata = {}
|
||||
|
||||
# Create document
|
||||
document = Document(
|
||||
id=row["id"],
|
||||
content=row["content"],
|
||||
metadata=metadata,
|
||||
file_path=row["file_path"] if row["file_path"] else None,
|
||||
chunk_index=row["chunk_index"],
|
||||
)
|
||||
|
||||
# Score is the similarity (higher is better)
|
||||
score = row.get("score", 0.0)
|
||||
distance = 1.0 - score # Convert similarity to distance
|
||||
|
||||
if score >= score_threshold:
|
||||
results.append(
|
||||
SearchResult(
|
||||
document=document,
|
||||
score=score,
|
||||
distance=distance,
|
||||
)
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def delete_document(self, doc_id: str) -> bool:
|
||||
"""Delete a document by ID.
|
||||
|
||||
Args:
|
||||
doc_id: Document ID to delete
|
||||
|
||||
Returns:
|
||||
True if document was deleted
|
||||
"""
|
||||
try:
|
||||
self.documents_table.delete(f"id = '{doc_id}'")
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def delete_file(self, file_path: str) -> int:
|
||||
"""Delete all documents from a specific file.
|
||||
|
||||
Args:
|
||||
file_path: File path to delete documents for
|
||||
|
||||
Returns:
|
||||
Number of documents deleted
|
||||
"""
|
||||
try:
|
||||
# Get count first
|
||||
results = self.documents_table.output(["id"]).filter(f"file_path = '{file_path}'").to_pl()
|
||||
count = len(results)
|
||||
|
||||
# Delete all documents for this file
|
||||
self.documents_table.delete(f"file_path = '{file_path}'")
|
||||
return count
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def list_files(self) -> List[Dict[str, Any]]:
|
||||
"""List all indexed files.
|
||||
|
||||
Returns:
|
||||
List of file information
|
||||
"""
|
||||
try:
|
||||
results = self.documents_table.output(["file_path", "metadata"]).to_pl()
|
||||
|
||||
files = {}
|
||||
for row in results.iter_rows(named=True):
|
||||
file_path = row["file_path"]
|
||||
if file_path and file_path not in files:
|
||||
try:
|
||||
metadata = json.loads(row["metadata"])
|
||||
files[file_path] = {
|
||||
"file_path": file_path,
|
||||
"file_name": metadata.get("file_name", Path(file_path).name),
|
||||
"file_size": metadata.get("file_size", 0),
|
||||
"total_chunks": metadata.get("total_chunks", 1),
|
||||
}
|
||||
except Exception:
|
||||
files[file_path] = {
|
||||
"file_path": file_path,
|
||||
"file_name": Path(file_path).name,
|
||||
}
|
||||
|
||||
return list(files.values())
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def _chunk_text(self, text: str, chunk_size: int, overlap: int) -> List[str]:
|
||||
"""Split text into overlapping chunks."""
|
||||
if len(text) <= chunk_size:
|
||||
return [text]
|
||||
|
||||
chunks = []
|
||||
start = 0
|
||||
|
||||
while start < len(text):
|
||||
end = start + chunk_size
|
||||
|
||||
# Try to break at word boundary
|
||||
if end < len(text):
|
||||
# Look back for a good break point
|
||||
break_point = end
|
||||
for i in range(end - 100, start + 100, -1):
|
||||
if i > 0 and text[i] in "\n\r.!?":
|
||||
break_point = i + 1
|
||||
break
|
||||
end = break_point
|
||||
|
||||
chunk = text[start:end].strip()
|
||||
if chunk:
|
||||
chunks.append(chunk)
|
||||
|
||||
start = max(start + chunk_size - overlap, end)
|
||||
|
||||
return chunks
|
||||
|
||||
def _generate_embedding(self, text: str) -> List[float]:
|
||||
"""Generate embedding for text.
|
||||
|
||||
For now, this returns a dummy embedding. In a real implementation,
|
||||
you would call an embedding API (OpenAI, Cohere, etc.) or use a local model.
|
||||
"""
|
||||
# This is a placeholder - you would implement actual embedding generation here
|
||||
# For now, return a random embedding of the correct dimension
|
||||
import random
|
||||
|
||||
return [random.random() for _ in range(self.dimension)]
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about the vector store.
|
||||
|
||||
Returns:
|
||||
Dictionary with statistics
|
||||
"""
|
||||
try:
|
||||
# Get document count
|
||||
doc_count_result = self.documents_table.output(["count(*)"]).to_pl()
|
||||
doc_count = doc_count_result.item(0, 0) if len(doc_count_result) > 0 else 0
|
||||
|
||||
# Get unique file count
|
||||
file_result = self.documents_table.output(["file_path"]).to_pl()
|
||||
unique_files = set()
|
||||
for row in file_result.iter_rows():
|
||||
if row[0]:
|
||||
unique_files.add(row[0])
|
||||
|
||||
# Get symbol count
|
||||
symbol_count = 0
|
||||
try:
|
||||
symbol_result = self.symbols_table.output(["count(*)"]).to_pl()
|
||||
symbol_count = symbol_result.item(0, 0) if len(symbol_result) > 0 else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Get AST count
|
||||
ast_count = 0
|
||||
try:
|
||||
ast_result = self.ast_table.output(["count(*)"]).to_pl()
|
||||
ast_count = ast_result.item(0, 0) if len(ast_result) > 0 else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"document_count": doc_count,
|
||||
"vector_count": doc_count, # Each document has a vector
|
||||
"unique_files": len(unique_files),
|
||||
"symbol_count": symbol_count,
|
||||
"ast_count": ast_count,
|
||||
"database_name": self.db_name,
|
||||
"table_name": "documents",
|
||||
"dimension": self.dimension,
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"error": str(e),
|
||||
"document_count": 0,
|
||||
"vector_count": 0,
|
||||
}
|
||||
|
||||
async def clear(self) -> bool:
|
||||
"""Clear all data from the vector store.
|
||||
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
try:
|
||||
# Delete all records from all tables
|
||||
self.documents_table.delete()
|
||||
|
||||
try:
|
||||
self.symbols_table.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.ast_table.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
self.references_table.delete()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error clearing vector store: {e}")
|
||||
return False
|
||||
|
||||
async def index_document(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Dict[str, Any] = None,
|
||||
) -> str:
|
||||
"""Async version of add_document for consistency.
|
||||
|
||||
Args:
|
||||
content: Document content
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
Document ID
|
||||
"""
|
||||
file_path = metadata.get("path") if metadata else None
|
||||
return self.add_document(content, metadata, file_path)
|
||||
|
||||
def close(self):
|
||||
"""Close the database connection."""
|
||||
if hasattr(self, "infinity"):
|
||||
self.infinity.disconnect()
|
||||
@@ -0,0 +1,159 @@
|
||||
"""Mock implementation of infinity_embedded for testing on unsupported platforms."""
|
||||
|
||||
import random
|
||||
from typing import Any, Dict, List
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class MockTable:
|
||||
"""Mock implementation of an Infinity table."""
|
||||
|
||||
def __init__(self, name: str, schema: Dict[str, Any]):
|
||||
self.name = name
|
||||
self.schema = schema
|
||||
self.data = []
|
||||
self._id_counter = 0
|
||||
|
||||
def insert(self, records: List[Dict[str, Any]]):
|
||||
"""Insert records into the table."""
|
||||
for record in records:
|
||||
# Add an internal ID if not present
|
||||
if "id" not in record:
|
||||
record["_internal_id"] = self._id_counter
|
||||
self._id_counter += 1
|
||||
self.data.append(record)
|
||||
|
||||
def delete(self, condition: str):
|
||||
"""Delete records matching condition."""
|
||||
# Simple implementation - just clear for now
|
||||
self.data = [r for r in self.data if not self._eval_condition(r, condition)]
|
||||
|
||||
def output(self, columns: List[str]):
|
||||
"""Start a query chain."""
|
||||
return MockQuery(self, columns)
|
||||
|
||||
def _eval_condition(self, record: Dict[str, Any], condition: str) -> bool:
|
||||
"""Evaluate a simple condition."""
|
||||
# Very basic implementation
|
||||
if "=" in condition:
|
||||
field, value = condition.split("=", 1)
|
||||
field = field.strip()
|
||||
value = value.strip().strip("'\"")
|
||||
return str(record.get(field, "")) == value
|
||||
return False
|
||||
|
||||
|
||||
class MockQuery:
|
||||
"""Mock query builder."""
|
||||
|
||||
def __init__(self, table: MockTable, columns: List[str]):
|
||||
self.table = table
|
||||
self.columns = columns
|
||||
self.filters = []
|
||||
self.vector_search = None
|
||||
self.limit_value = None
|
||||
|
||||
def filter(self, condition: str):
|
||||
"""Add a filter condition."""
|
||||
self.filters.append(condition)
|
||||
return self
|
||||
|
||||
def match_dense(self, column: str, vector: List[float], dtype: str, metric: str, limit: int):
|
||||
"""Add vector search."""
|
||||
self.vector_search = {
|
||||
"column": column,
|
||||
"vector": vector,
|
||||
"dtype": dtype,
|
||||
"metric": metric,
|
||||
"limit": limit,
|
||||
}
|
||||
self.limit_value = limit
|
||||
return self
|
||||
|
||||
def to_pl(self):
|
||||
"""Execute query and return polars-like result."""
|
||||
results = self.table.data.copy()
|
||||
|
||||
# Apply filters
|
||||
for condition in self.filters:
|
||||
results = [r for r in results if self.table._eval_condition(r, condition)]
|
||||
|
||||
# Apply vector search (mock similarity)
|
||||
if self.vector_search:
|
||||
# Add mock scores
|
||||
for r in results:
|
||||
r["score"] = random.uniform(0.5, 1.0)
|
||||
# Sort by score
|
||||
results.sort(key=lambda x: x.get("score", 0), reverse=True)
|
||||
# Limit results
|
||||
if self.limit_value:
|
||||
results = results[: self.limit_value]
|
||||
|
||||
# Return mock polars DataFrame
|
||||
return MockDataFrame(results)
|
||||
|
||||
|
||||
class MockDataFrame:
|
||||
"""Mock polars DataFrame."""
|
||||
|
||||
def __init__(self, data: List[Dict[str, Any]]):
|
||||
self.data = data
|
||||
|
||||
def __len__(self):
|
||||
return len(self.data)
|
||||
|
||||
def iter_rows(self, named: bool = False):
|
||||
"""Iterate over rows."""
|
||||
if named:
|
||||
return iter(self.data)
|
||||
else:
|
||||
# Return tuples
|
||||
if not self.data:
|
||||
return iter([])
|
||||
keys = list(self.data[0].keys())
|
||||
return iter([tuple(row.get(k) for k in keys) for row in self.data])
|
||||
|
||||
|
||||
class MockDatabase:
|
||||
"""Mock implementation of an Infinity database."""
|
||||
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
self.tables = {}
|
||||
|
||||
def create_table(self, name: str, schema: Dict[str, Any]) -> MockTable:
|
||||
"""Create a new table."""
|
||||
table = MockTable(name, schema)
|
||||
self.tables[name] = table
|
||||
return table
|
||||
|
||||
def get_table(self, name: str) -> MockTable:
|
||||
"""Get an existing table."""
|
||||
if name not in self.tables:
|
||||
raise KeyError(f"Table {name} not found")
|
||||
return self.tables[name]
|
||||
|
||||
|
||||
class MockInfinity:
|
||||
"""Mock implementation of Infinity connection."""
|
||||
|
||||
def __init__(self, path: str):
|
||||
self.path = Path(path)
|
||||
self.databases = {}
|
||||
# Ensure directory exists
|
||||
self.path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_database(self, name: str) -> MockDatabase:
|
||||
"""Get or create a database."""
|
||||
if name not in self.databases:
|
||||
self.databases[name] = MockDatabase(name)
|
||||
return self.databases[name]
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect from Infinity."""
|
||||
pass
|
||||
|
||||
|
||||
def connect(path: str) -> MockInfinity:
|
||||
"""Connect to Infinity (mock implementation)."""
|
||||
return MockInfinity(path)
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Project-aware vector database management for Hanzo AI."""
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Dict, List, Tuple, Optional
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from .index_config import IndexScope, IndexConfig
|
||||
|
||||
from .infinity_store import SearchResult, InfinityVectorStore
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProjectInfo:
|
||||
"""Information about a detected project."""
|
||||
|
||||
root_path: Path
|
||||
llm_md_path: Path
|
||||
db_path: Path
|
||||
name: str
|
||||
|
||||
|
||||
class ProjectVectorManager:
|
||||
"""Manages project-aware vector databases."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
global_db_path: Optional[str] = None,
|
||||
embedding_model: str = "text-embedding-3-small",
|
||||
dimension: int = 1536,
|
||||
):
|
||||
"""Initialize the project vector manager.
|
||||
|
||||
Args:
|
||||
global_db_path: Path for global vector store (default: ~/.config/hanzo/db)
|
||||
embedding_model: Embedding model to use
|
||||
dimension: Vector dimension
|
||||
"""
|
||||
self.embedding_model = embedding_model
|
||||
self.dimension = dimension
|
||||
|
||||
# Set up index configuration
|
||||
self.index_config = IndexConfig()
|
||||
|
||||
# Set up global database path
|
||||
if global_db_path:
|
||||
self.global_db_path = Path(global_db_path)
|
||||
else:
|
||||
self.global_db_path = self.index_config.get_index_path("vector")
|
||||
|
||||
self.global_db_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Cache for project info and vector stores
|
||||
self.projects: Dict[str, ProjectInfo] = {}
|
||||
self.vector_stores: Dict[str, InfinityVectorStore] = {}
|
||||
self._global_store: Optional[InfinityVectorStore] = None
|
||||
|
||||
# Thread pool for parallel operations
|
||||
self.executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
def _get_global_store(self) -> InfinityVectorStore:
|
||||
"""Get or create the global vector store."""
|
||||
if self._global_store is None:
|
||||
self._global_store = InfinityVectorStore(
|
||||
data_path=str(self.global_db_path),
|
||||
embedding_model=self.embedding_model,
|
||||
dimension=self.dimension,
|
||||
)
|
||||
return self._global_store
|
||||
|
||||
def detect_projects(self, search_paths: List[str]) -> List[ProjectInfo]:
|
||||
"""Detect projects by finding LLM.md files.
|
||||
|
||||
Args:
|
||||
search_paths: List of paths to search for projects
|
||||
|
||||
Returns:
|
||||
List of detected project information
|
||||
"""
|
||||
projects = []
|
||||
|
||||
for search_path in search_paths:
|
||||
path = Path(search_path).resolve()
|
||||
|
||||
# Search for LLM.md files
|
||||
for llm_md_path in path.rglob("LLM.md"):
|
||||
project_root = llm_md_path.parent
|
||||
project_name = project_root.name
|
||||
|
||||
# Create .hanzo/db directory in project
|
||||
db_path = project_root / ".hanzo" / "db"
|
||||
db_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
project_info = ProjectInfo(
|
||||
root_path=project_root,
|
||||
llm_md_path=llm_md_path,
|
||||
db_path=db_path,
|
||||
name=project_name,
|
||||
)
|
||||
|
||||
projects.append(project_info)
|
||||
|
||||
# Cache project info
|
||||
project_key = str(project_root)
|
||||
self.projects[project_key] = project_info
|
||||
|
||||
return projects
|
||||
|
||||
def get_project_for_path(self, file_path: str) -> Optional[ProjectInfo]:
|
||||
"""Find the project that contains a given file path.
|
||||
|
||||
Args:
|
||||
file_path: File path to check
|
||||
|
||||
Returns:
|
||||
Project info if found, None otherwise
|
||||
"""
|
||||
path = Path(file_path).resolve()
|
||||
|
||||
# Check each known project
|
||||
for project_key, project_info in self.projects.items():
|
||||
try:
|
||||
# Check if path is within project root
|
||||
path.relative_to(project_info.root_path)
|
||||
return project_info
|
||||
except ValueError:
|
||||
# Path is not within this project
|
||||
continue
|
||||
|
||||
# Try to find project by walking up the directory tree
|
||||
current_path = path.parent if path.is_file() else path
|
||||
|
||||
while current_path != current_path.parent: # Stop at filesystem root
|
||||
llm_md_path = current_path / "LLM.md"
|
||||
if llm_md_path.exists():
|
||||
# Found a project, create and cache it
|
||||
db_path = current_path / ".hanzo" / "db"
|
||||
db_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
project_info = ProjectInfo(
|
||||
root_path=current_path,
|
||||
llm_md_path=llm_md_path,
|
||||
db_path=db_path,
|
||||
name=current_path.name,
|
||||
)
|
||||
|
||||
project_key = str(current_path)
|
||||
self.projects[project_key] = project_info
|
||||
return project_info
|
||||
|
||||
current_path = current_path.parent
|
||||
|
||||
return None
|
||||
|
||||
def get_vector_store(self, project_info: Optional[ProjectInfo] = None) -> InfinityVectorStore:
|
||||
"""Get vector store for a project or global store.
|
||||
|
||||
Args:
|
||||
project_info: Project to get store for, None for global store
|
||||
|
||||
Returns:
|
||||
Vector store instance
|
||||
"""
|
||||
# Check indexing scope
|
||||
if project_info:
|
||||
scope = self.index_config.get_scope(str(project_info.root_path))
|
||||
if scope == IndexScope.GLOBAL:
|
||||
# Even for project files, use global store if configured
|
||||
return self._get_global_store()
|
||||
else:
|
||||
return self._get_global_store()
|
||||
|
||||
# Use project-specific store
|
||||
project_key = str(project_info.root_path)
|
||||
|
||||
if project_key not in self.vector_stores:
|
||||
# Get index path based on configuration
|
||||
index_path = self.index_config.get_index_path("vector", str(project_info.root_path))
|
||||
index_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.vector_stores[project_key] = InfinityVectorStore(
|
||||
data_path=str(index_path),
|
||||
embedding_model=self.embedding_model,
|
||||
dimension=self.dimension,
|
||||
)
|
||||
|
||||
return self.vector_stores[project_key]
|
||||
|
||||
def add_file_to_appropriate_store(
|
||||
self,
|
||||
file_path: str,
|
||||
chunk_size: int = 1000,
|
||||
chunk_overlap: int = 200,
|
||||
metadata: Dict[str, Any] = None,
|
||||
) -> Tuple[List[str], Optional[ProjectInfo]]:
|
||||
"""Add a file to the appropriate vector store (project or global).
|
||||
|
||||
Args:
|
||||
file_path: Path to file to add
|
||||
chunk_size: Chunk size for text splitting
|
||||
chunk_overlap: Overlap between chunks
|
||||
metadata: Additional metadata
|
||||
|
||||
Returns:
|
||||
Tuple of (document IDs, project info or None for global)
|
||||
"""
|
||||
# Check if indexing is enabled
|
||||
if not self.index_config.is_indexing_enabled("vector"):
|
||||
return [], None
|
||||
|
||||
# Find project for this file
|
||||
project_info = self.get_project_for_path(file_path)
|
||||
|
||||
# Get appropriate vector store based on scope configuration
|
||||
vector_store = self.get_vector_store(project_info)
|
||||
|
||||
# Add file metadata
|
||||
file_metadata = metadata or {}
|
||||
if project_info:
|
||||
file_metadata["project_name"] = project_info.name
|
||||
file_metadata["project_root"] = str(project_info.root_path)
|
||||
# Check actual scope used
|
||||
scope = self.index_config.get_scope(str(project_info.root_path))
|
||||
file_metadata["index_scope"] = scope.value
|
||||
else:
|
||||
file_metadata["project_name"] = "global"
|
||||
file_metadata["index_scope"] = "global"
|
||||
|
||||
# Add file to store
|
||||
doc_ids = vector_store.add_file(
|
||||
file_path=file_path,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
metadata=file_metadata,
|
||||
)
|
||||
|
||||
return doc_ids, project_info
|
||||
|
||||
async def search_all_projects(
|
||||
self,
|
||||
query: str,
|
||||
limit_per_project: int = 5,
|
||||
score_threshold: float = 0.0,
|
||||
include_global: bool = True,
|
||||
project_filter: Optional[List[str]] = None,
|
||||
) -> Dict[str, List[SearchResult]]:
|
||||
"""Search across all projects in parallel.
|
||||
|
||||
Args:
|
||||
query: Search query
|
||||
limit_per_project: Maximum results per project
|
||||
score_threshold: Minimum similarity score
|
||||
include_global: Whether to include global store
|
||||
project_filter: List of project names to search (None for all)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping project names to search results
|
||||
"""
|
||||
search_tasks = []
|
||||
project_names = []
|
||||
|
||||
# Add global store if requested
|
||||
if include_global:
|
||||
global_store = self._get_global_store()
|
||||
search_tasks.append(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
lambda: global_store.search(query, limit_per_project, score_threshold),
|
||||
)
|
||||
)
|
||||
project_names.append("global")
|
||||
|
||||
# Add project stores
|
||||
for _project_key, project_info in self.projects.items():
|
||||
# Apply project filter
|
||||
if project_filter and project_info.name not in project_filter:
|
||||
continue
|
||||
|
||||
vector_store = self.get_vector_store(project_info)
|
||||
search_tasks.append(
|
||||
asyncio.get_event_loop().run_in_executor(
|
||||
self.executor,
|
||||
lambda vs=vector_store: vs.search(query, limit_per_project, score_threshold),
|
||||
)
|
||||
)
|
||||
project_names.append(project_info.name)
|
||||
|
||||
# Execute all searches in parallel
|
||||
results = await asyncio.gather(*search_tasks, return_exceptions=True)
|
||||
|
||||
# Combine results
|
||||
combined_results = {}
|
||||
for i, result in enumerate(results):
|
||||
project_name = project_names[i]
|
||||
if isinstance(result, Exception):
|
||||
# Log error but continue
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.error(f"Error searching project {project_name}: {result}")
|
||||
combined_results[project_name] = []
|
||||
else:
|
||||
combined_results[project_name] = result
|
||||
|
||||
return combined_results
|
||||
|
||||
def search_project_by_path(
|
||||
self,
|
||||
file_path: str,
|
||||
query: str,
|
||||
limit: int = 10,
|
||||
score_threshold: float = 0.0,
|
||||
) -> List[SearchResult]:
|
||||
"""Search the project containing a specific file path.
|
||||
|
||||
Args:
|
||||
file_path: File path to determine project
|
||||
query: Search query
|
||||
limit: Maximum results
|
||||
score_threshold: Minimum similarity score
|
||||
|
||||
Returns:
|
||||
Search results from the appropriate project store
|
||||
"""
|
||||
project_info = self.get_project_for_path(file_path)
|
||||
vector_store = self.get_vector_store(project_info)
|
||||
|
||||
return vector_store.search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
|
||||
def get_project_stats(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Get statistics for all projects.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping project names to stats
|
||||
"""
|
||||
stats = {}
|
||||
|
||||
# Global store stats
|
||||
try:
|
||||
global_store = self._get_global_store()
|
||||
global_files = global_store.list_files()
|
||||
stats["global"] = {
|
||||
"file_count": len(global_files),
|
||||
"db_path": str(self.global_db_path),
|
||||
}
|
||||
except Exception as e:
|
||||
stats["global"] = {"error": str(e)}
|
||||
|
||||
# Project store stats
|
||||
for _project_key, project_info in self.projects.items():
|
||||
try:
|
||||
vector_store = self.get_vector_store(project_info)
|
||||
project_files = vector_store.list_files()
|
||||
stats[project_info.name] = {
|
||||
"file_count": len(project_files),
|
||||
"db_path": str(project_info.db_path),
|
||||
"root_path": str(project_info.root_path),
|
||||
"llm_md_exists": project_info.llm_md_path.exists(),
|
||||
}
|
||||
except Exception as e:
|
||||
stats[project_info.name] = {"error": str(e)}
|
||||
|
||||
return stats
|
||||
|
||||
def cleanup(self):
|
||||
"""Close all vector stores and cleanup resources."""
|
||||
# Close all project stores
|
||||
for vector_store in self.vector_stores.values():
|
||||
try:
|
||||
vector_store.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Close global store
|
||||
if self._global_store:
|
||||
try:
|
||||
self._global_store.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Shutdown executor
|
||||
self.executor.shutdown(wait=False)
|
||||
@@ -0,0 +1,326 @@
|
||||
"""Unified vector store tool."""
|
||||
|
||||
from typing import (
|
||||
Any,
|
||||
Dict,
|
||||
Unpack,
|
||||
Optional,
|
||||
Annotated,
|
||||
TypedDict,
|
||||
final,
|
||||
override,
|
||||
)
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import PermissionManager
|
||||
from hanzo_tools.core import auto_timeout
|
||||
from .project_manager import ProjectVectorManager
|
||||
|
||||
# Parameter types
|
||||
Action = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="Action: search (default), index, stats, clear",
|
||||
default="search",
|
||||
),
|
||||
]
|
||||
|
||||
Query = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Search query for semantic similarity",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Path = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="Path to index or search within",
|
||||
default=".",
|
||||
),
|
||||
]
|
||||
|
||||
Include = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="File pattern to include (e.g., '*.py')",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Exclude = Annotated[
|
||||
Optional[str],
|
||||
Field(
|
||||
description="File pattern to exclude",
|
||||
default=None,
|
||||
),
|
||||
]
|
||||
|
||||
Limit = Annotated[
|
||||
int,
|
||||
Field(
|
||||
description="Maximum results to return",
|
||||
default=10,
|
||||
),
|
||||
]
|
||||
|
||||
IncludeGit = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Include git history in indexing",
|
||||
default=True,
|
||||
),
|
||||
]
|
||||
|
||||
ForceReindex = Annotated[
|
||||
bool,
|
||||
Field(
|
||||
description="Force reindexing even if up to date",
|
||||
default=False,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
class VectorParams(TypedDict, total=False):
|
||||
"""Parameters for vector tool."""
|
||||
|
||||
action: str
|
||||
query: Optional[str]
|
||||
path: Optional[str]
|
||||
include: Optional[str]
|
||||
exclude: Optional[str]
|
||||
limit: int
|
||||
include_git: bool
|
||||
force_reindex: bool
|
||||
|
||||
|
||||
@final
|
||||
class VectorTool(BaseTool):
|
||||
"""Unified vector store tool for semantic search."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
permission_manager: PermissionManager,
|
||||
project_manager: ProjectVectorManager,
|
||||
):
|
||||
"""Initialize the vector tool."""
|
||||
super().__init__(permission_manager)
|
||||
self.project_manager = project_manager
|
||||
|
||||
@property
|
||||
@override
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "vector"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Semantic search with embeddings. Actions: search (default), index, stats, clear.
|
||||
|
||||
Usage:
|
||||
vector "find authentication logic"
|
||||
vector --action index --path ./src --include "*.py"
|
||||
vector --action stats
|
||||
vector --action clear --path ./old_code
|
||||
"""
|
||||
|
||||
@override
|
||||
@auto_timeout("vector")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[VectorParams],
|
||||
) -> str:
|
||||
"""Execute vector operation."""
|
||||
tool_ctx = self.create_tool_context(ctx)
|
||||
|
||||
# Extract action
|
||||
action = params.get("action", "search")
|
||||
|
||||
# Route to appropriate handler
|
||||
if action == "search":
|
||||
return await self._handle_search(params, tool_ctx)
|
||||
elif action == "index":
|
||||
return await self._handle_index(params, tool_ctx)
|
||||
elif action == "stats":
|
||||
return await self._handle_stats(params, tool_ctx)
|
||||
elif action == "clear":
|
||||
return await self._handle_clear(params, tool_ctx)
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: search, index, stats, clear"
|
||||
|
||||
async def _handle_search(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Handle semantic search."""
|
||||
query = params.get("query")
|
||||
if not query:
|
||||
return "Error: query is required for search action"
|
||||
|
||||
path = params.get("path", ".")
|
||||
limit = params.get("limit", 10)
|
||||
|
||||
# Validate path
|
||||
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
||||
if not allowed:
|
||||
return error_msg
|
||||
|
||||
try:
|
||||
# Determine search scope
|
||||
project = self.project_manager.get_project_for_path(path)
|
||||
if not project:
|
||||
return "Error: No indexed project found for this path. Run 'vector --action index' first."
|
||||
|
||||
# Search
|
||||
await tool_ctx.info(f"Searching for: {query}")
|
||||
results = project.search(query, k=limit)
|
||||
|
||||
if not results:
|
||||
return f"No results found for: {query}"
|
||||
|
||||
# Format results
|
||||
output = [f"=== Vector Search Results for '{query}' ==="]
|
||||
output.append(f"Found {len(results)} matches\n")
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
score = result.get("score", 0)
|
||||
file_path = result.get("file_path", "unknown")
|
||||
content = result.get("content", "")
|
||||
chunk_type = result.get("metadata", {}).get("type", "content")
|
||||
|
||||
output.append(f"Result {i} - Score: {score:.1%}")
|
||||
output.append(f"File: {file_path}")
|
||||
if chunk_type != "content":
|
||||
output.append(f"Type: {chunk_type}")
|
||||
output.append("-" * 60)
|
||||
|
||||
# Truncate content if too long
|
||||
if len(content) > 300:
|
||||
content = content[:300] + "..."
|
||||
output.append(content)
|
||||
output.append("")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Search failed: {str(e)}")
|
||||
return f"Error during search: {str(e)}"
|
||||
|
||||
async def _handle_index(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Handle indexing files."""
|
||||
path = params.get("path", ".")
|
||||
include = params.get("include")
|
||||
exclude = params.get("exclude")
|
||||
include_git = params.get("include_git", True)
|
||||
force = params.get("force_reindex", False)
|
||||
|
||||
# Validate path
|
||||
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
||||
if not allowed:
|
||||
return error_msg
|
||||
|
||||
try:
|
||||
await tool_ctx.info(f"Indexing {path}...")
|
||||
|
||||
# Get or create project
|
||||
project = self.project_manager.get_or_create_project(path)
|
||||
|
||||
# Index files
|
||||
stats = await project.index_directory(
|
||||
path,
|
||||
include_pattern=include,
|
||||
exclude_pattern=exclude,
|
||||
force_reindex=force,
|
||||
)
|
||||
|
||||
# Index git history if requested
|
||||
if include_git and Path(path).joinpath(".git").exists():
|
||||
await tool_ctx.info("Indexing git history...")
|
||||
git_stats = await project.index_git_history(path)
|
||||
stats["git_commits"] = git_stats.get("commits_indexed", 0)
|
||||
|
||||
# Format output
|
||||
output = [f"=== Indexing Complete ==="]
|
||||
output.append(f"Path: {path}")
|
||||
output.append(f"Files indexed: {stats.get('files_indexed', 0)}")
|
||||
output.append(f"Chunks created: {stats.get('chunks_created', 0)}")
|
||||
if stats.get("git_commits"):
|
||||
output.append(f"Git commits indexed: {stats['git_commits']}")
|
||||
output.append(f"Total documents: {project.get_stats().get('total_documents', 0)}")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Indexing failed: {str(e)}")
|
||||
return f"Error during indexing: {str(e)}"
|
||||
|
||||
async def _handle_stats(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Get vector store statistics."""
|
||||
path = params.get("path")
|
||||
|
||||
try:
|
||||
if path:
|
||||
# Stats for specific project
|
||||
project = self.project_manager.get_project_for_path(path)
|
||||
if not project:
|
||||
return f"No indexed project found for path: {path}"
|
||||
|
||||
stats = project.get_stats()
|
||||
output = [f"=== Vector Store Stats for {project.name} ==="]
|
||||
else:
|
||||
# Global stats
|
||||
stats = self.project_manager.get_global_stats()
|
||||
output = ["=== Global Vector Store Stats ==="]
|
||||
|
||||
output.append(f"Total documents: {stats.get('total_documents', 0)}")
|
||||
output.append(f"Total size: {stats.get('total_size_mb', 0):.1f} MB")
|
||||
|
||||
if stats.get("projects"):
|
||||
output.append(f"\nProjects indexed: {len(stats['projects'])}")
|
||||
for proj in stats["projects"]:
|
||||
output.append(f" - {proj['name']}: {proj['documents']} docs, {proj['size_mb']:.1f} MB")
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Failed to get stats: {str(e)}")
|
||||
return f"Error getting stats: {str(e)}"
|
||||
|
||||
async def _handle_clear(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Clear vector store."""
|
||||
path = params.get("path")
|
||||
|
||||
if not path:
|
||||
return "Error: path is required for clear action"
|
||||
|
||||
# Validate path
|
||||
allowed, error_msg = await self.check_path_allowed(path, tool_ctx)
|
||||
if not allowed:
|
||||
return error_msg
|
||||
|
||||
try:
|
||||
project = self.project_manager.get_project_for_path(path)
|
||||
if not project:
|
||||
return f"No indexed project found for path: {path}"
|
||||
|
||||
# Get stats before clearing
|
||||
stats = project.get_stats()
|
||||
doc_count = stats.get("total_documents", 0)
|
||||
|
||||
# Clear
|
||||
project.clear()
|
||||
|
||||
return f"Cleared {doc_count} documents from vector store for {project.name}"
|
||||
|
||||
except Exception as e:
|
||||
await tool_ctx.error(f"Failed to clear: {str(e)}")
|
||||
return f"Error clearing vector store: {str(e)}"
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server."""
|
||||
pass
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Vector indexing tool for adding documents to vector database."""
|
||||
|
||||
from typing import Dict, Unpack, Optional, TypedDict, final
|
||||
from pathlib import Path
|
||||
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import PermissionManager
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
from .project_manager import ProjectVectorManager
|
||||
|
||||
|
||||
class VectorIndexParams(TypedDict, total=False):
|
||||
"""Parameters for vector indexing operations."""
|
||||
|
||||
file_path: str
|
||||
content: Optional[str]
|
||||
chunk_size: Optional[int]
|
||||
chunk_overlap: Optional[int]
|
||||
metadata: Optional[Dict[str, str]]
|
||||
|
||||
|
||||
@final
|
||||
class VectorIndexTool(BaseTool):
|
||||
"""Tool for indexing documents in the vector database."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
permission_manager: PermissionManager,
|
||||
project_manager: ProjectVectorManager,
|
||||
):
|
||||
"""Initialize the vector index tool.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
project_manager: Project-aware vector store manager
|
||||
"""
|
||||
self.permission_manager = permission_manager
|
||||
self.project_manager = project_manager
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "vector_index"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Index documents in project-aware vector databases for semantic search.
|
||||
|
||||
Can index individual text content or entire files. Files are automatically assigned
|
||||
to the appropriate project database based on LLM.md detection or stored in the global
|
||||
database. Files are chunked for optimal search performance.
|
||||
|
||||
Projects are detected by finding LLM.md files, with databases stored in .hanzo/db
|
||||
directories alongside them. Use this to build searchable knowledge bases per project."""
|
||||
|
||||
@auto_timeout("vector_index")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[VectorIndexParams],
|
||||
) -> str:
|
||||
"""Index content or files in the vector database.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Indexing result message
|
||||
"""
|
||||
file_path = params.get("file_path")
|
||||
content = params.get("content")
|
||||
chunk_size = params.get("chunk_size", 1000)
|
||||
chunk_overlap = params.get("chunk_overlap", 200)
|
||||
metadata = params.get("metadata", {})
|
||||
|
||||
if not file_path and not content:
|
||||
return "Error: Either file_path or content must be provided"
|
||||
|
||||
try:
|
||||
if file_path:
|
||||
# Validate file access
|
||||
# Use permission manager's existing validation
|
||||
if not self.permission_manager.is_path_allowed(file_path):
|
||||
return f"Error: Access denied to path {file_path}"
|
||||
|
||||
if not Path(file_path).exists():
|
||||
return f"Error: File does not exist: {file_path}"
|
||||
|
||||
# Index file using project-aware manager
|
||||
doc_ids, project_info = self.project_manager.add_file_to_appropriate_store(
|
||||
file_path=file_path,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
file_name = Path(file_path).name
|
||||
if project_info:
|
||||
return (
|
||||
f"Successfully indexed {file_name} with {len(doc_ids)} chunks in project '{project_info.name}'"
|
||||
)
|
||||
else:
|
||||
return f"Successfully indexed {file_name} with {len(doc_ids)} chunks in global database"
|
||||
|
||||
else:
|
||||
# Index content directly in global store (no project context)
|
||||
global_store = self.project_manager._get_global_store()
|
||||
doc_id = global_store.add_document(
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
return f"Successfully indexed content as document {doc_id} in global database"
|
||||
|
||||
except Exception as e:
|
||||
return f"Error indexing content: {str(e)}"
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Vector search tool for semantic document retrieval."""
|
||||
|
||||
import json
|
||||
from typing import List, Unpack, Optional, TypedDict, final
|
||||
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool
|
||||
from hanzo_tools.core import PermissionManager
|
||||
from hanzo_tools.core import auto_timeout
|
||||
|
||||
from .project_manager import ProjectVectorManager
|
||||
|
||||
|
||||
class VectorSearchParams(TypedDict, total=False):
|
||||
"""Parameters for vector search operations."""
|
||||
|
||||
query: str
|
||||
limit: Optional[int]
|
||||
score_threshold: Optional[float]
|
||||
include_content: Optional[bool]
|
||||
file_filter: Optional[str]
|
||||
project_filter: Optional[List[str]]
|
||||
search_scope: Optional[str] # "all", "global", "current", or specific project name
|
||||
|
||||
|
||||
@final
|
||||
class VectorSearchTool(BaseTool):
|
||||
"""Tool for semantic search in the vector database."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
permission_manager: PermissionManager,
|
||||
project_manager: ProjectVectorManager,
|
||||
):
|
||||
"""Initialize the vector search tool.
|
||||
|
||||
Args:
|
||||
permission_manager: Permission manager for access control
|
||||
project_manager: Project-aware vector store manager
|
||||
"""
|
||||
self.permission_manager = permission_manager
|
||||
self.project_manager = project_manager
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""Get the tool name."""
|
||||
return "vector_search"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Pure semantic/vector search using Infinity embedded database.
|
||||
|
||||
Searches indexed documents using vector embeddings to find semantically similar content.
|
||||
This is NOT keyword search - it finds documents based on meaning and context similarity.
|
||||
|
||||
Features:
|
||||
- Searches across project-specific vector databases
|
||||
- Returns similarity scores (0-1, higher is better)
|
||||
- Supports filtering by project or file
|
||||
- Automatically detects projects via LLM.md files
|
||||
|
||||
Use 'grep' for exact text/pattern matching, 'vector_search' for semantic similarity."""
|
||||
|
||||
@auto_timeout("vector_search")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[VectorSearchParams],
|
||||
) -> str:
|
||||
"""Search for similar documents in the vector database.
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
|
||||
Returns:
|
||||
Search results formatted as text
|
||||
"""
|
||||
query = params.get("query")
|
||||
if not query:
|
||||
return "Error: query parameter is required"
|
||||
|
||||
limit = params.get("limit", 10)
|
||||
score_threshold = params.get("score_threshold", 0.0)
|
||||
include_content = params.get("include_content", True)
|
||||
file_filter = params.get("file_filter")
|
||||
project_filter = params.get("project_filter")
|
||||
search_scope = params.get("search_scope", "all")
|
||||
|
||||
try:
|
||||
# Determine search strategy based on scope
|
||||
if search_scope == "all":
|
||||
# Search across all projects
|
||||
project_results = await self.project_manager.search_all_projects(
|
||||
query=query,
|
||||
limit_per_project=limit,
|
||||
score_threshold=score_threshold,
|
||||
include_global=True,
|
||||
project_filter=project_filter,
|
||||
)
|
||||
|
||||
# Combine and sort all results
|
||||
all_results = []
|
||||
for project_name, results in project_results.items():
|
||||
for result in results:
|
||||
# Add project info to metadata
|
||||
result.document.metadata = result.document.metadata or {}
|
||||
result.document.metadata["search_project"] = project_name
|
||||
all_results.append(result)
|
||||
|
||||
# Sort by score and limit
|
||||
all_results.sort(key=lambda x: x.score, reverse=True)
|
||||
results = all_results[:limit]
|
||||
|
||||
elif search_scope == "global":
|
||||
# Search only global store
|
||||
global_store = self.project_manager._get_global_store()
|
||||
results = global_store.search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
for result in results:
|
||||
result.document.metadata = result.document.metadata or {}
|
||||
result.document.metadata["search_project"] = "global"
|
||||
|
||||
else:
|
||||
# Search specific project or current context
|
||||
if search_scope != "current":
|
||||
# Search specific project by name
|
||||
project_info = None
|
||||
for _proj_key, proj_info in self.project_manager.projects.items():
|
||||
if proj_info.name == search_scope:
|
||||
project_info = proj_info
|
||||
break
|
||||
|
||||
if project_info:
|
||||
vector_store = self.project_manager.get_vector_store(project_info)
|
||||
results = vector_store.search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
for result in results:
|
||||
result.document.metadata = result.document.metadata or {}
|
||||
result.document.metadata["search_project"] = project_info.name
|
||||
else:
|
||||
return f"Project '{search_scope}' not found"
|
||||
else:
|
||||
# For "current", try to detect from working directory
|
||||
import os
|
||||
|
||||
current_dir = os.getcwd()
|
||||
project_info = self.project_manager.get_project_for_path(current_dir)
|
||||
|
||||
if project_info:
|
||||
vector_store = self.project_manager.get_vector_store(project_info)
|
||||
results = vector_store.search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
for result in results:
|
||||
result.document.metadata = result.document.metadata or {}
|
||||
result.document.metadata["search_project"] = project_info.name
|
||||
else:
|
||||
# Fall back to global store
|
||||
global_store = self.project_manager._get_global_store()
|
||||
results = global_store.search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
score_threshold=score_threshold,
|
||||
)
|
||||
for result in results:
|
||||
result.document.metadata = result.document.metadata or {}
|
||||
result.document.metadata["search_project"] = "global"
|
||||
|
||||
if not results:
|
||||
return f"No results found for query: '{query}'"
|
||||
|
||||
# Filter by file if requested
|
||||
if file_filter:
|
||||
results = [r for r in results if file_filter in (r.document.file_path or "")]
|
||||
|
||||
# Format results
|
||||
output_lines = [f"Found {len(results)} results for query: '{query}'\n"]
|
||||
|
||||
for i, result in enumerate(results, 1):
|
||||
doc = result.document
|
||||
score_percent = result.score * 100
|
||||
|
||||
# Header with score and metadata
|
||||
project_name = doc.metadata.get("search_project", "unknown")
|
||||
header = f"Result {i} (Score: {score_percent:.1f}%) - Project: {project_name}"
|
||||
if doc.file_path:
|
||||
header += f" - {doc.file_path}"
|
||||
if doc.chunk_index is not None:
|
||||
header += f" [Chunk {doc.chunk_index}]"
|
||||
|
||||
output_lines.append(header)
|
||||
output_lines.append("-" * len(header))
|
||||
|
||||
# Add metadata if available
|
||||
if doc.metadata:
|
||||
relevant_metadata = {
|
||||
k: v
|
||||
for k, v in doc.metadata.items()
|
||||
if k not in ["chunk_number", "total_chunks", "search_project"]
|
||||
}
|
||||
if relevant_metadata:
|
||||
output_lines.append(f"Metadata: {json.dumps(relevant_metadata, indent=2)}")
|
||||
|
||||
# Add content if requested
|
||||
if include_content:
|
||||
content = doc.content
|
||||
if len(content) > 500:
|
||||
content = content[:500] + "..."
|
||||
output_lines.append(f"Content:\n{content}")
|
||||
|
||||
output_lines.append("") # Empty line between results
|
||||
|
||||
return "\n".join(output_lines)
|
||||
|
||||
except Exception as e:
|
||||
return f"Error searching vector database: {str(e)}"
|
||||
|
||||
def register(self, mcp_server) -> None:
|
||||
"""Register this tool with the MCP server.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
"""
|
||||
# This is a placeholder - the actual registration would happen
|
||||
# through the MCP server's tool registration mechanism
|
||||
pass
|
||||
@@ -5,15 +5,20 @@ build-backend = "setuptools.build_meta"
|
||||
[project]
|
||||
name = "hanzo-tools-vector"
|
||||
version = "0.1.0"
|
||||
description = "Vector search and embedding tools"
|
||||
description = "Vector/embedding tools for Hanzo AI - indexing, search, RAG"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo Industries Inc", email = "[email protected]" }]
|
||||
dependencies = ["mcp>=1.25.0", "fastmcp>=2.14.1", "pydantic>=2.12.5"]
|
||||
keywords = ["hanzo", "tools", "vector", "embeddings", "rag", "mcp", "ai"]
|
||||
dependencies = [
|
||||
"hanzo-tools-core>=0.1.0",
|
||||
"mcp>=1.25.0",
|
||||
"fastmcp>=2.14.1",
|
||||
"pydantic>=2.12.5",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
chroma = ["chromadb>=0.5.0"]
|
||||
openai = ["openai>=1.82.0"]
|
||||
full = ["sentence-transformers>=2.0.0", "faiss-cpu>=1.7.0"]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
vector = "hanzo_tools.vector:TOOLS"
|
||||
|
||||
@@ -37,12 +37,17 @@ lsp = ["hanzo-tools-lsp>=0.1.0"]
|
||||
refactor = ["hanzo-tools-refactor>=0.1.0"]
|
||||
vector = ["hanzo-tools-vector>=0.1.0"]
|
||||
todo = ["hanzo-tools-todo>=0.1.0"]
|
||||
config = ["hanzo-tools-config>=0.1.0"]
|
||||
mcp_tools = ["hanzo-tools-mcp>=0.1.0"]
|
||||
reasoning = ["hanzo-tools-reasoning>=0.1.0"]
|
||||
|
||||
# Bundles
|
||||
core = [
|
||||
"hanzo-tools-filesystem>=0.1.0",
|
||||
"hanzo-tools-shell>=0.1.0",
|
||||
"hanzo-tools-todo>=0.1.0",
|
||||
"hanzo-tools-reasoning>=0.1.0",
|
||||
"hanzo-tools-config>=0.1.0",
|
||||
]
|
||||
dev = [
|
||||
"hanzo-tools[core]",
|
||||
@@ -69,6 +74,9 @@ all = [
|
||||
"hanzo-tools-refactor>=0.1.0",
|
||||
"hanzo-tools-vector>=0.1.0",
|
||||
"hanzo-tools-todo>=0.1.0",
|
||||
"hanzo-tools-reasoning>=0.1.0",
|
||||
"hanzo-tools-config>=0.1.0",
|
||||
"hanzo-tools-mcp>=0.1.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
|
||||
Reference in New Issue
Block a user