mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
240 lines
7.5 KiB
Python
240 lines
7.5 KiB
Python
"""Tool configuration registry.
|
|
|
|
Discovers tools from hanzo-tools-* entry points and provides a unified
|
|
registry for enable/disable, CLI flag generation, and mode filtering.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from enum import Enum
|
|
from importlib.metadata import entry_points
|
|
from typing import Optional
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
TOOLS_ENTRY_POINT_GROUP = "hanzo.tools"
|
|
|
|
|
|
class ToolCategory(Enum):
|
|
filesystem = "filesystem"
|
|
shell = "shell"
|
|
code = "code"
|
|
search = "search"
|
|
vcs = "vcs"
|
|
reasoning = "reasoning"
|
|
memory = "memory"
|
|
todo = "todo"
|
|
config = "config"
|
|
agent = "agent"
|
|
browser = "browser"
|
|
computer = "computer"
|
|
lsp = "lsp"
|
|
refactor = "refactor"
|
|
net = "net"
|
|
llm = "llm"
|
|
api = "api"
|
|
auth = "auth"
|
|
ui = "ui"
|
|
other = "other"
|
|
|
|
|
|
@dataclass
|
|
class ToolConfigEntry:
|
|
"""Metadata for a single tool."""
|
|
|
|
name: str
|
|
description: str = ""
|
|
category: ToolCategory = ToolCategory.other
|
|
enabled: bool = True
|
|
package: Optional[str] = None
|
|
cli_flag: str = ""
|
|
dependencies: list[str] = field(default_factory=list)
|
|
|
|
|
|
# Category guessing from tool name prefix
|
|
_PREFIX_TO_CATEGORY: dict[str, ToolCategory] = {
|
|
"fs": ToolCategory.filesystem,
|
|
"read": ToolCategory.filesystem,
|
|
"write": ToolCategory.filesystem,
|
|
"edit": ToolCategory.filesystem,
|
|
"tree": ToolCategory.filesystem,
|
|
"exec": ToolCategory.shell,
|
|
"run": ToolCategory.shell,
|
|
"zsh": ToolCategory.shell,
|
|
"code": ToolCategory.code,
|
|
"grep": ToolCategory.search,
|
|
"find": ToolCategory.search,
|
|
"search": ToolCategory.search,
|
|
"git": ToolCategory.vcs,
|
|
"repo": ToolCategory.vcs,
|
|
"think": ToolCategory.reasoning,
|
|
"critic": ToolCategory.reasoning,
|
|
"memory": ToolCategory.memory,
|
|
"tasks": ToolCategory.todo,
|
|
"todo": ToolCategory.todo,
|
|
"config": ToolCategory.config,
|
|
"mode": ToolCategory.config,
|
|
"agent": ToolCategory.agent,
|
|
"zen": ToolCategory.agent,
|
|
"review": ToolCategory.agent,
|
|
"browser": ToolCategory.browser,
|
|
"computer": ToolCategory.computer,
|
|
"lsp": ToolCategory.lsp,
|
|
"refactor": ToolCategory.refactor,
|
|
"fetch": ToolCategory.net,
|
|
"curl": ToolCategory.net,
|
|
"wget": ToolCategory.net,
|
|
"llm": ToolCategory.llm,
|
|
"kai_decide": ToolCategory.llm,
|
|
"consensus": ToolCategory.llm,
|
|
"api": ToolCategory.api,
|
|
"hanzo": ToolCategory.api,
|
|
"auth": ToolCategory.auth,
|
|
"ui": ToolCategory.ui,
|
|
}
|
|
|
|
|
|
def _guess_category(name: str) -> ToolCategory:
|
|
for prefix, cat in _PREFIX_TO_CATEGORY.items():
|
|
if name == prefix or name.startswith(prefix + "_"):
|
|
return cat
|
|
return ToolCategory.other
|
|
|
|
|
|
class DynamicToolRegistry:
|
|
"""Discovers tools from entry points and exposes them as ToolConfigEntry."""
|
|
|
|
_entries: dict[str, ToolConfigEntry] = {}
|
|
_initialized: bool = False
|
|
|
|
@classmethod
|
|
def initialize(cls) -> None:
|
|
if cls._initialized:
|
|
return
|
|
cls._initialized = True
|
|
cls._entries = {}
|
|
try:
|
|
eps = entry_points(group=TOOLS_ENTRY_POINT_GROUP)
|
|
for ep in eps:
|
|
try:
|
|
tools_list = ep.load()
|
|
if not isinstance(tools_list, list):
|
|
continue
|
|
for tool_class in tools_list:
|
|
name = _extract_tool_name(tool_class)
|
|
desc = _extract_tool_description(tool_class)
|
|
cls._entries[name] = ToolConfigEntry(
|
|
name=name,
|
|
description=desc,
|
|
category=_guess_category(name),
|
|
enabled=True,
|
|
package=ep.name,
|
|
cli_flag=f"--{name.replace('_', '-')}",
|
|
)
|
|
except Exception as e:
|
|
logger.debug(f"Failed to load entry point '{ep.name}': {e}")
|
|
except Exception as e:
|
|
logger.debug(f"Failed to discover entry points: {e}")
|
|
|
|
@classmethod
|
|
def get(cls, name: str) -> Optional[ToolConfigEntry]:
|
|
cls.initialize()
|
|
return cls._entries.get(name)
|
|
|
|
@classmethod
|
|
def list_all(cls) -> dict[str, ToolConfigEntry]:
|
|
cls.initialize()
|
|
return dict(cls._entries)
|
|
|
|
@classmethod
|
|
def reset(cls) -> None:
|
|
"""Reset for testing."""
|
|
cls._entries = {}
|
|
cls._initialized = False
|
|
|
|
|
|
def _extract_tool_name(tool_class: type) -> str:
|
|
"""Get name from a tool class, handling @property."""
|
|
for klass in tool_class.__mro__:
|
|
if "name" in getattr(klass, "__dict__", {}):
|
|
attr = klass.__dict__["name"]
|
|
if isinstance(attr, property):
|
|
try:
|
|
inst = tool_class()
|
|
val = getattr(inst, "name", None)
|
|
if isinstance(val, str):
|
|
return val
|
|
except Exception:
|
|
pass
|
|
return tool_class.__name__.lower().replace("tool", "")
|
|
break
|
|
name = getattr(tool_class, "name", None)
|
|
if isinstance(name, str):
|
|
return name
|
|
return tool_class.__name__.lower().replace("tool", "")
|
|
|
|
|
|
def _extract_tool_description(tool_class: type) -> str:
|
|
"""Get a tool class's description as a STRING, handling @property.
|
|
|
|
When a tool defines ``description`` as a ``@property`` (evaluated per
|
|
instance), ``getattr(tool_class, "description")`` returns the property
|
|
DESCRIPTOR object, not a string — storing that made ``list_all`` crash later
|
|
with ``object of type 'property' has no len()``. Mirror ``_extract_tool_name``:
|
|
if it's a property, instantiate to read the real value; otherwise coerce to
|
|
a plain string. Always returns a ``str`` (empty when unavailable).
|
|
"""
|
|
for klass in tool_class.__mro__:
|
|
if "description" in getattr(klass, "__dict__", {}):
|
|
attr = klass.__dict__["description"]
|
|
if isinstance(attr, property):
|
|
try:
|
|
val = getattr(tool_class(), "description", None)
|
|
if isinstance(val, str):
|
|
return val
|
|
except Exception:
|
|
pass
|
|
return ""
|
|
break
|
|
desc = getattr(tool_class, "description", "")
|
|
return desc if isinstance(desc, str) else ""
|
|
|
|
|
|
# Module-level convenience -- DynamicToolRegistry doubles as the dict-like TOOL_REGISTRY.
|
|
# Code that does `TOOL_REGISTRY.items()` or `TOOL_REGISTRY.keys()` works via __getattr__.
|
|
class _RegistryProxy:
|
|
"""Thin proxy so ``TOOL_REGISTRY.items()`` and ``TOOL_REGISTRY.keys()`` work."""
|
|
|
|
def keys(self):
|
|
DynamicToolRegistry.initialize()
|
|
return DynamicToolRegistry._entries.keys()
|
|
|
|
def values(self):
|
|
DynamicToolRegistry.initialize()
|
|
return DynamicToolRegistry._entries.values()
|
|
|
|
def items(self):
|
|
DynamicToolRegistry.initialize()
|
|
return DynamicToolRegistry._entries.items()
|
|
|
|
def __len__(self):
|
|
DynamicToolRegistry.initialize()
|
|
return len(DynamicToolRegistry._entries)
|
|
|
|
def __iter__(self):
|
|
DynamicToolRegistry.initialize()
|
|
return iter(DynamicToolRegistry._entries)
|
|
|
|
def __getitem__(self, key: str):
|
|
DynamicToolRegistry.initialize()
|
|
return DynamicToolRegistry._entries[key]
|
|
|
|
def get(self, key: str, default=None):
|
|
DynamicToolRegistry.initialize()
|
|
return DynamicToolRegistry._entries.get(key, default)
|
|
|
|
|
|
TOOL_REGISTRY = _RegistryProxy()
|