diff --git a/AGENTS.md b/AGENTS.md index 07f330e..5dd27ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ plugin. - Keep the public API centered on `@tool`, `register_all`, and the argument helpers exported from `hermes_plugin_kit`. +- Use `tool_name(namespace, verb, noun)` for new tools and prefer explicit + verbs such as `read`, `write`, and `patch`. Do not use Hermes agent-loop + names (`memory`, `todo`, `session_search`, `delegate_task`) as plugin tools. - Preserve the Hermes tool schema convention: arguments live under `function.parameters`, never as flattened top-level schema fields. - Tool handlers must accept `(args, **kwargs)` and return JSON-compatible diff --git a/README.md b/README.md index 47f6cf8..87efc1b 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ the same boilerplate. `hermes-plugin-kit` makes them structurally impossible: description, the one field a model reliably sees. - **Validation + instructive errors** — a missing or blank required argument returns an error that *names the argument and its example*. +- **Explicit tool namespacing** — build names with `tool_name(namespace, verb, noun)` + and reject Hermes agent-loop names such as `memory`. - **Logging** — `WARNING` on a rejected call (arguments truncated, secret-looking values redacted), `INFO` on success, under your plugin's own logger. - **Envelope + safety** — return a plain `dict` (or raise); the kit encodes the JSON @@ -84,10 +86,12 @@ hermes-plugin-kit = { git = "https://github.com/offendingcommit/hermes-plugin-ki `tools.py`: ```python -from hermes_plugin_kit import tool, register_all, str_arg, int_arg +from hermes_plugin_kit import tool, tool_name, register_all, str_arg, int_arg @tool( toolset="messaging", + namespace="discord", + name=tool_name("discord", "read", "thread"), requires_env=["DISCORD_BOT_TOKEN"], params={ "thread_id_or_url": str_arg( @@ -116,6 +120,23 @@ That's it. `discord_read_thread` is registered with a `parameters`-wrapped schem self-documenting description, required-argument validation, logging, and the JSON envelope — none of which you had to write. +## Tool names + +Hermes uses one global tool registry, and the agent loop intercepts core names +before registry dispatch. Plugin tools should use an explicit domain namespace +and an action verb: + +```python +name=tool_name("discord", "read", "thread") # discord_read_thread +name=tool_name("workspace", "write", "diary") # workspace_write_diary +name=tool_name("workspace", "patch", "text") # workspace_patch_text +``` + +Do not register plugin tools with agent-loop names such as `memory`, `todo`, +`session_search`, or `delegate_task`. The kit also rejects the reserved +`memory_` prefix so plugin tools cannot be confused with Hermes' built-in +persistent memory tool. + ## Argument specs - `str_arg(description, *, required=False, example=None, enum=None, min_length=None, **extra)` diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 2fdcd16..2748dd3 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -46,6 +46,7 @@ def register(ctx): import inspect import json import logging +import re import sys from typing import Any, Callable @@ -53,6 +54,8 @@ def register(ctx): "tool", "register_all", "build_schema", + "tool_name", + "validate_tool_name", "arg", "str_arg", "int_arg", @@ -62,6 +65,58 @@ def register(ctx): _SPEC_ATTR = "_hpk_tool_spec" _REDACT_HINTS = ("token", "secret", "password", "passwd", "api_key", "apikey", "auth") _MAX_LOG_CHARS = 200 +_TOOL_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$") +_AGENT_LOOP_TOOL_NAMES = frozenset({"todo", "memory", "session_search", "delegate_task"}) +_RESERVED_NAMESPACE_PREFIXES = ("memory_",) + + +# --------------------------------------------------------------------------- +# Tool naming +# --------------------------------------------------------------------------- + +def validate_tool_name(name: str, *, namespace: str | None = None) -> str: + """Validate a Hermes plugin tool name and return it unchanged. + + Hermes keeps tool names in one global registry. The core agent loop also + intercepts a few names before registry dispatch, so plugin tools must avoid + those names and should carry an explicit plugin/domain prefix. + """ + if not isinstance(name, str) or not name: + raise ValueError("tool name is required") + if not _TOOL_NAME_RE.fullmatch(name): + raise ValueError( + f"tool name {name!r} must match {_TOOL_NAME_RE.pattern!r}" + ) + if name in _AGENT_LOOP_TOOL_NAMES: + raise ValueError( + f"tool name {name!r} is reserved by the Hermes agent loop" + ) + if name.startswith(_RESERVED_NAMESPACE_PREFIXES): + raise ValueError( + f"tool name {name!r} uses a reserved Hermes core namespace; " + "choose a plugin/domain namespace instead" + ) + if namespace is not None: + if not _TOOL_NAME_RE.fullmatch(namespace): + raise ValueError( + f"tool namespace {namespace!r} must match {_TOOL_NAME_RE.pattern!r}" + ) + if namespace in _AGENT_LOOP_TOOL_NAMES: + raise ValueError( + f"tool namespace {namespace!r} is reserved by the Hermes agent loop" + ) + expected = f"{namespace}_" + if not name.startswith(expected): + raise ValueError( + f"tool name {name!r} must start with explicit namespace {expected!r}" + ) + return name + + +def tool_name(namespace: str, verb: str, noun: str) -> str: + """Build and validate a namespaced tool name such as ``discord_read_thread``.""" + name = "_".join(part.strip("_") for part in (namespace, verb, noun) if part) + return validate_tool_name(name, namespace=namespace) # --------------------------------------------------------------------------- @@ -138,6 +193,7 @@ def _augment_description(description: str, required: list, examples: dict) -> st def build_schema(name: str, description: str, params: dict | None) -> dict: """Build a hermes-convention tool schema: arguments nested under ``parameters``.""" + validate_tool_name(name) properties, required, examples = _split_params(params) parameters: dict[str, Any] = { "type": "object", @@ -178,6 +234,7 @@ def tool( toolset: str, params: dict | None = None, name: str | None = None, + namespace: str | None = None, description: str | None = None, requires_env: list | None = None, emoji: str = "", @@ -190,7 +247,7 @@ def tool( """ def decorate(fn: Callable) -> Callable: - tool_name = name or fn.__name__ + tool_name = validate_tool_name(name or fn.__name__, namespace=namespace) doc = (description or inspect.getdoc(fn) or "").strip() if not doc: raise ValueError( diff --git a/tests/test_kit.py b/tests/test_kit.py index b15d7e5..7105e33 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -16,6 +16,8 @@ def register_tool(self, **kwargs) -> None: @hpk.tool( toolset="messaging", + namespace="sample", + name=hpk.tool_name("sample", "read", "thread"), requires_env=["DISCORD_BOT_TOKEN"], emoji="🧵", params={ @@ -41,7 +43,7 @@ def setUp(self) -> None: self.schema = getattr(sample_read, "_hpk_tool_spec")["schema"] def test_arguments_live_under_parameters_not_top_level(self) -> None: - self.assertEqual(self.schema["name"], "sample_read") + self.assertEqual(self.schema["name"], "sample_read_thread") self.assertIn("parameters", self.schema) self.assertNotIn("properties", self.schema) # never at the top level params = self.schema["parameters"] @@ -86,6 +88,26 @@ def test_exception_caught_in_band(self) -> None: self.assertFalse(out["success"]) self.assertIn("sample_boom failed", out["error"]) + def test_reserved_agent_loop_tool_name_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "reserved"): + + @hpk.tool(toolset="x", name="memory") + def reserved(args, **kwargs): + """Reserved.""" + return {} + + def test_reserved_core_namespace_prefix_rejected(self) -> None: + with self.assertRaisesRegex(ValueError, "reserved Hermes core namespace"): + hpk.tool_name("memory", "write", "entry") + + def test_explicit_namespace_must_match_tool_name(self) -> None: + with self.assertRaisesRegex(ValueError, "must start with explicit namespace"): + + @hpk.tool(toolset="x", namespace="discord", name="thread_read") + def wrong_namespace(args, **kwargs): + """Wrong namespace.""" + return {} + def test_secret_looking_values_redacted_in_logs(self) -> None: @hpk.tool(toolset="x", params={"id": hpk.str_arg("id", required=True)}) def needs_id(args, **kwargs): @@ -113,8 +135,8 @@ def test_registers_every_decorated_tool_with_convention(self) -> None: count = hpk.register_all(ctx, __name__) self.assertGreaterEqual(count, 2) by_name = {tool["name"]: tool for tool in ctx.tools} - self.assertIn("sample_read", by_name) - sample = by_name["sample_read"] + self.assertIn("sample_read_thread", by_name) + sample = by_name["sample_read_thread"] self.assertEqual(sample["toolset"], "messaging") self.assertEqual(sample["requires_env"], ["DISCORD_BOT_TOKEN"]) self.assertEqual(sample["emoji"], "🧵")