feat: discover and register BaseTool subclasses from plugin tools/ dirs
Plugins can now contribute Python tools by placing BaseTool subclasses in their tools/ directory. The loader scans for .py files, imports them via importlib, instantiates any BaseTool subclasses found, and registers them in the tool registry at runtime. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@ The format is based on Keep a Changelog, and this project currently tracks chang
|
||||
|
||||
### Added
|
||||
|
||||
- Plugin tool discovery: plugins can now provide `BaseTool` subclasses in a `<plugin>/tools/` directory and they are auto-discovered, instantiated, and registered in the tool registry at runtime. Add `tools_dir` to `plugin.json` (defaults to `"tools"`).
|
||||
- `oh --dry-run` safe preview mode for inspecting resolved runtime settings, auth state, prompt assembly, commands, skills, tools, and configured MCP servers without executing the model or tools.
|
||||
- Built-in `minimax` provider profile so `oh setup` offers MiniMax as a first-class provider choice, with `MINIMAX_API_KEY` auth source, `MiniMax-M2.7` as the default model, and `MiniMax-M2.7-highspeed` in the model picker.
|
||||
- Docker as an alternative sandbox backend (`sandbox.backend = "docker"`) for stronger execution isolation with configurable resource limits, network isolation, and automatic image management.
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
@@ -135,6 +138,7 @@ def load_plugin(path: Path, enabled_plugins: dict[str, bool]) -> LoadedPlugin |
|
||||
skills = _load_plugin_skills(path / manifest.skills_dir)
|
||||
commands = _load_plugin_commands(path, manifest)
|
||||
agents = _load_plugin_agents(path, manifest)
|
||||
tools = _load_plugin_tools(path, manifest)
|
||||
hooks = _load_plugin_hooks(path / manifest.hooks_file)
|
||||
hooks_dir_file = path / "hooks" / "hooks.json"
|
||||
if not hooks and hooks_dir_file.exists():
|
||||
@@ -154,6 +158,7 @@ def load_plugin(path: Path, enabled_plugins: dict[str, bool]) -> LoadedPlugin |
|
||||
agents=agents,
|
||||
hooks=hooks,
|
||||
mcp_servers=mcp,
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
|
||||
@@ -661,3 +666,45 @@ def _load_plugin_mcp(path: Path) -> dict[str, object]:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
parsed = McpJsonConfig.model_validate(raw)
|
||||
return parsed.mcpServers
|
||||
|
||||
|
||||
def _load_plugin_tools(path: Path, manifest: PluginManifest) -> list:
|
||||
"""Discover and instantiate BaseTool subclasses from a plugin's tools/ directory."""
|
||||
from openharness.tools.base import BaseTool
|
||||
|
||||
tools_dir = path / manifest.tools_dir
|
||||
if not tools_dir.is_dir():
|
||||
return []
|
||||
|
||||
tools: list[BaseTool] = []
|
||||
for py_file in sorted(tools_dir.glob("*.py")):
|
||||
if py_file.name.startswith("_"):
|
||||
continue
|
||||
module_name = f"_plugin_tools_{manifest.name}_{py_file.stem}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, py_file)
|
||||
if spec is None or spec.loader is None:
|
||||
continue
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
except Exception:
|
||||
logger.debug("Failed to load plugin tool module %s", py_file, exc_info=True)
|
||||
continue
|
||||
|
||||
for attr_name in dir(module):
|
||||
attr = getattr(module, attr_name, None)
|
||||
if (
|
||||
isinstance(attr, type)
|
||||
and issubclass(attr, BaseTool)
|
||||
and attr is not BaseTool
|
||||
and hasattr(attr, "name")
|
||||
and hasattr(attr, "description")
|
||||
):
|
||||
try:
|
||||
instance = attr()
|
||||
tools.append(instance)
|
||||
logger.debug("Loaded plugin tool: %s from %s", instance.name, py_file)
|
||||
except Exception:
|
||||
logger.debug("Failed to instantiate tool %s from %s", attr_name, py_file, exc_info=True)
|
||||
return tools
|
||||
|
||||
@@ -13,6 +13,7 @@ class PluginManifest(BaseModel):
|
||||
description: str = ""
|
||||
enabled_by_default: bool = True
|
||||
skills_dir: str = "skills"
|
||||
tools_dir: str = "tools"
|
||||
hooks_file: str = "hooks.json"
|
||||
mcp_file: str = "mcp.json"
|
||||
# Extended fields: optional author, commands, agents, etc.
|
||||
|
||||
@@ -42,6 +42,7 @@ class LoadedPlugin:
|
||||
skills: list[SkillDefinition] = field(default_factory=list)
|
||||
commands: list[PluginCommandDefinition] = field(default_factory=list)
|
||||
agents: list[AgentDefinition] = field(default_factory=list)
|
||||
tools: list = field(default_factory=list)
|
||||
hooks: dict[str, list] = field(default_factory=dict)
|
||||
mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -212,6 +212,11 @@ async def build_runtime(
|
||||
mcp_manager = McpClientManager(load_mcp_server_configs(settings, plugins))
|
||||
await mcp_manager.connect_all()
|
||||
tool_registry = create_default_tool_registry(mcp_manager)
|
||||
# Register plugin-provided tools
|
||||
for plugin in plugins:
|
||||
if plugin.enabled and plugin.tools:
|
||||
for tool in plugin.tools:
|
||||
tool_registry.register(tool)
|
||||
provider = detect_provider(settings)
|
||||
bridge_manager = get_bridge_manager()
|
||||
app_state = AppStateStore(
|
||||
|
||||
Reference in New Issue
Block a user