From 016c772e7fcf3acca54e7c87e7c5a22541adb5d0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 15 May 2026 22:12:57 -0700 Subject: [PATCH] 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. --- hermes_cli/plugins.py | 15 ++- tests/hermes_cli/test_plugins.py | 123 +++++++++++++++++++ tools/registry.py | 23 +++- website/docs/guides/build-a-hermes-plugin.md | 24 ++++ 4 files changed, 180 insertions(+), 5 deletions(-) diff --git a/hermes_cli/plugins.py b/hermes_cli/plugins.py index 9e9af0e0644..d0bbee6ce63 100644 --- a/hermes_cli/plugins.py +++ b/hermes_cli/plugins.py @@ -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 -------------------------------------------------- diff --git a/tests/hermes_cli/test_plugins.py b/tests/hermes_cli/test_plugins.py index 7be43a236f2..0c500297a2b 100644 --- a/tests/hermes_cli/test_plugins.py +++ b/tests/hermes_cli/test_plugins.py @@ -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 ─────────────────────────────────────────────── diff --git a/tools/registry.py b/tools/registry.py index 9cac53084bd..2639eac74ed 100644 --- a/tools/registry.py +++ b/tools/registry.py @@ -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 diff --git a/website/docs/guides/build-a-hermes-plugin.md b/website/docs/guides/build-a-hermes-plugin.md index ee74e23ac5e..3135c68daaf 100644 --- a/website/docs/guides/build-a-hermes-plugin.md +++ b/website/docs/guides/build-a-hermes-plugin.md @@ -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