diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 5e8ff8e4fd..2385a5c942 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -259,6 +259,37 @@ class PluginContext: } logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean) + # -- tool dispatch ------------------------------------------------------- + + def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str: + """Dispatch a tool call through the registry, with parent agent context. + + This is the public interface for plugin slash commands that need to call + tools like ``delegate_task`` without reaching into framework internals. + The parent agent (if available) is resolved automatically — plugins never + need to access the agent directly. + + Args: + tool_name: Registry name of the tool (e.g. ``"delegate_task"``). + args: Tool arguments dict (same as what the model would pass). + **kwargs: Extra keyword args forwarded to the registry dispatch. + + Returns: + JSON string from the tool handler (same format as model tool calls). + """ + from tools.registry import registry + + # Wire up parent agent context when available (CLI mode). + # In gateway mode _cli_ref is None — tools degrade gracefully + # (workspace hints fall back to TERMINAL_CWD, no spinner). + if "parent_agent" not in kwargs: + cli = self._manager._cli_ref + agent = getattr(cli, "agent", None) if cli else None + if agent is not None: + kwargs["parent_agent"] = agent + + return registry.dispatch(tool_name, args, **kwargs) + # -- context engine registration ----------------------------------------- def register_context_engine(self, engine) -> None: diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index acc63e9069..3e43acd7bb 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -764,3 +764,135 @@ class TestPluginCommands: assert "cmd-b" in mgr._plugin_commands assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a" assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b" + + +# ── TestPluginDispatchTool ──────────────────────────────────────────────── + + +class TestPluginDispatchTool: + """Tests for PluginContext.dispatch_tool() — tool dispatch with agent context.""" + + def test_dispatch_tool_calls_registry(self): + """dispatch_tool() delegates to registry.dispatch().""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"result": "ok"}' + + with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"): + with patch.dict("sys.modules", {}): + with patch("tools.registry.registry", mock_registry): + result = ctx.dispatch_tool("web_search", {"query": "test"}) + + assert result == '{"result": "ok"}' + + def test_dispatch_tool_injects_parent_agent_from_cli_ref(self): + """When _cli_ref has an agent, it's passed as parent_agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_agent = MagicMock() + mock_cli = MagicMock() + mock_cli.agent = mock_agent + mgr._cli_ref = mock_cli + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + mock_registry.dispatch.assert_called_once() + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1].get("parent_agent") is mock_agent + + def test_dispatch_tool_no_parent_agent_when_no_cli_ref(self): + """When _cli_ref is None (gateway mode), no parent_agent is injected.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + call_kwargs = mock_registry.dispatch.call_args + assert "parent_agent" not in call_kwargs[1] + + def test_dispatch_tool_no_parent_agent_when_agent_is_none(self): + """When cli_ref exists but agent is None (not yet initialized), skip parent_agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + mock_cli = MagicMock() + mock_cli.agent = None + mgr._cli_ref = mock_cli + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}) + + call_kwargs = mock_registry.dispatch.call_args + assert "parent_agent" not in call_kwargs[1] + + def test_dispatch_tool_respects_explicit_parent_agent(self): + """Explicit parent_agent kwarg is not overwritten by _cli_ref.agent.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + + cli_agent = MagicMock(name="cli_agent") + mock_cli = MagicMock() + mock_cli.agent = cli_agent + mgr._cli_ref = mock_cli + + explicit_agent = MagicMock(name="explicit_agent") + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent) + + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1]["parent_agent"] is explicit_agent + + def test_dispatch_tool_forwards_extra_kwargs(self): + """Extra kwargs are forwarded to registry.dispatch().""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"ok": true}' + + with patch("tools.registry.registry", mock_registry): + ctx.dispatch_tool("some_tool", {"x": 1}, task_id="test-123") + + call_kwargs = mock_registry.dispatch.call_args + assert call_kwargs[1]["task_id"] == "test-123" + + def test_dispatch_tool_returns_json_string(self): + """dispatch_tool() returns the raw JSON string from the registry.""" + mgr = PluginManager() + manifest = PluginManifest(name="test-plugin", source="user") + ctx = PluginContext(manifest, mgr) + mgr._cli_ref = None + + mock_registry = MagicMock() + mock_registry.dispatch.return_value = '{"error": "Unknown tool: fake"}' + + with patch("tools.registry.registry", mock_registry): + result = ctx.dispatch_tool("fake", {}) + + assert '"error"' in result