feat(plugins): add dispatch_tool() to PluginContext (#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.
This commit is contained in:
Teknium
2026-04-15 22:23:01 -07:00
committed by GitHub
parent 9b7bd4ca61
commit 36b54afbc4
2 changed files with 163 additions and 0 deletions

View File

@@ -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:

View File

@@ -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