From 886f965cc52d68ddd2703add420b8ca057be3509 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Thu, 30 Jul 2026 10:46:55 -0500 Subject: [PATCH 1/2] feat(runtime): add effective plugin config helpers --- AGENTS.md | 4 ++ README.md | 27 ++++++++++++ hermes_plugin_kit/__init__.py | 80 +++++++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_kit.py | 62 +++++++++++++++++++++++++++ uv.lock | 2 +- 6 files changed, 175 insertions(+), 2 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 44a5842..31b87d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,10 @@ plugin. - Keep `@tool` and `register_all` backward compatible. Use `@command`, `@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin lifecycle registration. +- Use `load_plugin_config` for effective `plugins.` runtime settings; + 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. - 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 450ddf1..af5fcd7 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,33 @@ reported in the returned `RegistrationSummary`. Hermes supplies the plugin namespace, so a declared `temporal-awareness` skill from plugin `temporal-awareness` resolves as `temporal-awareness:temporal-awareness`. +## Runtime configuration and registration receipts + +Real Hermes `PluginManifest` objects do not carry profile runtime config. Use +the kit compatibility seam instead of reading `ctx.manifest.config` directly: + +```python +import logging + +from hermes_plugin_kit import configure_stderr_logging, load_plugin_config + +logger = logging.getLogger("memory-sync") + +def register(ctx): + configure_stderr_logging(logger, env_var="MEMORY_SYNC_LOG_STDERR") + config = load_plugin_config(ctx, "memory-sync") + # Register the lifecycle-gated surface from config. +``` + +`load_plugin_config` accepts a non-empty `manifest.config` for tests and older +hosts. On current Hermes it reads `plugins.` through +`load_config_readonly()` and returns a shallow copy so plugin code cannot +mutate Hermes' cached configuration. + +`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. + ## Tool names Hermes uses one global tool registry, and the agent loop intercepts core names diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 065e8bd..61a1e22 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -54,6 +54,7 @@ def register(ctx): import inspect import json import logging +import os import re import sys import threading @@ -82,6 +83,8 @@ def register(ctx): "MiddlewareKind", "PluginSkill", "RegistrationSummary", + "load_plugin_config", + "configure_stderr_logging", "register_all", "build_schema", "tool_name", @@ -150,6 +153,83 @@ class MiddlewareKind(str, Enum): LLM_EXECUTION = "llm_execution" +def load_plugin_config( + ctx: Any, + plugin_name: str, + *, + config_loader: Callable[[], dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Return one plugin's effective Hermes config without mutating host state. + + Current Hermes ``PluginManifest`` objects do not carry runtime profile + configuration. ``manifest.config`` remains a compatibility seam for tests + and older hosts; otherwise this reads ``plugins.`` through + Hermes' read-only effective config loader. A shallow copy prevents plugin + code from mutating the host config cache. + """ + clean_name = str(plugin_name or "").strip() + if not clean_name: + raise ValueError("plugin_name must be a non-empty string") + manifest = getattr(ctx, "manifest", None) + manifest_config = getattr(manifest, "config", None) + if isinstance(manifest_config, dict) and manifest_config: + return dict(manifest_config) + if config_loader is None: + try: + from hermes_cli.config import load_config_readonly + except (ImportError, AttributeError): + return {} + config_loader = load_config_readonly + try: + effective = config_loader() + except Exception as exc: + logging.getLogger(__name__).warning( + "hermes_plugin_kit: effective config read failed for %s: %s", + clean_name, + exc, + ) + return {} + plugins = effective.get("plugins") if isinstance(effective, dict) else None + plugin_config = plugins.get(clean_name) if isinstance(plugins, dict) else None + return dict(plugin_config) if isinstance(plugin_config, dict) else {} + + +def configure_stderr_logging( + logger: logging.Logger, + *, + env_var: str, + default: bool = False, +) -> logging.Handler | None: + """Enable one idempotent INFO stderr handler from an operator env flag.""" + if not isinstance(logger, logging.Logger): + raise TypeError("logger must be a logging.Logger") + clean_env_var = str(env_var or "").strip() + if not clean_env_var: + raise ValueError("env_var must be a non-empty string") + raw = os.environ.get(clean_env_var) + enabled = default if raw is None else raw.strip().lower() in { + "1", + "true", + "yes", + "on", + } + if not enabled: + return None + for handler in logger.handlers: + if getattr(handler, "_hpk_stderr_env_var", None) == clean_env_var: + return handler + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(logging.INFO) + handler.setFormatter( + logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s") + ) + handler._hpk_stderr_env_var = clean_env_var # type: ignore[attr-defined] + logger.addHandler(handler) + if logger.level == logging.NOTSET or logger.level > logging.INFO: + logger.setLevel(logging.INFO) + return handler + + class MediaType(str, Enum): """Hermes-agent ``send_message`` media directive modes.""" diff --git a/pyproject.toml b/pyproject.toml index 92b60da..738fbc6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-plugin-kit" -version = "0.5.0" +version = "0.6.0" description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_kit.py b/tests/test_kit.py index 09476d5..d3fbf00 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -3,6 +3,7 @@ import asyncio import inspect import json +import logging import sys import tempfile import types @@ -42,6 +43,67 @@ def register_skill(self, **kwargs) -> None: self.skills.append(kwargs) +class RuntimeCompatibilityTests(unittest.TestCase): + def test_manifest_config_remains_compatible_without_loading_host_config(self) -> None: + ctx = types.SimpleNamespace( + manifest=types.SimpleNamespace(config={"mode": "community"}) + ) + loader = Mock(side_effect=AssertionError("loader should not run")) + + result = hpk.load_plugin_config( + ctx, + "memory-sync", + config_loader=loader, + ) + + self.assertEqual(result, {"mode": "community"}) + loader.assert_not_called() + + def test_real_plugin_context_reads_named_effective_config(self) -> None: + ctx = types.SimpleNamespace(manifest=types.SimpleNamespace()) + effective = { + "plugins": { + "enabled": ["memory-sync"], + "memory-sync": {"authored_memory": {"enabled": True}}, + } + } + + result = hpk.load_plugin_config( + ctx, + "memory-sync", + config_loader=lambda: effective, + ) + + self.assertEqual(result, {"authored_memory": {"enabled": True}}) + self.assertIsNot(result, effective["plugins"]["memory-sync"]) + + def test_stderr_logging_is_operator_gated_and_idempotent(self) -> None: + logger = logging.getLogger("hpk-runtime-compatibility-test") + logger.handlers.clear() + logger.setLevel(logging.NOTSET) + try: + with patch.dict( + hpk.os.environ, + {"MEMORY_SYNC_LOG_STDERR": "true"}, + clear=False, + ): + first = hpk.configure_stderr_logging( + logger, + env_var="MEMORY_SYNC_LOG_STDERR", + ) + second = hpk.configure_stderr_logging( + logger, + env_var="MEMORY_SYNC_LOG_STDERR", + ) + + self.assertIsNotNone(first) + self.assertIs(first, second) + self.assertEqual(logger.handlers, [first]) + self.assertEqual(logger.level, logging.INFO) + finally: + logger.handlers.clear() + + @hpk.tool( toolset="messaging", namespace="sample", diff --git a/uv.lock b/uv.lock index b349fa4..bc1530a 100644 --- a/uv.lock +++ b/uv.lock @@ -87,7 +87,7 @@ wheels = [ [[package]] name = "hermes-plugin-kit" -version = "0.5.0" +version = "0.6.0" source = { editable = "." } [package.dev-dependencies] From 5ef9f4d1137553b86cc9d8e023a5081c451f9593 Mon Sep 17 00:00:00 2001 From: Offending Commit Date: Thu, 30 Jul 2026 10:50:47 -0500 Subject: [PATCH 2/2] fix(runtime): isolate nested plugin config --- README.md | 4 ++-- hermes_plugin_kit/__init__.py | 9 +++++---- tests/test_kit.py | 4 ++++ 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index af5fcd7..4d33878 100644 --- a/README.md +++ b/README.md @@ -247,8 +247,8 @@ def register(ctx): `load_plugin_config` accepts a non-empty `manifest.config` for tests and older hosts. On current Hermes it reads `plugins.` through -`load_config_readonly()` and returns a shallow copy so plugin code cannot -mutate Hermes' cached configuration. +`load_config_readonly()` and returns a deep copy so plugin code cannot mutate +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 diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index 61a1e22..4997746 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -49,6 +49,7 @@ def register(ctx): from __future__ import annotations +import copy import functools import importlib import inspect @@ -164,8 +165,8 @@ def load_plugin_config( Current Hermes ``PluginManifest`` objects do not carry runtime profile configuration. ``manifest.config`` remains a compatibility seam for tests and older hosts; otherwise this reads ``plugins.`` through - Hermes' read-only effective config loader. A shallow copy prevents plugin - code from mutating the host config cache. + Hermes' read-only effective config loader. A deep copy prevents plugin code + from mutating the host config cache through nested mappings or lists. """ clean_name = str(plugin_name or "").strip() if not clean_name: @@ -173,7 +174,7 @@ def load_plugin_config( manifest = getattr(ctx, "manifest", None) manifest_config = getattr(manifest, "config", None) if isinstance(manifest_config, dict) and manifest_config: - return dict(manifest_config) + return copy.deepcopy(manifest_config) if config_loader is None: try: from hermes_cli.config import load_config_readonly @@ -191,7 +192,7 @@ def load_plugin_config( return {} plugins = effective.get("plugins") if isinstance(effective, dict) else None plugin_config = plugins.get(clean_name) if isinstance(plugins, dict) else None - return dict(plugin_config) if isinstance(plugin_config, dict) else {} + return copy.deepcopy(plugin_config) if isinstance(plugin_config, dict) else {} def configure_stderr_logging( diff --git a/tests/test_kit.py b/tests/test_kit.py index d3fbf00..11722ff 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -76,6 +76,10 @@ def test_real_plugin_context_reads_named_effective_config(self) -> None: self.assertEqual(result, {"authored_memory": {"enabled": True}}) self.assertIsNot(result, effective["plugins"]["memory-sync"]) + self.assertIsNot( + result["authored_memory"], + effective["plugins"]["memory-sync"]["authored_memory"], + ) def test_stderr_logging_is_operator_gated_and_idempotent(self) -> None: logger = logging.getLogger("hpk-runtime-compatibility-test")