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
25 changes: 19 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
# hermes-plugin-kit

> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct 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, 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 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
[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
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 @@ -131,13 +132,18 @@ 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
## Commands, 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
from hermes_plugin_kit import command, hook, 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)

@hook("pre_llm_call")
def inject_context(**kwargs):
Expand All @@ -156,6 +162,13 @@ def register(ctx):
return register_plugin(ctx, __name__, skills=SKILLS)
```

`@command` requires a bare lowercase kebab-case name without the leading slash.
Its handler receives the trailing command text unchanged and may return
`str | None` synchronously or asynchronously. The optional `args_hint` is
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.

`@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
134 changes: 130 additions & 4 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
"""hermes-plugin-kit — convention-correct tool registration for hermes-agent plugins.
"""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:
Expand Down Expand Up @@ -61,6 +61,7 @@ def register(ctx):

__all__ = [
"tool",
"command",
"hook",
"plugin_skill",
"register_plugin",
Expand All @@ -86,10 +87,12 @@ def register(ctx):
]

_SPEC_ATTR = "_hpk_tool_spec"
_COMMAND_SPEC_ATTR = "_hpk_command_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_]*$")
_COMMAND_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:-[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_-]+$")
Expand Down Expand Up @@ -127,6 +130,7 @@ class RegistrationSummary:
hooks: tuple[str, ...] = ()
skills: tuple[str, ...] = ()
skipped_optional_skills: tuple[str, ...] = ()
commands: tuple[str, ...] = ()


class MediaType(str, Enum):
Expand Down Expand Up @@ -396,6 +400,106 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]:
# The decorator
# ---------------------------------------------------------------------------

def command(
name: str,
description: str | None = None,
args_hint: str = "",
) -> Callable:
"""Mark and instrument a Hermes in-session slash command.

``name`` is the bare command name without a leading slash. The wrapped
handler receives the original ``raw_args`` string and returns ``str | None``.
Synchronous and asynchronous handlers preserve their native callable shape.
"""
if not isinstance(name, str) or not _COMMAND_NAME_RE.fullmatch(name):
raise ValueError(
"command name must be a bare lowercase kebab-case name matching "
f"{_COMMAND_NAME_RE.pattern!r}"
)
if description is not None and not isinstance(description, str):
raise TypeError("command description must be a string or None")
if not isinstance(args_hint, str):
raise TypeError("command args_hint must be a string")
clean_args_hint = args_hint.strip()

def decorate(fn: Callable) -> Callable:
explicit_description = (description or "").strip()
doc = explicit_description or (inspect.getdoc(fn) or "").strip()
if not doc:
raise ValueError(
f"@command {name!r}: a description is required "
"(docstring or description=)."
)
log = logging.getLogger(fn.__module__ or "hermes_plugin_kit")

def log_invocation(raw_args: str) -> float:
started = time.perf_counter()
try:
args_chars = len(raw_args)
except TypeError:
args_chars = len(str(raw_args))
log.debug("%s: invoked; args_chars=%d", name, args_chars)
return started

def log_failure(started: float, exc: Exception) -> None:
log.warning(
"%s: handler raised; elapsed_ms=%.2f; error_type=%s",
name,
(time.perf_counter() - started) * 1000,
type(exc).__name__,
)

def log_success(started: float, result: Any) -> None:
log.info(
"%s: ok; elapsed_ms=%.2f; result=%s",
name,
(time.perf_counter() - started) * 1000,
type(result).__name__,
)

if inspect.iscoroutinefunction(fn):

@functools.wraps(fn)
async def async_wrapper(raw_args: str) -> str | None:
started = log_invocation(raw_args)
try:
result = await fn(raw_args)
except Exception as exc:
log_failure(started, exc)
raise
log_success(started, result)
return result

wrapper = async_wrapper
else:

@functools.wraps(fn)
def sync_wrapper(raw_args: str) -> str | None:
started = log_invocation(raw_args)
try:
result = fn(raw_args)
except Exception as exc:
log_failure(started, exc)
raise
log_success(started, result)
return result

wrapper = sync_wrapper

setattr(
wrapper,
_COMMAND_SPEC_ATTR,
{
"name": name,
"description": doc,
"args_hint": clean_args_hint,
},
)
return wrapper

return decorate


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

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

Unlike the backward-compatible :func:`register_all`, this lifecycle-level
entrypoint rejects distinct declarations that share a public name. Missing
Expand All @@ -1146,9 +1250,17 @@ def register_plugin(
module = sys.modules[module]
log = logging.getLogger(getattr(module, "__name__", "hermes_plugin_kit"))

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

tool_spec = getattr(obj, _SPEC_ATTR, None)
if tool_spec:
existing = tools.get(tool_spec["name"])
Expand Down Expand Up @@ -1187,6 +1299,18 @@ def register_plugin(
)
skipped_skills.append(name)

registered_commands: list[str] = []
for name in sorted(commands):
obj = commands[name]
spec = getattr(obj, _COMMAND_SPEC_ATTR)
ctx.register_command(
name=spec["name"],
handler=obj,
description=spec["description"],
args_hint=spec["args_hint"],
)
registered_commands.append(name)

registered_tools: list[str] = []
for name in sorted(tools):
obj = tools[name]
Expand All @@ -1209,14 +1333,16 @@ def register_plugin(
registered_skills.append(skill.name)

summary = RegistrationSummary(
commands=tuple(registered_commands),
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",
"hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; "
"hooks=%s; skills=%s; skipped_optional_skills=%s",
",".join(summary.commands) or "<none>",
",".join(summary.tools) or "<none>",
",".join(summary.hooks) or "<none>",
",".join(summary.skills) or "<none>",
Expand Down
10 changes: 5 additions & 5 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.3.0"
description = "Convention-correct lifecycle registration for hermes-agent plugins."
version = "0.4.0"
description = "Convention-correct command and lifecycle registration for hermes-agent plugins."
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
Expand All @@ -20,10 +20,10 @@ dependencies = []
[project.urls]
Repository = "https://github.com/offendingcommit/hermes-plugin-kit"

# Runtime stays dependency-free. PyYAML is dev-only: the hermes contract tests
# import hermes_cli.plugins, which transitively needs yaml. Skipped without it.
# Runtime stays dependency-free. The hermes contract tests import current
# upstream source, whose plugin and gateway seams transitively need these.
[dependency-groups]
dev = ["pyyaml"]
dev = ["pyyaml", "requests==2.33.0"]

[tool.setuptools]
packages = ["hermes_plugin_kit"]
39 changes: 39 additions & 0 deletions tests/test_hermes_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ def _try():
PluginManager,
PluginManifest,
VALID_HOOKS,
resolve_plugin_command_result,
)
from tools.registry import registry # type: ignore

Expand All @@ -64,6 +65,7 @@ def _try():
PluginManager=PluginManager,
PluginManifest=PluginManifest,
VALID_HOOKS=set(VALID_HOOKS),
resolve_plugin_command_result=resolve_plugin_command_result,
registry=registry,
)

Expand Down Expand Up @@ -115,6 +117,15 @@ def hpk_contract_probe(args, **kwargs):
_SPEC = getattr(hpk_contract_probe, "_hpk_tool_spec")


@hpk.command(
"hpk-contract-probe",
description="Probe command registration.",
args_hint="<value>",
)
async def hpk_command_contract_probe(raw_args):
return f"command:{raw_args}"


@unittest.skipUnless(_REAL is not None, "hermes-agent source not importable")
class HermesContractTests(unittest.TestCase):
"""Validate the kit's output against genuine hermes-agent runtime APIs."""
Expand Down Expand Up @@ -199,7 +210,35 @@ def contract_hook(**kwargs):
)
self.assertEqual(manager.find_plugin_skill("contract-plugin:probe"), path)

def test_command_registers_and_dispatches_through_real_plugin_context(self) -> None:
manager = _REAL.PluginManager()
manifest = _REAL.PluginManifest(name="contract-plugin")
ctx = _REAL.PluginContext(manifest, manager)
module = types.ModuleType("contract_command_plugin")
module.hpk_command_contract_probe = hpk_command_contract_probe

summary = hpk.register_plugin(ctx, module)

self.assertEqual(summary.commands, ("hpk-contract-probe",))
entry = manager._plugin_commands["hpk-contract-probe"]
self.assertEqual(entry["description"], "Probe command registration.")
self.assertEqual(entry["args_hint"], "<value>")
result = entry["handler"]("exact raw args")
self.assertEqual(
_REAL.resolve_plugin_command_result(result),
"command:exact raw args",
)

def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None:
command_sig = inspect.signature(_REAL.PluginContext.register_command)
command_sig.bind(
None,
name="probe-command",
handler=lambda raw_args: raw_args,
description="Probe command.",
args_hint="<value>",
)

hook_sig = inspect.signature(_REAL.PluginContext.register_hook)
hook_sig.bind(None, "pre_llm_call", lambda **kwargs: None)

Expand Down
Loading