Skip to content

Commit a249f1c

Browse files
committed
feat(plugins): add dispatch_tool() to PluginContext (NousResearch#10763)
Expands the plugin interface so slash command handlers can dispatch tool calls through the registry with parent agent context wired up automatically. This is the public API for plugins that need to orchestrate tools like delegate_task — they call ctx.dispatch_tool() instead of reaching into framework internals. The parent agent is resolved lazily from _cli_ref when available (CLI mode) and omitted in gateway mode (tools degrade gracefully). Enables the hermes-deliver-plugin pattern where /deliver and /fanout slash commands spawn subagents via delegate_task without touching the agent conversation loop. 7 new tests covering: registry delegation, parent_agent injection from cli_ref, gateway mode (no cli_ref), uninitialized agent, explicit parent_agent override, kwargs forwarding, return value passthrough.
1 parent 269f72f commit a249f1c

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

hermes_cli/plugins.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,37 @@ def register_command(
259259
}
260260
logger.debug("Plugin %s registered command: /%s", self.manifest.name, clean)
261261

262+
# -- tool dispatch -------------------------------------------------------
263+
264+
def dispatch_tool(self, tool_name: str, args: dict, **kwargs) -> str:
265+
"""Dispatch a tool call through the registry, with parent agent context.
266+
267+
This is the public interface for plugin slash commands that need to call
268+
tools like ``delegate_task`` without reaching into framework internals.
269+
The parent agent (if available) is resolved automatically — plugins never
270+
need to access the agent directly.
271+
272+
Args:
273+
tool_name: Registry name of the tool (e.g. ``"delegate_task"``).
274+
args: Tool arguments dict (same as what the model would pass).
275+
**kwargs: Extra keyword args forwarded to the registry dispatch.
276+
277+
Returns:
278+
JSON string from the tool handler (same format as model tool calls).
279+
"""
280+
from tools.registry import registry
281+
282+
# Wire up parent agent context when available (CLI mode).
283+
# In gateway mode _cli_ref is None — tools degrade gracefully
284+
# (workspace hints fall back to TERMINAL_CWD, no spinner).
285+
if "parent_agent" not in kwargs:
286+
cli = self._manager._cli_ref
287+
agent = getattr(cli, "agent", None) if cli else None
288+
if agent is not None:
289+
kwargs["parent_agent"] = agent
290+
291+
return registry.dispatch(tool_name, args, **kwargs)
292+
262293
# -- context engine registration -----------------------------------------
263294

264295
def register_context_engine(self, engine) -> None:

tests/hermes_cli/test_plugins.py

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,3 +764,135 @@ def test_multiple_plugins_register_different_commands(self):
764764
assert "cmd-b" in mgr._plugin_commands
765765
assert mgr._plugin_commands["cmd-a"]["plugin"] == "plugin-a"
766766
assert mgr._plugin_commands["cmd-b"]["plugin"] == "plugin-b"
767+
768+
769+
# ── TestPluginDispatchTool ────────────────────────────────────────────────
770+
771+
772+
class TestPluginDispatchTool:
773+
"""Tests for PluginContext.dispatch_tool() — tool dispatch with agent context."""
774+
775+
def test_dispatch_tool_calls_registry(self):
776+
"""dispatch_tool() delegates to registry.dispatch()."""
777+
mgr = PluginManager()
778+
manifest = PluginManifest(name="test-plugin", source="user")
779+
ctx = PluginContext(manifest, mgr)
780+
781+
mock_registry = MagicMock()
782+
mock_registry.dispatch.return_value = '{"result": "ok"}'
783+
784+
with patch("hermes_cli.plugins.PluginContext.dispatch_tool.__module__", "hermes_cli.plugins"):
785+
with patch.dict("sys.modules", {}):
786+
with patch("tools.registry.registry", mock_registry):
787+
result = ctx.dispatch_tool("web_search", {"query": "test"})
788+
789+
assert result == '{"result": "ok"}'
790+
791+
def test_dispatch_tool_injects_parent_agent_from_cli_ref(self):
792+
"""When _cli_ref has an agent, it's passed as parent_agent."""
793+
mgr = PluginManager()
794+
manifest = PluginManifest(name="test-plugin", source="user")
795+
ctx = PluginContext(manifest, mgr)
796+
797+
mock_agent = MagicMock()
798+
mock_cli = MagicMock()
799+
mock_cli.agent = mock_agent
800+
mgr._cli_ref = mock_cli
801+
802+
mock_registry = MagicMock()
803+
mock_registry.dispatch.return_value = '{"ok": true}'
804+
805+
with patch("tools.registry.registry", mock_registry):
806+
ctx.dispatch_tool("delegate_task", {"goal": "test"})
807+
808+
mock_registry.dispatch.assert_called_once()
809+
call_kwargs = mock_registry.dispatch.call_args
810+
assert call_kwargs[1].get("parent_agent") is mock_agent
811+
812+
def test_dispatch_tool_no_parent_agent_when_no_cli_ref(self):
813+
"""When _cli_ref is None (gateway mode), no parent_agent is injected."""
814+
mgr = PluginManager()
815+
manifest = PluginManifest(name="test-plugin", source="user")
816+
ctx = PluginContext(manifest, mgr)
817+
mgr._cli_ref = None
818+
819+
mock_registry = MagicMock()
820+
mock_registry.dispatch.return_value = '{"ok": true}'
821+
822+
with patch("tools.registry.registry", mock_registry):
823+
ctx.dispatch_tool("delegate_task", {"goal": "test"})
824+
825+
call_kwargs = mock_registry.dispatch.call_args
826+
assert "parent_agent" not in call_kwargs[1]
827+
828+
def test_dispatch_tool_no_parent_agent_when_agent_is_none(self):
829+
"""When cli_ref exists but agent is None (not yet initialized), skip parent_agent."""
830+
mgr = PluginManager()
831+
manifest = PluginManifest(name="test-plugin", source="user")
832+
ctx = PluginContext(manifest, mgr)
833+
834+
mock_cli = MagicMock()
835+
mock_cli.agent = None
836+
mgr._cli_ref = mock_cli
837+
838+
mock_registry = MagicMock()
839+
mock_registry.dispatch.return_value = '{"ok": true}'
840+
841+
with patch("tools.registry.registry", mock_registry):
842+
ctx.dispatch_tool("delegate_task", {"goal": "test"})
843+
844+
call_kwargs = mock_registry.dispatch.call_args
845+
assert "parent_agent" not in call_kwargs[1]
846+
847+
def test_dispatch_tool_respects_explicit_parent_agent(self):
848+
"""Explicit parent_agent kwarg is not overwritten by _cli_ref.agent."""
849+
mgr = PluginManager()
850+
manifest = PluginManifest(name="test-plugin", source="user")
851+
ctx = PluginContext(manifest, mgr)
852+
853+
cli_agent = MagicMock(name="cli_agent")
854+
mock_cli = MagicMock()
855+
mock_cli.agent = cli_agent
856+
mgr._cli_ref = mock_cli
857+
858+
explicit_agent = MagicMock(name="explicit_agent")
859+
860+
mock_registry = MagicMock()
861+
mock_registry.dispatch.return_value = '{"ok": true}'
862+
863+
with patch("tools.registry.registry", mock_registry):
864+
ctx.dispatch_tool("delegate_task", {"goal": "test"}, parent_agent=explicit_agent)
865+
866+
call_kwargs = mock_registry.dispatch.call_args
867+
assert call_kwargs[1]["parent_agent"] is explicit_agent
868+
869+
def test_dispatch_tool_forwards_extra_kwargs(self):
870+
"""Extra kwargs are forwarded to registry.dispatch()."""
871+
mgr = PluginManager()
872+
manifest = PluginManifest(name="test-plugin", source="user")
873+
ctx = PluginContext(manifest, mgr)
874+
mgr._cli_ref = None
875+
876+
mock_registry = MagicMock()
877+
mock_registry.dispatch.return_value = '{"ok": true}'
878+
879+
with patch("tools.registry.registry", mock_registry):
880+
ctx.dispatch_tool("some_tool", {"x": 1}, task_id="test-123")
881+
882+
call_kwargs = mock_registry.dispatch.call_args
883+
assert call_kwargs[1]["task_id"] == "test-123"
884+
885+
def test_dispatch_tool_returns_json_string(self):
886+
"""dispatch_tool() returns the raw JSON string from the registry."""
887+
mgr = PluginManager()
888+
manifest = PluginManifest(name="test-plugin", source="user")
889+
ctx = PluginContext(manifest, mgr)
890+
mgr._cli_ref = None
891+
892+
mock_registry = MagicMock()
893+
mock_registry.dispatch.return_value = '{"error": "Unknown tool: fake"}'
894+
895+
with patch("tools.registry.registry", mock_registry):
896+
result = ctx.dispatch_tool("fake", {})
897+
898+
assert '"error"' in result

0 commit comments

Comments
 (0)