feat(plugins): tool override flag for replacing built-in tools (closes #11049) (#26759)

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:
Teknium 2026-05-15 22:12:57 -07:00 committed by GitHub
parent 9c304a7f56
commit 016c772e7f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 180 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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