diff --git a/AGENTS.md b/AGENTS.md index 31b87d8..5c669f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/README.md b/README.md index 4d33878..65c6b5a 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 4997746..4d49b81 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -72,6 +72,7 @@ def register(ctx): "hook", "plugin_skill", "register_plugin", + "log_registration_summary", "invoke_host_tool", "deliver_media", "resolve_delivery_target", @@ -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 "", + ",".join(summary.tools) or "", + ",".join(summary.middlewares) or "", + ",".join(summary.hooks) or "", + ",".join(summary.skills) or "", + ",".join(summary.skipped_optional_skills) or "", + ) + + class MiddlewareKind(str, Enum): """Middleware phases currently supported by hermes-agent.""" @@ -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 "", - ",".join(summary.tools) or "", - ",".join(summary.middlewares) or "", - ",".join(summary.hooks) or "", - ",".join(summary.skills) or "", - ",".join(summary.skipped_optional_skills) or "", + 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 diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index 7531064..9d89c90 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -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=; tools=; " + "middlewares=; hooks=pre_llm_call; skills=probe; " + "skipped_optional_skills=", + cap.records[0].getMessage(), + ) self.assertEqual( manager.invoke_hook("pre_llm_call", message="gateway-shaped"), [{"context": "gateway-shaped"}], diff --git a/tests/test_kit.py b/tests/test_kit.py index 11722ff..6f20c0a 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -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)