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
9 changes: 5 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# hermes-plugin-kit

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

## Working Rules

- Keep `@tool` and `register_all` backward compatible. Use `@hook`,
`plugin_skill`, and `register_plugin` for full plugin lifecycle registration.
- Keep `@tool` and `register_all` backward compatible. Use `@command`,
`@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin
lifecycle registration.
- Use `invoke_host_tool` for host-managed capabilities such as `send_message`;
do not assume every Hermes capability is registered in `tools.registry`.
Nested host calls must remain visible to `pre_tool_call` and `post_tool_call`.
Expand Down
57 changes: 52 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# hermes-plugin-kit

> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, hooks, skills, validation, and safe logging, baked in.
> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, middleware, 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 plugins for
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a slash
command with `@command`, a tool with `@tool`, or a lifecycle callback with
`@hook`, then use `register_plugin` to register commands, tools, hooks, and
plugin-owned skills together. Existing tool-only
`@middleware` or `@hook`, then use `register_plugin` to register commands,
tools, middleware, 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 @@ -132,19 +132,44 @@ 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.

## Commands, hooks, and plugin skills
## Commands, middleware, hooks, and plugin skills

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

```python
import time
from pathlib import Path
from hermes_plugin_kit import command, hook, plugin_skill, register_plugin
from hermes_plugin_kit import (
MiddlewareKind,
command,
hook,
middleware,
plugin_skill,
register_plugin,
)

@command("valdris-status", args_hint="<scope>")
def valdris_status(raw_args):
"""Show the current Valdris plugin status."""
return build_status(raw_args)

@middleware(MiddlewareKind.TOOL_REQUEST)
def normalize_tool_request(**kwargs):
args = {**kwargs["args"]}
args["workspace"] = normalize_workspace(args.get("workspace"))
return {"args": args, "source": "valdris"}

@middleware(MiddlewareKind.TOOL_EXECUTION)
def measure_tool_execution(**kwargs):
started = time.perf_counter()
try:
return kwargs["next_call"](kwargs["args"])
finally:
record_tool_latency(
kwargs["tool_name"],
time.perf_counter() - started,
)

@hook("pre_llm_call")
def inject_context(**kwargs):
return {"context": build_context(kwargs)}
Expand All @@ -169,6 +194,28 @@ forwarded to Hermes for native command pickers. Command logs include only the
command name, elapsed time, result type, and argument character count, never
the raw arguments.

`@middleware` changes runtime behavior rather than merely observing it. Request
middleware rewrites the effective payload before Hermes continues; execution
middleware wraps the actual tool or model call through the supplied
single-use `next_call`. The four current phases are:

- `MiddlewareKind.TOOL_REQUEST`: return `{"args": {...}}` to replace tool
arguments before hooks, guardrails, approvals, and execution.
- `MiddlewareKind.TOOL_EXECUTION`: call `next_call(args)` to wrap the real tool
execution and optionally transform its result.
- `MiddlewareKind.LLM_REQUEST`: return `{"request": {...}}` to replace provider
request arguments before the model call.
- `MiddlewareKind.LLM_EXECUTION`: call `next_call(request)` to wrap the real
model execution and optionally transform its result.

Middleware callbacks must be synchronous because Hermes does not await them.
Each execution callback must call `next_call` at most once. The decorator also
accepts a non-empty string kind for forward compatibility with future Hermes
phases. `register_plugin` rejects two callbacks for the same kind within one
plugin, which prevents registration order from silently deciding behavior.
Logs contain the kind, elapsed time, result type, and safe correlation IDs, but
never request payloads or exception messages.

`@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
Expand Down
103 changes: 98 additions & 5 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""hermes-plugin-kit — convention-correct surface registration for Hermes plugins.

Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied
for you, so the classes of bug that bite hand-written plugins cannot recur:
Reach for ``@tool`` + ``register_all`` and every Hermes tool convention is
applied for you. Use ``@command``, ``@middleware``, ``@hook``, and
``register_plugin`` for the full plugin lifecycle:

- **Schema convention** — arguments are nested under a ``parameters`` wrapper
(``{name, description, parameters: {type, properties, required,
Expand All @@ -19,6 +20,9 @@
``(args, **kwargs)`` signature, exactly as the registry requires.
- **Host invocation** — ``invoke_host_tool`` reaches supported Hermes runtime
services that are not registry-backed while preserving tool lifecycle hooks.
- **Middleware** — request callbacks can rewrite tool or model inputs, while
execution callbacks wrap the real call through Hermes' single-use
``next_call`` chain.

Usage::

Expand Down Expand Up @@ -62,6 +66,7 @@ def register(ctx):
__all__ = [
"tool",
"command",
"middleware",
"hook",
"plugin_skill",
"register_plugin",
Expand All @@ -74,6 +79,7 @@ def register(ctx):
"MediaPayload",
"ResolvedDeliveryTarget",
"MediaDeliveryResult",
"MiddlewareKind",
"PluginSkill",
"RegistrationSummary",
"register_all",
Expand All @@ -88,6 +94,7 @@ def register(ctx):

_SPEC_ATTR = "_hpk_tool_spec"
_COMMAND_SPEC_ATTR = "_hpk_command_spec"
_MIDDLEWARE_SPEC_ATTR = "_hpk_middleware_spec"
_HOOK_SPEC_ATTR = "_hpk_hook_spec"
_REDACT_HINTS = ("token", "secret", "password", "passwd", "api_key", "apikey", "auth")
_MAX_LOG_CHARS = 200
Expand Down Expand Up @@ -131,6 +138,16 @@ class RegistrationSummary:
skills: tuple[str, ...] = ()
skipped_optional_skills: tuple[str, ...] = ()
commands: tuple[str, ...] = ()
middlewares: tuple[str, ...] = ()


class MiddlewareKind(str, Enum):
"""Middleware phases currently supported by hermes-agent."""

TOOL_REQUEST = "tool_request"
TOOL_EXECUTION = "tool_execution"
LLM_REQUEST = "llm_request"
LLM_EXECUTION = "llm_execution"


class MediaType(str, Enum):
Expand Down Expand Up @@ -397,7 +414,7 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]:


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

def command(
Expand Down Expand Up @@ -500,6 +517,65 @@ def sync_wrapper(raw_args: str) -> str | None:
return decorate


def middleware(kind: MiddlewareKind | str) -> Callable:
"""Mark and instrument a synchronous Hermes middleware callback.

Known middleware phases are available through :class:`MiddlewareKind`.
Non-empty strings are also accepted so plugins can adopt new Hermes phases
without waiting for a kit release. Keyword arguments and return values pass
through unchanged.
"""
if isinstance(kind, MiddlewareKind):
middleware_kind = kind.value
elif isinstance(kind, str) and kind.strip():
middleware_kind = kind.strip()
else:
raise ValueError("middleware kind is required")

def decorate(fn: Callable) -> Callable:
if inspect.iscoroutinefunction(fn):
raise TypeError(
"@middleware callbacks must be synchronous; "
"hermes-agent does not await middleware callbacks"
)
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 middleware: invoked; context=%s",
middleware_kind,
context,
)
try:
result = fn(**kwargs)
except Exception as exc:
log.warning(
"%s middleware: callback raised; elapsed_ms=%.2f; "
"error_type=%s; context=%s",
middleware_kind,
(time.perf_counter() - started) * 1000,
type(exc).__name__,
context,
)
raise
log.info(
"%s middleware: ok; elapsed_ms=%.2f; result=%s; context=%s",
middleware_kind,
(time.perf_counter() - started) * 1000,
type(result).__name__,
context,
)
return result

setattr(wrapper, _MIDDLEWARE_SPEC_ATTR, {"kind": middleware_kind})
return wrapper

return decorate


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

Expand Down Expand Up @@ -1240,7 +1316,7 @@ def register_plugin(
module: Any,
skills: tuple[PluginSkill, ...] | list[PluginSkill] = (),
) -> RegistrationSummary:
"""Register decorated commands, tools, hooks, and skills from *module*.
"""Register decorated commands, tools, middleware, hooks, and skills.

Unlike the backward-compatible :func:`register_all`, this lifecycle-level
entrypoint rejects distinct declarations that share a public name. Missing
Expand All @@ -1252,6 +1328,7 @@ def register_plugin(

commands: dict[str, Callable] = {}
tools: dict[str, Callable] = {}
middlewares: dict[str, Callable] = {}
hooks: dict[str, Callable] = {}
for _, obj in inspect.getmembers(module):
command_spec = getattr(obj, _COMMAND_SPEC_ATTR, None)
Expand All @@ -1268,6 +1345,15 @@ def register_plugin(
raise ValueError(f"duplicate tool name: {tool_spec['name']}")
tools[tool_spec["name"]] = obj

middleware_spec = getattr(obj, _MIDDLEWARE_SPEC_ATTR, None)
if middleware_spec:
existing = middlewares.get(middleware_spec["kind"])
if existing is not None and existing is not obj:
raise ValueError(
f"duplicate middleware kind: {middleware_spec['kind']}"
)
middlewares[middleware_spec["kind"]] = obj

hook_spec = getattr(obj, _HOOK_SPEC_ATTR, None)
if hook_spec:
existing = hooks.get(hook_spec["name"])
Expand Down Expand Up @@ -1318,6 +1404,11 @@ def register_plugin(
_register_tool(ctx, obj, spec)
registered_tools.append(name)

registered_middlewares: list[str] = []
for kind in sorted(middlewares):
ctx.register_middleware(kind, middlewares[kind])
registered_middlewares.append(kind)

registered_hooks: list[str] = []
for name in sorted(hooks):
ctx.register_hook(name, hooks[name])
Expand All @@ -1335,15 +1426,17 @@ def register_plugin(
summary = RegistrationSummary(
commands=tuple(registered_commands),
tools=tuple(registered_tools),
middlewares=tuple(registered_middlewares),
hooks=tuple(registered_hooks),
skills=tuple(registered_skills),
skipped_optional_skills=tuple(skipped_skills),
)
log.info(
"hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; "
"hooks=%s; skills=%s; skipped_optional_skills=%s",
"middlewares=%s; hooks=%s; skills=%s; skipped_optional_skills=%s",
",".join(summary.commands) or "<none>",
",".join(summary.tools) or "<none>",
",".join(summary.middlewares) or "<none>",
",".join(summary.hooks) or "<none>",
",".join(summary.skills) or "<none>",
",".join(summary.skipped_optional_skills) or "<none>",
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ build-backend = "setuptools.build_meta"

[project]
name = "hermes-plugin-kit"
version = "0.4.0"
description = "Convention-correct command and lifecycle registration for hermes-agent plugins."
version = "0.5.0"
description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
Expand Down
Loading