diff --git a/src/openharness/plugins/loader.py b/src/openharness/plugins/loader.py index 358c1db..c6b1fdf 100644 --- a/src/openharness/plugins/loader.py +++ b/src/openharness/plugins/loader.py @@ -138,7 +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) + tools = _load_plugin_tools(path, manifest) if enabled else [] hooks = _load_plugin_hooks(path / manifest.hooks_file) hooks_dir_file = path / "hooks" / "hooks.json" if not hooks and hooks_dir_file.exists(): diff --git a/src/openharness/plugins/types.py b/src/openharness/plugins/types.py index 1a3ba9a..7e7c7f1 100644 --- a/src/openharness/plugins/types.py +++ b/src/openharness/plugins/types.py @@ -4,12 +4,16 @@ from __future__ import annotations from dataclasses import dataclass, field from pathlib import Path +from typing import TYPE_CHECKING from openharness.coordinator.agent_definitions import AgentDefinition from openharness.mcp.types import McpServerConfig from openharness.plugins.schemas import PluginManifest from openharness.skills.types import SkillDefinition +if TYPE_CHECKING: + from openharness.tools.base import BaseTool + @dataclass(frozen=True) class PluginCommandDefinition: @@ -42,7 +46,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) + tools: list[BaseTool] = field(default_factory=list) hooks: dict[str, list] = field(default_factory=dict) mcp_servers: dict[str, McpServerConfig] = field(default_factory=dict) diff --git a/tests/test_plugins/test_loader.py b/tests/test_plugins/test_loader.py index b503713..867eb83 100644 --- a/tests/test_plugins/test_loader.py +++ b/tests/test_plugins/test_loader.py @@ -71,6 +71,38 @@ def _write_plugin(root: Path) -> None: ) +def _write_tool_plugin(root: Path, *, enabled_by_default: bool = True) -> Path: + plugin_dir = root / "tool-plugin" + tools_dir = plugin_dir / "tools" + tools_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text( + json.dumps( + { + "name": "tool-plugin", + "version": "1.0.0", + "description": "Example tool plugin", + "enabled_by_default": enabled_by_default, + } + ), + encoding="utf-8", + ) + (tools_dir / "echo_tool.py").write_text( + "from pydantic import BaseModel\n" + "from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult\n\n" + "class EchoArgs(BaseModel):\n" + " text: str = 'hello'\n\n" + "class EchoTool(BaseTool):\n" + " name = 'plugin_echo'\n" + " description = 'Echo from plugin tool'\n" + " input_model = EchoArgs\n\n" + " async def execute(self, arguments: EchoArgs, context: ToolExecutionContext) -> ToolResult:\n" + " del context\n" + " return ToolResult(output=arguments.text)\n", + encoding="utf-8", + ) + return plugin_dir + + def test_load_plugins_from_project_dir(tmp_path: Path, monkeypatch): monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) project = tmp_path / "repo" @@ -148,3 +180,42 @@ def test_user_plugins_still_load_when_project_plugins_are_disabled(tmp_path: Pat assert len(plugins) == 1 assert plugins[0].manifest.name == "example" + + +def test_enabled_plugin_tools_are_loaded(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + project = tmp_path / "repo" + plugins_root = project / ".openharness" / "plugins" + plugins_root.mkdir(parents=True) + _write_tool_plugin(plugins_root, enabled_by_default=True) + + plugins = load_plugins(Settings(allow_project_plugins=True), project) + + assert len(plugins) == 1 + plugin = plugins[0] + assert plugin.enabled is True + assert [tool.name for tool in plugin.tools] == ["plugin_echo"] + + +def test_disabled_plugin_tools_are_not_imported(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + project = tmp_path / "repo" + plugins_root = project / ".openharness" / "plugins" + plugins_root.mkdir(parents=True) + plugin_dir = _write_tool_plugin(plugins_root, enabled_by_default=False) + marker = tmp_path / "tool-imported.txt" + tool_file = plugin_dir / "tools" / "echo_tool.py" + tool_file.write_text( + f"from pathlib import Path\n" + f"Path({str(marker)!r}).write_text('loaded', encoding='utf-8')\n" + + tool_file.read_text(encoding="utf-8"), + encoding="utf-8", + ) + + plugins = load_plugins(Settings(allow_project_plugins=True), project) + + assert len(plugins) == 1 + plugin = plugins[0] + assert plugin.enabled is False + assert plugin.tools == [] + assert not marker.exists() diff --git a/tests/test_ui/test_runtime_plugin_tools.py b/tests/test_ui/test_runtime_plugin_tools.py new file mode 100644 index 0000000..88d0553 --- /dev/null +++ b/tests/test_ui/test_runtime_plugin_tools.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from openharness.ui.runtime import build_runtime, close_runtime + + +class _StaticApiClient: + async def stream_message(self, request): + del request + if False: + yield None + + +def _write_tool_plugin(plugins_root: Path) -> None: + plugin_dir = plugins_root / "tool-plugin" + tools_dir = plugin_dir / "tools" + tools_dir.mkdir(parents=True) + (plugin_dir / "plugin.json").write_text( + json.dumps( + { + "name": "tool-plugin", + "version": "1.0.0", + "description": "Runtime tool plugin", + "enabled_by_default": True, + } + ), + encoding="utf-8", + ) + (tools_dir / "echo_tool.py").write_text( + "from pydantic import BaseModel\n" + "from openharness.tools.base import BaseTool, ToolExecutionContext, ToolResult\n\n" + "class EchoArgs(BaseModel):\n" + " text: str = 'hello'\n\n" + "class EchoTool(BaseTool):\n" + " name = 'plugin_echo'\n" + " description = 'Echo from plugin tool'\n" + " input_model = EchoArgs\n\n" + " async def execute(self, arguments: EchoArgs, context: ToolExecutionContext) -> ToolResult:\n" + " del context\n" + " return ToolResult(output=arguments.text)\n", + encoding="utf-8", + ) + + +@pytest.mark.asyncio +async def test_build_runtime_registers_enabled_plugin_tools(tmp_path: Path, monkeypatch): + monkeypatch.setenv("OPENHARNESS_CONFIG_DIR", str(tmp_path / "config")) + project = tmp_path / "repo" + plugins_root = project / ".openharness" / "plugins" + plugins_root.mkdir(parents=True) + _write_tool_plugin(plugins_root) + + from openharness.config.settings import Settings + + monkeypatch.setattr("openharness.ui.runtime.load_settings", lambda: Settings(allow_project_plugins=True)) + + bundle = await build_runtime(cwd=str(project), api_client=_StaticApiClient()) + try: + tool = bundle.tool_registry.get("plugin_echo") + assert tool is not None + assert tool.description == "Echo from plugin tool" + finally: + await close_runtime(bundle)