diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index f52f3f1..fe9c423 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -140,7 +140,11 @@ def register(ctx): _MAX_LOG_CHARS = 200 _TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") _COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$") -# Fallback for _agent_loop_tool_names() outside a Hermes host; matches v0.22.5. +# Names Hermes intercepts or rewrites before registry dispatch (v0.22.5 +# model_tools._AGENT_LOOP_TOOLS | _LEGACY_TOOL_ALIASES). Never import the host to +# read them: plugins register while model_tools is mid-import, from loader +# threads, so that import deadlocks on the module lock. The contract lane pins +# this copy to the deployed host instead. _AGENT_LOOP_TOOL_NAMES = frozenset({ "todo_list", "memory", "session_search", "delegate_task", "todo", "cronjob", "process", "tour", "tip", @@ -711,15 +715,6 @@ def as_dict(self) -> dict[str, Any]: # Tool naming # --------------------------------------------------------------------------- -def _agent_loop_tool_names() -> frozenset[str]: - """Names Hermes intercepts or rewrites before registry dispatch, read from the host itself.""" - try: - from model_tools import _AGENT_LOOP_TOOLS, _LEGACY_TOOL_ALIASES - except ImportError: # not inside a Hermes host (unit tests, tooling) - return _AGENT_LOOP_TOOL_NAMES - return frozenset(_AGENT_LOOP_TOOLS) | frozenset(_LEGACY_TOOL_ALIASES) - - def validate_tool_name(name: str, *, namespace: str | None = None) -> str: """Validate a Hermes plugin tool name and return it unchanged. @@ -733,7 +728,7 @@ def validate_tool_name(name: str, *, namespace: str | None = None) -> str: raise ValueError( f"tool name {name!r} must match {_TOOL_NAME_RE.pattern!r}" ) - reserved = _agent_loop_tool_names() + reserved = _AGENT_LOOP_TOOL_NAMES if name in reserved: raise ValueError( f"tool name {name!r} is reserved by the Hermes agent loop" diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index d080cd4..24e87fb 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -717,7 +717,6 @@ def test_reserved_tool_names_match_the_host(self) -> None: from model_tools import _AGENT_LOOP_TOOLS, _LEGACY_TOOL_ALIASES # type: ignore host = frozenset(_AGENT_LOOP_TOOLS) | frozenset(_LEGACY_TOOL_ALIASES) - self.assertEqual(hpk._agent_loop_tool_names(), host) self.assertEqual(hpk._AGENT_LOOP_TOOL_NAMES, host) def test_host_tool_invocation_reaches_real_telegram_media_contract(self) -> None: diff --git a/tests/test_kit.py b/tests/test_kit.py index 89066a9..156be60 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -443,6 +443,28 @@ def test_renamed_and_legacy_agent_loop_names_rejected(self) -> None: with self.subTest(name=name), self.assertRaisesRegex(ValueError, "reserved"): hpk.validate_tool_name(name) + def test_tool_name_validation_never_imports_the_host(self) -> None: + # Hermes registers plugins from loader threads while model_tools is still + # importing; importing it here waits on that module lock until the 10 s + # plugin-load deadline, for every plugin (infra#1558). + class RefuseModelTools: + def find_spec(self, name, path=None, target=None): + if name == "model_tools": + raise AssertionError("tool-name validation imported model_tools") + return None + + blocker = RefuseModelTools() + saved = sys.modules.pop("model_tools", None) + sys.meta_path.insert(0, blocker) + try: + self.assertEqual(hpk.validate_tool_name("sample_read_note"), "sample_read_note") + with self.assertRaisesRegex(ValueError, "reserved"): + hpk.validate_tool_name("todo_list") + finally: + sys.meta_path.remove(blocker) + if saved is not None: + sys.modules["model_tools"] = saved + def test_reserved_core_namespace_prefix_rejected(self) -> None: with self.assertRaisesRegex(ValueError, "reserved Hermes core namespace"): hpk.tool_name("memory", "write", "entry")