Skip to content

Commit 39cd892

Browse files
Merge pull request #12 from offendingcommit/codex/feat-runtime-plugin-config
feat(runtime): add effective plugin config helpers
2 parents ab70104 + 5ef9f4d commit 39cd892

6 files changed

Lines changed: 180 additions & 2 deletions

File tree

AGENTS.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ plugin.
1010
- Keep `@tool` and `register_all` backward compatible. Use `@command`,
1111
`@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin
1212
lifecycle registration.
13+
- Use `load_plugin_config` for effective `plugins.<name>` runtime settings;
14+
current Hermes `PluginManifest` objects do not expose profile config. Use
15+
`configure_stderr_logging` for operator-gated registration receipts instead
16+
of rebuilding per-plugin stderr handlers.
1317
- Use `invoke_host_tool` for host-managed capabilities such as `send_message`;
1418
do not assume every Hermes capability is registered in `tools.registry`.
1519
Nested host calls must remain visible to `pre_tool_call` and `post_tool_call`.

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,33 @@ reported in the returned `RegistrationSummary`. Hermes supplies the plugin
227227
namespace, so a declared `temporal-awareness` skill from plugin
228228
`temporal-awareness` resolves as `temporal-awareness:temporal-awareness`.
229229

230+
## Runtime configuration and registration receipts
231+
232+
Real Hermes `PluginManifest` objects do not carry profile runtime config. Use
233+
the kit compatibility seam instead of reading `ctx.manifest.config` directly:
234+
235+
```python
236+
import logging
237+
238+
from hermes_plugin_kit import configure_stderr_logging, load_plugin_config
239+
240+
logger = logging.getLogger("memory-sync")
241+
242+
def register(ctx):
243+
configure_stderr_logging(logger, env_var="MEMORY_SYNC_LOG_STDERR")
244+
config = load_plugin_config(ctx, "memory-sync")
245+
# Register the lifecycle-gated surface from config.
246+
```
247+
248+
`load_plugin_config` accepts a non-empty `manifest.config` for tests and older
249+
hosts. On current Hermes it reads `plugins.<name>` through
250+
`load_config_readonly()` and returns a deep copy so plugin code cannot mutate
251+
Hermes' cached configuration through nested values.
252+
253+
`configure_stderr_logging` installs one idempotent INFO handler only when its
254+
operator-owned environment flag is enabled. This makes registration receipts
255+
visible in container logs without forcing verbose plugin logging everywhere.
256+
230257
## Tool names
231258

232259
Hermes uses one global tool registry, and the agent loop intercepts core names

hermes_plugin_kit/__init__.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,13 @@ def register(ctx):
4949

5050
from __future__ import annotations
5151

52+
import copy
5253
import functools
5354
import importlib
5455
import inspect
5556
import json
5657
import logging
58+
import os
5759
import re
5860
import sys
5961
import threading
@@ -82,6 +84,8 @@ def register(ctx):
8284
"MiddlewareKind",
8385
"PluginSkill",
8486
"RegistrationSummary",
87+
"load_plugin_config",
88+
"configure_stderr_logging",
8589
"register_all",
8690
"build_schema",
8791
"tool_name",
@@ -150,6 +154,83 @@ class MiddlewareKind(str, Enum):
150154
LLM_EXECUTION = "llm_execution"
151155

152156

157+
def load_plugin_config(
158+
ctx: Any,
159+
plugin_name: str,
160+
*,
161+
config_loader: Callable[[], dict[str, Any]] | None = None,
162+
) -> dict[str, Any]:
163+
"""Return one plugin's effective Hermes config without mutating host state.
164+
165+
Current Hermes ``PluginManifest`` objects do not carry runtime profile
166+
configuration. ``manifest.config`` remains a compatibility seam for tests
167+
and older hosts; otherwise this reads ``plugins.<plugin_name>`` through
168+
Hermes' read-only effective config loader. A deep copy prevents plugin code
169+
from mutating the host config cache through nested mappings or lists.
170+
"""
171+
clean_name = str(plugin_name or "").strip()
172+
if not clean_name:
173+
raise ValueError("plugin_name must be a non-empty string")
174+
manifest = getattr(ctx, "manifest", None)
175+
manifest_config = getattr(manifest, "config", None)
176+
if isinstance(manifest_config, dict) and manifest_config:
177+
return copy.deepcopy(manifest_config)
178+
if config_loader is None:
179+
try:
180+
from hermes_cli.config import load_config_readonly
181+
except (ImportError, AttributeError):
182+
return {}
183+
config_loader = load_config_readonly
184+
try:
185+
effective = config_loader()
186+
except Exception as exc:
187+
logging.getLogger(__name__).warning(
188+
"hermes_plugin_kit: effective config read failed for %s: %s",
189+
clean_name,
190+
exc,
191+
)
192+
return {}
193+
plugins = effective.get("plugins") if isinstance(effective, dict) else None
194+
plugin_config = plugins.get(clean_name) if isinstance(plugins, dict) else None
195+
return copy.deepcopy(plugin_config) if isinstance(plugin_config, dict) else {}
196+
197+
198+
def configure_stderr_logging(
199+
logger: logging.Logger,
200+
*,
201+
env_var: str,
202+
default: bool = False,
203+
) -> logging.Handler | None:
204+
"""Enable one idempotent INFO stderr handler from an operator env flag."""
205+
if not isinstance(logger, logging.Logger):
206+
raise TypeError("logger must be a logging.Logger")
207+
clean_env_var = str(env_var or "").strip()
208+
if not clean_env_var:
209+
raise ValueError("env_var must be a non-empty string")
210+
raw = os.environ.get(clean_env_var)
211+
enabled = default if raw is None else raw.strip().lower() in {
212+
"1",
213+
"true",
214+
"yes",
215+
"on",
216+
}
217+
if not enabled:
218+
return None
219+
for handler in logger.handlers:
220+
if getattr(handler, "_hpk_stderr_env_var", None) == clean_env_var:
221+
return handler
222+
handler = logging.StreamHandler(sys.stderr)
223+
handler.setLevel(logging.INFO)
224+
handler.setFormatter(
225+
logging.Formatter("%(asctime)s %(levelname)s %(name)s %(message)s")
226+
)
227+
handler._hpk_stderr_env_var = clean_env_var # type: ignore[attr-defined]
228+
logger.addHandler(handler)
229+
if logger.level == logging.NOTSET or logger.level > logging.INFO:
230+
logger.setLevel(logging.INFO)
231+
return handler
232+
233+
153234
class MediaType(str, Enum):
154235
"""Hermes-agent ``send_message`` media directive modes."""
155236

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ build-backend = "setuptools.build_meta"
88

99
[project]
1010
name = "hermes-plugin-kit"
11-
version = "0.5.0"
11+
version = "0.6.0"
1212
description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins."
1313
readme = "README.md"
1414
requires-python = ">=3.11"

tests/test_kit.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import asyncio
44
import inspect
55
import json
6+
import logging
67
import sys
78
import tempfile
89
import types
@@ -42,6 +43,71 @@ def register_skill(self, **kwargs) -> None:
4243
self.skills.append(kwargs)
4344

4445

46+
class RuntimeCompatibilityTests(unittest.TestCase):
47+
def test_manifest_config_remains_compatible_without_loading_host_config(self) -> None:
48+
ctx = types.SimpleNamespace(
49+
manifest=types.SimpleNamespace(config={"mode": "community"})
50+
)
51+
loader = Mock(side_effect=AssertionError("loader should not run"))
52+
53+
result = hpk.load_plugin_config(
54+
ctx,
55+
"memory-sync",
56+
config_loader=loader,
57+
)
58+
59+
self.assertEqual(result, {"mode": "community"})
60+
loader.assert_not_called()
61+
62+
def test_real_plugin_context_reads_named_effective_config(self) -> None:
63+
ctx = types.SimpleNamespace(manifest=types.SimpleNamespace())
64+
effective = {
65+
"plugins": {
66+
"enabled": ["memory-sync"],
67+
"memory-sync": {"authored_memory": {"enabled": True}},
68+
}
69+
}
70+
71+
result = hpk.load_plugin_config(
72+
ctx,
73+
"memory-sync",
74+
config_loader=lambda: effective,
75+
)
76+
77+
self.assertEqual(result, {"authored_memory": {"enabled": True}})
78+
self.assertIsNot(result, effective["plugins"]["memory-sync"])
79+
self.assertIsNot(
80+
result["authored_memory"],
81+
effective["plugins"]["memory-sync"]["authored_memory"],
82+
)
83+
84+
def test_stderr_logging_is_operator_gated_and_idempotent(self) -> None:
85+
logger = logging.getLogger("hpk-runtime-compatibility-test")
86+
logger.handlers.clear()
87+
logger.setLevel(logging.NOTSET)
88+
try:
89+
with patch.dict(
90+
hpk.os.environ,
91+
{"MEMORY_SYNC_LOG_STDERR": "true"},
92+
clear=False,
93+
):
94+
first = hpk.configure_stderr_logging(
95+
logger,
96+
env_var="MEMORY_SYNC_LOG_STDERR",
97+
)
98+
second = hpk.configure_stderr_logging(
99+
logger,
100+
env_var="MEMORY_SYNC_LOG_STDERR",
101+
)
102+
103+
self.assertIsNotNone(first)
104+
self.assertIs(first, second)
105+
self.assertEqual(logger.handlers, [first])
106+
self.assertEqual(logger.level, logging.INFO)
107+
finally:
108+
logger.handlers.clear()
109+
110+
45111
@hpk.tool(
46112
toolset="messaging",
47113
namespace="sample",

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)