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: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ plugin.
current Hermes `PluginManifest` objects do not expose profile config. Use
`configure_stderr_logging` for operator-gated registration receipts instead
of rebuilding per-plugin stderr handlers.
- Keep lifecycle registration receipts centralized in
`log_registration_summary`; preserve its stable field order and actual
command, tool, middleware, hook, skill, and skipped optional skill names.
`register_plugin` must emit exactly one receipt through that helper.
- 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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,12 @@ Hermes' cached configuration through nested values.
`configure_stderr_logging` installs one idempotent INFO handler only when its
operator-owned environment flag is enabled. This makes registration receipts
visible in container logs without forcing verbose plugin logging everywhere.
`register_plugin` emits exactly one stable INFO receipt through the public
`log_registration_summary(logger, plugin_name, summary)` helper. The receipt
uses the Hermes manifest name when available and lists the actual registered
command, tool, middleware, hook, and skill names, plus skipped optional skills.
Consumers with a custom registration path can call the same helper with their
own `RegistrationSummary` instead of inventing a second receipt format.

## Tool names

Expand Down Expand Up @@ -384,6 +390,9 @@ include:
- `INFO`: successful completion with `elapsed_ms` and whether the handler returned
a dictionary-like result or an already-encoded string.
- `INFO`: a registration summary from `register_all`, including count and names.
- `INFO`: one stable lifecycle receipt from `register_plugin`, including the
plugin name and actual command, tool, middleware, hook, skill, and skipped
optional skill names.

The kit never logs handler result payloads. Keys containing `token`, `secret`,
`password`, `passwd`, `api_key`, `apikey`, or `auth` are replaced with `***` at
Expand Down
40 changes: 31 additions & 9 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ def register(ctx):
"hook",
"plugin_skill",
"register_plugin",
"log_registration_summary",
"invoke_host_tool",
"deliver_media",
"resolve_delivery_target",
Expand Down Expand Up @@ -145,6 +146,31 @@ class RegistrationSummary:
middlewares: tuple[str, ...] = ()


def log_registration_summary(
logger: logging.Logger,
plugin_name: str,
summary: RegistrationSummary,
) -> None:
"""Emit one stable INFO receipt for a completed lifecycle registration."""
clean_plugin_name = str(plugin_name or "").strip()
if not clean_plugin_name:
raise ValueError("plugin_name must be a non-empty string")
if not isinstance(summary, RegistrationSummary):
raise TypeError("summary must be a RegistrationSummary")
logger.info(
"hermes_plugin_kit: registered plugin lifecycle; plugin=%s; "
"commands=%s; tools=%s; middlewares=%s; hooks=%s; skills=%s; "
"skipped_optional_skills=%s",
clean_plugin_name,
",".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>",
)


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

Expand Down Expand Up @@ -1512,14 +1538,10 @@ def register_plugin(
skills=tuple(registered_skills),
skipped_optional_skills=tuple(skipped_skills),
)
log.info(
"hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%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>",
plugin_name = (
getattr(getattr(ctx, "manifest", None), "name", None)
or getattr(module, "__name__", None)
or "hermes_plugin_kit"
)
log_registration_summary(log, plugin_name, summary)
return summary
18 changes: 13 additions & 5 deletions tests/test_hermes_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,12 +214,20 @@ def contract_hook(**kwargs):
with TemporaryDirectory() as tmp:
path = Path(tmp) / "SKILL.md"
path.write_text("# Contract skill\n")
summary = hpk.register_plugin(
ctx,
module,
skills=(hpk.plugin_skill("probe", path, "Contract probe"),),
)
with self.assertLogs("contract_lifecycle_plugin", level="INFO") as cap:
summary = hpk.register_plugin(
ctx,
module,
skills=(hpk.plugin_skill("probe", path, "Contract probe"),),
)
self.assertEqual(summary.hooks, ("pre_llm_call",))
self.assertEqual(len(cap.records), 1)
self.assertIn(
"plugin=contract-plugin; commands=<none>; tools=<none>; "
"middlewares=<none>; hooks=pre_llm_call; skills=probe; "
"skipped_optional_skills=<none>",
cap.records[0].getMessage(),
)
self.assertEqual(
manager.invoke_hook("pre_llm_call", message="gateway-shaped"),
[{"context": "gateway-shaped"}],
Expand Down
38 changes: 38 additions & 0 deletions tests/test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,44 @@ def request_middleware(**kwargs):
self.assertIn("hooks=pre_llm_call", "\n".join(cap.output))
self.assertIn("skills=temporal-awareness", "\n".join(cap.output))

def test_logs_one_stable_registration_receipt_with_actual_names(self) -> None:
logger = logging.getLogger("registration-receipt-test")
summary = hpk.RegistrationSummary(
commands=("valdris-status",),
tools=("sample_read_thread",),
middlewares=("tool_request",),
hooks=("pre_llm_call",),
skills=("temporal-awareness",),
skipped_optional_skills=("missing-optional",),
)

with self.assertLogs(logger, level="INFO") as cap:
hpk.log_registration_summary(logger, "sample-plugin", summary)

self.assertEqual(len(cap.records), 1)
self.assertEqual(
cap.records[0].getMessage(),
"hermes_plugin_kit: registered plugin lifecycle; "
"plugin=sample-plugin; commands=valdris-status; "
"tools=sample_read_thread; middlewares=tool_request; "
"hooks=pre_llm_call; skills=temporal-awareness; "
"skipped_optional_skills=missing-optional",
)

def test_register_plugin_uses_public_registration_summary_logger(self) -> None:
ctx = FakePluginCtx()
ctx.manifest = types.SimpleNamespace(name="sample-plugin")
module = self._module()

with patch.object(hpk, "log_registration_summary") as log_summary:
summary = hpk.register_plugin(ctx, module)

log_summary.assert_called_once_with(
logging.getLogger("sample_plugin"),
"sample-plugin",
summary,
)

def test_missing_optional_skill_is_skipped_with_warning(self) -> None:
ctx = FakePluginCtx()
skill = hpk.plugin_skill("optional", "/missing/SKILL.md", "Optional", optional=True)
Expand Down