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 @@ -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.<name>` 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`.
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` through
`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
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
Expand Down
81 changes: 81 additions & 0 deletions hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,13 @@ def register(ctx):

from __future__ import annotations

import copy
import functools
import importlib
import inspect
import json
import logging
import os
import re
import sys
import threading
Expand Down Expand Up @@ -82,6 +84,8 @@ def register(ctx):
"MiddlewareKind",
"PluginSkill",
"RegistrationSummary",
"load_plugin_config",
"configure_stderr_logging",
"register_all",
"build_schema",
"tool_name",
Expand Down Expand Up @@ -150,6 +154,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.<plugin_name>`` through
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:
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 copy.deepcopy(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 copy.deepcopy(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."""

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_kit.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import inspect
import json
import logging
import sys
import tempfile
import types
Expand Down Expand Up @@ -42,6 +43,71 @@ 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"])
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")
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",
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.