mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-18 04:41:56 +00:00
Plugins can now replace a built-in tool by passing override=True to ctx.register_tool(). Without it, the registry rejects any registration that would shadow an existing tool from a different toolset (unchanged default behavior). Unlocks the use case from #11049: drop-in replacement of browser/web backends without forking core. Composes with the existing pre_tool_call hook for runtime interception of any implementation. The override is audit-logged at INFO so it surfaces in agent.log.
This commit is contained in:
parent
9c304a7f56
commit
016c772e7f
4 changed files with 180 additions and 5 deletions
|
|
@ -325,8 +325,15 @@ class PluginContext:
|
|||
is_async: bool = False,
|
||||
description: str = "",
|
||||
emoji: str = "",
|
||||
override: bool = False,
|
||||
) -> None:
|
||||
"""Register a tool in the global registry **and** track it as plugin-provided."""
|
||||
"""Register a tool in the global registry **and** track it as plugin-provided.
|
||||
|
||||
Pass ``override=True`` to replace an existing built-in tool with the
|
||||
same name (e.g. swap the default ``browser_navigate`` for a custom
|
||||
CDP-backed implementation). Without it, attempting to register a name
|
||||
already claimed by a different toolset is rejected.
|
||||
"""
|
||||
from tools.registry import registry
|
||||
|
||||
registry.register(
|
||||
|
|
@ -339,9 +346,13 @@ class PluginContext:
|
|||
is_async=is_async,
|
||||
description=description,
|
||||
emoji=emoji,
|
||||
override=override,
|
||||
)
|
||||
self._manager._plugin_tool_names.add(name)
|
||||
logger.debug("Plugin %s registered tool: %s", self.manifest.name, name)
|
||||
logger.debug(
|
||||
"Plugin %s registered tool: %s%s",
|
||||
self.manifest.name, name, " (override)" if override else "",
|
||||
)
|
||||
|
||||
# -- message injection --------------------------------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -662,6 +662,129 @@ class TestPluginContext:
|
|||
from tools.registry import registry
|
||||
assert "plugin_echo" in registry._tools
|
||||
|
||||
def test_register_tool_rejects_shadow_without_override(self, tmp_path, monkeypatch, caplog):
|
||||
"""Without override=True, registering a tool name claimed by a different toolset is rejected."""
|
||||
from tools.registry import registry
|
||||
|
||||
# Seed an existing entry from a non-plugin toolset.
|
||||
registry.register(
|
||||
name="shadow_target",
|
||||
toolset="terminal",
|
||||
schema={"name": "shadow_target", "description": "Built-in", "parameters": {"type": "object", "properties": {}}},
|
||||
handler=lambda args, **kw: "built-in",
|
||||
)
|
||||
original_handler = registry._tools["shadow_target"].handler
|
||||
try:
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "shadow_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "shadow_plugin"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
'def register(ctx):\n'
|
||||
' ctx.register_tool(\n'
|
||||
' name="shadow_target",\n'
|
||||
' toolset="plugin_shadow_plugin",\n'
|
||||
' schema={"name": "shadow_target", "description": "Plugin", "parameters": {"type": "object", "properties": {}}},\n'
|
||||
' handler=lambda args, **kw: "plugin",\n'
|
||||
' )\n'
|
||||
)
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["shadow_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with caplog.at_level(logging.ERROR, logger="tools.registry"):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Original handler must still be in place — registration was rejected.
|
||||
assert registry._tools["shadow_target"].handler is original_handler
|
||||
assert registry._tools["shadow_target"].toolset == "terminal"
|
||||
# And an ERROR was logged explaining why and how to opt in.
|
||||
assert any("override=True" in r.message for r in caplog.records)
|
||||
finally:
|
||||
registry.deregister("shadow_target")
|
||||
|
||||
def test_register_tool_override_replaces_existing(self, tmp_path, monkeypatch, caplog):
|
||||
"""override=True lets a plugin replace an existing built-in tool."""
|
||||
from tools.registry import registry
|
||||
|
||||
registry.register(
|
||||
name="override_target",
|
||||
toolset="terminal",
|
||||
schema={"name": "override_target", "description": "Built-in", "parameters": {"type": "object", "properties": {}}},
|
||||
handler=lambda args, **kw: "built-in",
|
||||
)
|
||||
try:
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "override_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "override_plugin"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
'def register(ctx):\n'
|
||||
' ctx.register_tool(\n'
|
||||
' name="override_target",\n'
|
||||
' toolset="plugin_override_plugin",\n'
|
||||
' schema={"name": "override_target", "description": "Plugin", "parameters": {"type": "object", "properties": {}}},\n'
|
||||
' handler=lambda args, **kw: "plugin",\n'
|
||||
' override=True,\n'
|
||||
' )\n'
|
||||
)
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["override_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
with caplog.at_level(logging.INFO, logger="tools.registry"):
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
|
||||
# Plugin handler replaced the built-in one.
|
||||
assert registry._tools["override_target"].toolset == "plugin_override_plugin"
|
||||
assert registry._tools["override_target"].handler({}, ) == "plugin"
|
||||
# Override is audit-logged at INFO.
|
||||
assert any(
|
||||
"overriding existing" in r.message and "override_target" in r.message
|
||||
for r in caplog.records
|
||||
)
|
||||
# Plugin tracks it.
|
||||
assert "override_target" in mgr._plugin_tool_names
|
||||
finally:
|
||||
registry.deregister("override_target")
|
||||
|
||||
def test_register_tool_override_on_new_name_is_noop_path(self, tmp_path, monkeypatch):
|
||||
"""override=True on a brand-new name still registers cleanly (no existing entry to replace)."""
|
||||
from tools.registry import registry
|
||||
|
||||
plugins_dir = tmp_path / "hermes_test" / "plugins"
|
||||
plugin_dir = plugins_dir / "new_override_plugin"
|
||||
plugin_dir.mkdir(parents=True)
|
||||
(plugin_dir / "plugin.yaml").write_text(yaml.dump({"name": "new_override_plugin"}))
|
||||
(plugin_dir / "__init__.py").write_text(
|
||||
'def register(ctx):\n'
|
||||
' ctx.register_tool(\n'
|
||||
' name="brand_new_override_tool",\n'
|
||||
' toolset="plugin_new_override_plugin",\n'
|
||||
' schema={"name": "brand_new_override_tool", "description": "New", "parameters": {"type": "object", "properties": {}}},\n'
|
||||
' handler=lambda args, **kw: "ok",\n'
|
||||
' override=True,\n'
|
||||
' )\n'
|
||||
)
|
||||
hermes_home = tmp_path / "hermes_test"
|
||||
(hermes_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"plugins": {"enabled": ["new_override_plugin"]}})
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
|
||||
try:
|
||||
mgr = PluginManager()
|
||||
mgr.discover_and_load()
|
||||
assert "brand_new_override_tool" in registry._tools
|
||||
finally:
|
||||
registry.deregister("brand_new_override_tool")
|
||||
|
||||
|
||||
# ── TestPluginToolVisibility ───────────────────────────────────────────────
|
||||
|
||||
|
|
|
|||
|
|
@ -244,8 +244,16 @@ class ToolRegistry:
|
|||
emoji: str = "",
|
||||
max_result_size_chars: int | float | None = None,
|
||||
dynamic_schema_overrides: Callable = None,
|
||||
override: bool = False,
|
||||
):
|
||||
"""Register a tool. Called at module-import time by each tool file."""
|
||||
"""Register a tool. Called at module-import time by each tool file.
|
||||
|
||||
``override=True`` is an explicit opt-in for plugins that intend to
|
||||
replace an existing built-in tool implementation (e.g. swap the
|
||||
default browser tool for a headed-Chrome CDP backend). Without it,
|
||||
registrations that would shadow an existing tool from a different
|
||||
toolset are rejected to prevent accidental overwrites.
|
||||
"""
|
||||
with self._lock:
|
||||
existing = self._tools.get(name)
|
||||
if existing and existing.toolset != toolset:
|
||||
|
|
@ -260,13 +268,22 @@ class ToolRegistry:
|
|||
"Tool '%s': MCP toolset '%s' overwriting MCP toolset '%s'",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
elif override:
|
||||
# Explicit plugin opt-in: replace the existing tool.
|
||||
# Logged at INFO so the override is auditable in agent.log.
|
||||
logger.info(
|
||||
"Tool '%s': toolset '%s' overriding existing toolset '%s' "
|
||||
"(override=True opt-in)",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
else:
|
||||
# Reject shadowing — prevent plugins/MCP from overwriting
|
||||
# built-in tools or vice versa.
|
||||
logger.error(
|
||||
"Tool registration REJECTED: '%s' (toolset '%s') would "
|
||||
"shadow existing tool from toolset '%s'. Deregister the "
|
||||
"existing tool first if this is intentional.",
|
||||
"shadow existing tool from toolset '%s'. Pass "
|
||||
"override=True to register() if the replacement is "
|
||||
"intentional, or deregister the existing tool first.",
|
||||
name, toolset, existing.toolset,
|
||||
)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -465,6 +465,30 @@ ctx.register_tool(
|
|||
)
|
||||
```
|
||||
|
||||
### Overriding a built-in tool
|
||||
|
||||
To replace a built-in tool with your own implementation (e.g. swap the
|
||||
default browser tool for a headed-Chrome CDP backend, or replace
|
||||
`web_search` with a custom corporate index), pass `override=True`:
|
||||
|
||||
```python
|
||||
def register(ctx):
|
||||
ctx.register_tool(
|
||||
name="browser_navigate", # same name as the built-in
|
||||
toolset="plugin_my_browser", # your own toolset namespace
|
||||
schema={...},
|
||||
handler=my_custom_navigate,
|
||||
override=True, # explicit opt-in
|
||||
)
|
||||
```
|
||||
|
||||
Without `override=True`, the registry rejects any registration that would
|
||||
shadow an existing tool from a different toolset — this prevents
|
||||
accidental overwrites. The override is logged at INFO level so it's
|
||||
auditable in `~/.hermes/logs/agent.log`. Plugins load after built-in
|
||||
tools, so the registration order is correct: your handler replaces the
|
||||
built-in one.
|
||||
|
||||
### Register multiple hooks
|
||||
|
||||
```python
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue