Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)`
Expand Down
59 changes: 58 additions & 1 deletion hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ def register(ctx):
import inspect
import json
import logging
import re
import sys
from typing import Any, Callable

__all__ = [
"tool",
"register_all",
"build_schema",
"tool_name",
"validate_tool_name",
"arg",
"str_arg",
"int_arg",
Expand All @@ -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)


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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 = "",
Expand All @@ -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(
Expand Down
28 changes: 25 additions & 3 deletions tests/test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={
Expand All @@ -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"]
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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"], "🧵")
Expand Down