diff --git a/CHANGELOG.md b/CHANGELOG.md index 733a50e..e81dfe3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `/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. diff --git a/src/openharness/plugins/loader.py b/src/openharness/plugins/loader.py index 0fe370e..358c1db 100644 --- a/src/openharness/plugins/loader.py +++ b/src/openharness/plugins/loader.py @@ -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 diff --git a/src/openharness/plugins/schemas.py b/src/openharness/plugins/schemas.py index 84d79f5..2517b73 100644 --- a/src/openharness/plugins/schemas.py +++ b/src/openharness/plugins/schemas.py @@ -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. diff --git a/src/openharness/plugins/types.py b/src/openharness/plugins/types.py index 2d3f795..1a3ba9a 100644 --- a/src/openharness/plugins/types.py +++ b/src/openharness/plugins/types.py @@ -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) diff --git a/src/openharness/ui/runtime.py b/src/openharness/ui/runtime.py index ab943b2..db1a638 100644 --- a/src/openharness/ui/runtime.py +++ b/src/openharness/ui/runtime.py @@ -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(