mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-28 06:51:16 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user