Skip to content

Commit ab70104

Browse files
Merge pull request #11 from offendingcommit/codex/feat/middleware-registration
feat: register plugin middleware declaratively
2 parents 54b151e + fa64f5d commit ab70104

7 files changed

Lines changed: 394 additions & 18 deletions

File tree

AGENTS.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
11
# hermes-plugin-kit
22

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

88
## Working Rules
99

10-
- Keep `@tool` and `register_all` backward compatible. Use `@hook`,
11-
`plugin_skill`, and `register_plugin` for full plugin lifecycle registration.
10+
- Keep `@tool` and `register_all` backward compatible. Use `@command`,
11+
`@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin
12+
lifecycle registration.
1213
- Use `invoke_host_tool` for host-managed capabilities such as `send_message`;
1314
do not assume every Hermes capability is registered in `tools.registry`.
1415
Nested host calls must remain visible to `pre_tool_call` and `post_tool_call`.

README.md

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
# hermes-plugin-kit
22

3-
> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, hooks, skills, validation, and safe logging, baked in.
3+
> 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.
44
55
[![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)
66
![python](https://img.shields.io/badge/python-3.11%2B-blue)
77

88
`hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for
99
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a slash
1010
command with `@command`, a tool with `@tool`, or a lifecycle callback with
11-
`@hook`, then use `register_plugin` to register commands, tools, hooks, and
12-
plugin-owned skills together. Existing tool-only
11+
`@middleware` or `@hook`, then use `register_plugin` to register commands,
12+
tools, middleware, hooks, and plugin-owned skills together. Existing tool-only
1313
plugins can keep using `register_all`; the LLM-facing schema,
1414
argument validation, structured logging, and the JSON result envelope are all
1515
generated for you — correctly, every time.
@@ -132,19 +132,44 @@ That's it. `discord_read_thread` is registered with a `parameters`-wrapped schem
132132
self-documenting description, required-argument validation, logging, and the JSON
133133
envelope — none of which you had to write.
134134

135-
## Commands, hooks, and plugin skills
135+
## Commands, middleware, hooks, and plugin skills
136136

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

139139
```python
140+
import time
140141
from pathlib import Path
141-
from hermes_plugin_kit import command, hook, plugin_skill, register_plugin
142+
from hermes_plugin_kit import (
143+
MiddlewareKind,
144+
command,
145+
hook,
146+
middleware,
147+
plugin_skill,
148+
register_plugin,
149+
)
142150

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

156+
@middleware(MiddlewareKind.TOOL_REQUEST)
157+
def normalize_tool_request(**kwargs):
158+
args = {**kwargs["args"]}
159+
args["workspace"] = normalize_workspace(args.get("workspace"))
160+
return {"args": args, "source": "valdris"}
161+
162+
@middleware(MiddlewareKind.TOOL_EXECUTION)
163+
def measure_tool_execution(**kwargs):
164+
started = time.perf_counter()
165+
try:
166+
return kwargs["next_call"](kwargs["args"])
167+
finally:
168+
record_tool_latency(
169+
kwargs["tool_name"],
170+
time.perf_counter() - started,
171+
)
172+
148173
@hook("pre_llm_call")
149174
def inject_context(**kwargs):
150175
return {"context": build_context(kwargs)}
@@ -169,6 +194,28 @@ forwarded to Hermes for native command pickers. Command logs include only the
169194
command name, elapsed time, result type, and argument character count, never
170195
the raw arguments.
171196

197+
`@middleware` changes runtime behavior rather than merely observing it. Request
198+
middleware rewrites the effective payload before Hermes continues; execution
199+
middleware wraps the actual tool or model call through the supplied
200+
single-use `next_call`. The four current phases are:
201+
202+
- `MiddlewareKind.TOOL_REQUEST`: return `{"args": {...}}` to replace tool
203+
arguments before hooks, guardrails, approvals, and execution.
204+
- `MiddlewareKind.TOOL_EXECUTION`: call `next_call(args)` to wrap the real tool
205+
execution and optionally transform its result.
206+
- `MiddlewareKind.LLM_REQUEST`: return `{"request": {...}}` to replace provider
207+
request arguments before the model call.
208+
- `MiddlewareKind.LLM_EXECUTION`: call `next_call(request)` to wrap the real
209+
model execution and optionally transform its result.
210+
211+
Middleware callbacks must be synchronous because Hermes does not await them.
212+
Each execution callback must call `next_call` at most once. The decorator also
213+
accepts a non-empty string kind for forward compatibility with future Hermes
214+
phases. `register_plugin` rejects two callbacks for the same kind within one
215+
plugin, which prevents registration order from silently deciding behavior.
216+
Logs contain the kind, elapsed time, result type, and safe correlation IDs, but
217+
never request payloads or exception messages.
218+
172219
`@hook` forwards Hermes keyword arguments and return values unchanged. It logs
173220
only the hook name, elapsed time, result type, and supplied `session_id` or
174221
`task_id`; callback payloads and exception messages are never logged. Exceptions

hermes_plugin_kit/__init__.py

Lines changed: 98 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
"""hermes-plugin-kit — convention-correct surface registration for Hermes plugins.
22
3-
Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied
4-
for you, so the classes of bug that bite hand-written plugins cannot recur:
3+
Reach for ``@tool`` + ``register_all`` and every Hermes tool convention is
4+
applied for you. Use ``@command``, ``@middleware``, ``@hook``, and
5+
``register_plugin`` for the full plugin lifecycle:
56
67
- **Schema convention** — arguments are nested under a ``parameters`` wrapper
78
(``{name, description, parameters: {type, properties, required,
@@ -19,6 +20,9 @@
1920
``(args, **kwargs)`` signature, exactly as the registry requires.
2021
- **Host invocation** — ``invoke_host_tool`` reaches supported Hermes runtime
2122
services that are not registry-backed while preserving tool lifecycle hooks.
23+
- **Middleware** — request callbacks can rewrite tool or model inputs, while
24+
execution callbacks wrap the real call through Hermes' single-use
25+
``next_call`` chain.
2226
2327
Usage::
2428
@@ -62,6 +66,7 @@ def register(ctx):
6266
__all__ = [
6367
"tool",
6468
"command",
69+
"middleware",
6570
"hook",
6671
"plugin_skill",
6772
"register_plugin",
@@ -74,6 +79,7 @@ def register(ctx):
7479
"MediaPayload",
7580
"ResolvedDeliveryTarget",
7681
"MediaDeliveryResult",
82+
"MiddlewareKind",
7783
"PluginSkill",
7884
"RegistrationSummary",
7985
"register_all",
@@ -88,6 +94,7 @@ def register(ctx):
8894

8995
_SPEC_ATTR = "_hpk_tool_spec"
9096
_COMMAND_SPEC_ATTR = "_hpk_command_spec"
97+
_MIDDLEWARE_SPEC_ATTR = "_hpk_middleware_spec"
9198
_HOOK_SPEC_ATTR = "_hpk_hook_spec"
9299
_REDACT_HINTS = ("token", "secret", "password", "passwd", "api_key", "apikey", "auth")
93100
_MAX_LOG_CHARS = 200
@@ -131,6 +138,16 @@ class RegistrationSummary:
131138
skills: tuple[str, ...] = ()
132139
skipped_optional_skills: tuple[str, ...] = ()
133140
commands: tuple[str, ...] = ()
141+
middlewares: tuple[str, ...] = ()
142+
143+
144+
class MiddlewareKind(str, Enum):
145+
"""Middleware phases currently supported by hermes-agent."""
146+
147+
TOOL_REQUEST = "tool_request"
148+
TOOL_EXECUTION = "tool_execution"
149+
LLM_REQUEST = "llm_request"
150+
LLM_EXECUTION = "llm_execution"
134151

135152

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

398415

399416
# ---------------------------------------------------------------------------
400-
# The decorator
417+
# Decorators
401418
# ---------------------------------------------------------------------------
402419

403420
def command(
@@ -500,6 +517,65 @@ def sync_wrapper(raw_args: str) -> str | None:
500517
return decorate
501518

502519

520+
def middleware(kind: MiddlewareKind | str) -> Callable:
521+
"""Mark and instrument a synchronous Hermes middleware callback.
522+
523+
Known middleware phases are available through :class:`MiddlewareKind`.
524+
Non-empty strings are also accepted so plugins can adopt new Hermes phases
525+
without waiting for a kit release. Keyword arguments and return values pass
526+
through unchanged.
527+
"""
528+
if isinstance(kind, MiddlewareKind):
529+
middleware_kind = kind.value
530+
elif isinstance(kind, str) and kind.strip():
531+
middleware_kind = kind.strip()
532+
else:
533+
raise ValueError("middleware kind is required")
534+
535+
def decorate(fn: Callable) -> Callable:
536+
if inspect.iscoroutinefunction(fn):
537+
raise TypeError(
538+
"@middleware callbacks must be synchronous; "
539+
"hermes-agent does not await middleware callbacks"
540+
)
541+
log = logging.getLogger(fn.__module__ or "hermes_plugin_kit")
542+
543+
@functools.wraps(fn)
544+
def wrapper(**kwargs: Any) -> Any:
545+
started = time.perf_counter()
546+
context = _truncate(_safe_context(kwargs))
547+
log.debug(
548+
"%s middleware: invoked; context=%s",
549+
middleware_kind,
550+
context,
551+
)
552+
try:
553+
result = fn(**kwargs)
554+
except Exception as exc:
555+
log.warning(
556+
"%s middleware: callback raised; elapsed_ms=%.2f; "
557+
"error_type=%s; context=%s",
558+
middleware_kind,
559+
(time.perf_counter() - started) * 1000,
560+
type(exc).__name__,
561+
context,
562+
)
563+
raise
564+
log.info(
565+
"%s middleware: ok; elapsed_ms=%.2f; result=%s; context=%s",
566+
middleware_kind,
567+
(time.perf_counter() - started) * 1000,
568+
type(result).__name__,
569+
context,
570+
)
571+
return result
572+
573+
setattr(wrapper, _MIDDLEWARE_SPEC_ATTR, {"kind": middleware_kind})
574+
return wrapper
575+
576+
return decorate
577+
578+
503579
def hook(name: str) -> Callable:
504580
"""Mark and instrument a Hermes lifecycle hook callback.
505581
@@ -1240,7 +1316,7 @@ def register_plugin(
12401316
module: Any,
12411317
skills: tuple[PluginSkill, ...] | list[PluginSkill] = (),
12421318
) -> RegistrationSummary:
1243-
"""Register decorated commands, tools, hooks, and skills from *module*.
1319+
"""Register decorated commands, tools, middleware, hooks, and skills.
12441320
12451321
Unlike the backward-compatible :func:`register_all`, this lifecycle-level
12461322
entrypoint rejects distinct declarations that share a public name. Missing
@@ -1252,6 +1328,7 @@ def register_plugin(
12521328

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

1348+
middleware_spec = getattr(obj, _MIDDLEWARE_SPEC_ATTR, None)
1349+
if middleware_spec:
1350+
existing = middlewares.get(middleware_spec["kind"])
1351+
if existing is not None and existing is not obj:
1352+
raise ValueError(
1353+
f"duplicate middleware kind: {middleware_spec['kind']}"
1354+
)
1355+
middlewares[middleware_spec["kind"]] = obj
1356+
12711357
hook_spec = getattr(obj, _HOOK_SPEC_ATTR, None)
12721358
if hook_spec:
12731359
existing = hooks.get(hook_spec["name"])
@@ -1318,6 +1404,11 @@ def register_plugin(
13181404
_register_tool(ctx, obj, spec)
13191405
registered_tools.append(name)
13201406

1407+
registered_middlewares: list[str] = []
1408+
for kind in sorted(middlewares):
1409+
ctx.register_middleware(kind, middlewares[kind])
1410+
registered_middlewares.append(kind)
1411+
13211412
registered_hooks: list[str] = []
13221413
for name in sorted(hooks):
13231414
ctx.register_hook(name, hooks[name])
@@ -1335,15 +1426,17 @@ def register_plugin(
13351426
summary = RegistrationSummary(
13361427
commands=tuple(registered_commands),
13371428
tools=tuple(registered_tools),
1429+
middlewares=tuple(registered_middlewares),
13381430
hooks=tuple(registered_hooks),
13391431
skills=tuple(registered_skills),
13401432
skipped_optional_skills=tuple(skipped_skills),
13411433
)
13421434
log.info(
13431435
"hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; "
1344-
"hooks=%s; skills=%s; skipped_optional_skills=%s",
1436+
"middlewares=%s; hooks=%s; skills=%s; skipped_optional_skills=%s",
13451437
",".join(summary.commands) or "<none>",
13461438
",".join(summary.tools) or "<none>",
1439+
",".join(summary.middlewares) or "<none>",
13471440
",".join(summary.hooks) or "<none>",
13481441
",".join(summary.skills) or "<none>",
13491442
",".join(summary.skipped_optional_skills) or "<none>",

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ build-backend = "setuptools.build_meta"
88

99
[project]
1010
name = "hermes-plugin-kit"
11-
version = "0.4.0"
12-
description = "Convention-correct command and lifecycle registration for hermes-agent plugins."
11+
version = "0.5.0"
12+
description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins."
1313
readme = "README.md"
1414
requires-python = ">=3.11"
1515
license = { text = "MIT" }

0 commit comments

Comments
 (0)