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
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ jobs:

# Validate the kit against the *real* hermes-agent source so it can't silently
# drift from upstream. Checks out NousResearch/hermes-agent and points the
# contract tests at it; they skip if the import fails, so this never blocks on
# an upstream layout change — it goes red only on a genuine contract break.
# contract tests at it; explicit checkout import or layout drift fails this
# job so an upstream contract break cannot become a misleading skipped green.
hermes-contract:
runs-on: ubuntu-latest
steps:
Expand Down
7 changes: 4 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
# hermes-plugin-kit

Convention-correct helper library for registering `hermes-agent` plugin tools.
Convention-correct helper library for registering `hermes-agent` plugin tools,
hooks, and skills.
This repository is an installable Python package, not a path-loaded runtime
plugin.

## Working Rules

- Keep the public API centered on `@tool`, `register_all`, and the argument
helpers exported from `hermes_plugin_kit`.
- Keep `@tool` and `register_all` backward compatible. Use `@hook`,
`plugin_skill`, and `register_plugin` for full plugin lifecycle registration.
- 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.
Expand Down
46 changes: 42 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
# hermes-plugin-kit

> A `@tool` decorator for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct LLM tool schemas, validation, logging, and the JSON envelope, baked in.
> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct tools, hooks, skills, validation, and safe logging, baked in.

[![test](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml/badge.svg)](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml)
![python](https://img.shields.io/badge/python-3.11%2B-blue)

`hermes-plugin-kit` is a tiny, dependency-free helper for authoring **tools** in
[hermes-agent](https://github.com/NousResearch/hermes-agent) plugins. Decorate a
handler with `@tool`, register it with `register_all`, and the LLM-facing schema,
`hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a tool
with `@tool` or a lifecycle callback with `@hook`, then use `register_plugin` to
register tools, hooks, and plugin-owned skills together. Existing tool-only
plugins can keep using `register_all`; the LLM-facing schema,
argument validation, structured logging, and the JSON result envelope are all
generated for you — correctly, every time.

Expand Down Expand Up @@ -122,6 +124,42 @@ 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.

## Hooks and plugin skills

Use the lifecycle entrypoint when a plugin provides more than tools:

```python
from pathlib import Path
from hermes_plugin_kit import hook, plugin_skill, register_plugin

@hook("pre_llm_call")
def inject_context(**kwargs):
return {"context": build_context(kwargs)}

SKILLS = (
plugin_skill(
"temporal-awareness",
Path(__file__).with_name("SKILL.md"),
"Calibrate responses against local time and message gaps.",
optional=True,
),
)

def register(ctx):
return register_plugin(ctx, __name__, skills=SKILLS)
```

`@hook` forwards Hermes keyword arguments and return values unchanged. It logs
only the hook name, elapsed time, result type, and supplied `session_id` or
`task_id`; callback payloads and exception messages are never logged. Exceptions
are re-raised so Hermes retains its normal per-plugin isolation behavior.

`plugin_skill` validates the bare skill name, `SKILL.md` path, and description.
Required missing skills fail registration; optional missing skills warn and are
reported in the returned `RegistrationSummary`. Hermes supplies the plugin
namespace, so a declared `temporal-awareness` skill from plugin
`temporal-awareness` resolves as `temporal-awareness:temporal-awareness`.

## Tool names

Hermes uses one global tool registry, and the agent loop intercepts core names
Expand Down
227 changes: 213 additions & 14 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,17 @@ def register(ctx):
import re
import sys
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable

__all__ = [
"tool",
"hook",
"plugin_skill",
"register_plugin",
"PluginSkill",
"RegistrationSummary",
"register_all",
"build_schema",
"tool_name",
Expand All @@ -65,11 +72,33 @@ def register(ctx):
]

_SPEC_ATTR = "_hpk_tool_spec"
_HOOK_SPEC_ATTR = "_hpk_hook_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_",)
_SKILL_NAME_RE = re.compile(r"^[a-zA-Z0-9_-]+$")


@dataclass(frozen=True)
class PluginSkill:
"""Validated declaration for a plugin-owned, read-only Hermes skill."""

name: str
path: Path
description: str
optional: bool = False


@dataclass(frozen=True)
class RegistrationSummary:
"""Inventory of lifecycle surfaces registered by :func:`register_plugin`."""

tools: tuple[str, ...] = ()
hooks: tuple[str, ...] = ()
skills: tuple[str, ...] = ()
skipped_optional_skills: tuple[str, ...] = ()


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -239,10 +268,85 @@ def _truncate(value: Any) -> str:
return text if len(text) <= _MAX_LOG_CHARS else text[:_MAX_LOG_CHARS] + "…"


def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]:
"""Return only Hermes correlation identifiers that are safe to log."""
return {
key: kwargs[key]
for key in ("session_id", "task_id")
if kwargs.get(key) is not None
}


# ---------------------------------------------------------------------------
# The decorator
# ---------------------------------------------------------------------------

def hook(name: str) -> Callable:
"""Mark and instrument a Hermes lifecycle hook callback.

The wrapper preserves Hermes' callback contract: keyword arguments and the
return value pass through unchanged, and exceptions are re-raised for the
plugin manager to isolate. Logs contain correlation identifiers only, never
message payloads or exception messages.
"""
if not isinstance(name, str) or not name.strip():
raise ValueError("hook name is required")
hook_name = name.strip()

def decorate(fn: Callable) -> Callable:
log = logging.getLogger(fn.__module__ or "hermes_plugin_kit")

@functools.wraps(fn)
def wrapper(**kwargs: Any) -> Any:
started = time.perf_counter()
context = _truncate(_safe_context(kwargs))
log.debug(
"%s: invoked; context=%s",
hook_name,
context,
)
try:
result = fn(**kwargs)
except Exception as exc:
log.warning(
"%s: callback raised; elapsed_ms=%.2f; error_type=%s; context=%s",
hook_name,
(time.perf_counter() - started) * 1000,
type(exc).__name__,
context,
)
raise
log.info(
"%s: ok; elapsed_ms=%.2f; result=%s; context=%s",
hook_name,
(time.perf_counter() - started) * 1000,
type(result).__name__,
context,
)
return result

setattr(wrapper, _HOOK_SPEC_ATTR, {"name": hook_name})
return wrapper

return decorate


def plugin_skill(
name: str,
path: str | Path,
description: str,
optional: bool = False,
) -> PluginSkill:
"""Declare a plugin-owned ``SKILL.md`` for :func:`register_plugin`."""
if not isinstance(name, str) or not _SKILL_NAME_RE.fullmatch(name):
raise ValueError("skill name must match [a-zA-Z0-9_-]+ and contain no namespace")
skill_path = Path(path)
if skill_path.name != "SKILL.md":
raise ValueError("skill path must point to SKILL.md")
if not isinstance(description, str) or not description.strip():
raise ValueError("skill description is required")
return PluginSkill(name, skill_path, description.strip(), bool(optional))

def tool(
*,
toolset: str,
Expand Down Expand Up @@ -280,11 +384,7 @@ def wrapper(args: dict, **kwargs: Any) -> str:
args = args or {}
started = time.perf_counter()
safe_args = _truncate(_redacted_args(args))
context = {
key: kwargs[key]
for key in ("session_id", "task_id")
if kwargs.get(key) is not None
}
context = _safe_context(kwargs)
log.debug(
"%s: invoked; args=%s; context=%s",
tool_name,
Expand Down Expand Up @@ -370,19 +470,118 @@ def register_all(ctx: Any, module: Any) -> int:
if not spec or spec["name"] in seen:
continue
seen.add(spec["name"])
ctx.register_tool(
name=spec["name"],
toolset=spec["toolset"],
schema=spec["schema"],
handler=obj,
requires_env=spec["requires_env"],
description=spec["schema"]["description"],
emoji=spec["emoji"],
)
_register_tool(ctx, obj, spec)
count += 1
log.info(
"hermes_plugin_kit: registered %d tool(s); names=%s",
count,
",".join(sorted(seen)) or "<none>",
)
return count


def _register_tool(ctx: Any, handler: Callable, spec: dict[str, Any]) -> None:
"""Bind one kit-decorated tool to the current Hermes plugin context."""
ctx.register_tool(
name=spec["name"],
toolset=spec["toolset"],
schema=spec["schema"],
handler=handler,
requires_env=spec["requires_env"],
description=spec["schema"]["description"],
emoji=spec["emoji"],
)


def register_plugin(
ctx: Any,
module: Any,
skills: tuple[PluginSkill, ...] | list[PluginSkill] = (),
) -> RegistrationSummary:
"""Register decorated tools, hooks, and declared skills from *module*.

Unlike the backward-compatible :func:`register_all`, this lifecycle-level
entrypoint rejects distinct declarations that share a public name. Missing
optional skills are warned and skipped; missing required skills fail fast.
"""
if isinstance(module, str):
module = sys.modules[module]
log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit"))

tools: dict[str, Callable] = {}
hooks: dict[str, Callable] = {}
for _, obj in inspect.getmembers(module):
tool_spec = getattr(obj, _SPEC_ATTR, None)
if tool_spec:
existing = tools.get(tool_spec["name"])
if existing is not None and existing is not obj:
raise ValueError(f"duplicate tool name: {tool_spec['name']}")
tools[tool_spec["name"]] = obj

hook_spec = getattr(obj, _HOOK_SPEC_ATTR, None)
if hook_spec:
existing = hooks.get(hook_spec["name"])
if existing is not None and existing is not obj:
raise ValueError(f"duplicate hook name: {hook_spec['name']}")
hooks[hook_spec["name"]] = obj

declared_skills: dict[str, PluginSkill] = {}
for skill in skills:
if not isinstance(skill, PluginSkill):
raise TypeError("skills must contain plugin_skill() declarations")
if skill.name in declared_skills:
raise ValueError(f"duplicate skill name: {skill.name}")
declared_skills[skill.name] = skill

available_skills: list[PluginSkill] = []
skipped_skills: list[str] = []
for name in sorted(declared_skills):
skill = declared_skills[name]
if skill.path.is_file():
available_skills.append(skill)
continue
if not skill.optional:
raise FileNotFoundError(f"SKILL.md not found at {skill.path}")
log.warning(
"hermes_plugin_kit: optional skill missing; name=%s; path=%s",
skill.name,
skill.path,
)
skipped_skills.append(name)

registered_tools: list[str] = []
for name in sorted(tools):
obj = tools[name]
spec = getattr(obj, _SPEC_ATTR)
_register_tool(ctx, obj, spec)
registered_tools.append(name)

registered_hooks: list[str] = []
for name in sorted(hooks):
ctx.register_hook(name, hooks[name])
registered_hooks.append(name)

registered_skills: list[str] = []
for skill in available_skills:
ctx.register_skill(
name=skill.name,
path=skill.path,
description=skill.description,
)
registered_skills.append(skill.name)

summary = RegistrationSummary(
tools=tuple(registered_tools),
hooks=tuple(registered_hooks),
skills=tuple(registered_skills),
skipped_optional_skills=tuple(skipped_skills),
)
log.info(
"hermes_plugin_kit: registered plugin lifecycle; tools=%s; hooks=%s; "
"skills=%s; skipped_optional_skills=%s",
",".join(summary.tools) or "<none>",
",".join(summary.hooks) or "<none>",
",".join(summary.skills) or "<none>",
",".join(summary.skipped_optional_skills) or "<none>",
)
return summary
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hermes-plugin-kit"
version = "0.1.0"
description = "Convention-correct tool registration for hermes-agent plugins."
description = "Convention-correct lifecycle registration for hermes-agent plugins."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
Expand Down
Loading