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:
Yan, Zhi Yuan
2026-04-23 22:34:58 +08:00
committed by tjb-tech
parent e7eab63852
commit 6c83a4c978
5 changed files with 55 additions and 0 deletions
+1
View File
@@ -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.
+47
View File
@@ -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
+1
View File
@@ -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.
+1
View File
@@ -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)
+5
View File
@@ -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(