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
30 changes: 26 additions & 4 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,34 @@ jobs:
- name: Run unittest suite
run: make test

# Validate the kit against the *real* hermes-agent source so it can't silently
# drift from upstream. Checks out NousResearch/hermes-agent and points the
# contract tests at it; explicit checkout import or layout drift fails this
# job so an upstream contract break cannot become a misleading skipped green.
# Amber admission gates on the exact deployed Hermes fork contract. Keep the
# immutable revision aligned with infra's candidate image/source receipt.
hermes-deployed-context-engine-contract:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
with:
repository: offendingcommit/hermes-agent
ref: d3b1cfe9a80531e0682b1e66752e04cea29d5d4a
path: .hermes-agent-deployed
- uses: astral-sh/setup-uv@v8.2.0
with:
python-version: "3.11"
enable-cache: true
- name: Run context-engine contract against deployed Hermes revision
env:
HERMES_AGENT_PATH: ${{ github.workspace }}/.hermes-agent-deployed
run: |
test "$(git -C "$HERMES_AGENT_PATH" rev-parse HEAD)" = \
"d3b1cfe9a80531e0682b1e66752e04cea29d5d4a"
uv run python -m unittest tests.test_context_engine_contract -v

# Track forward drift against upstream main without making Amber depend on
# APIs that are newer than its exact deployed host.
hermes-contract:
runs-on: ubuntu-latest
continue-on-error: true
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v6
Expand Down
5 changes: 5 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@ plugin.
- Keep stateful Hermes provider ABCs as provider instances: register memory,
image-generation, and video-generation providers through their specialized
contexts instead of decorating provider methods as general plugin surfaces.
- Register at most one real Hermes `ContextEngine` through `register_plugin`.
Finish every registrar, identity, type, declaration, and provider preflight
before submitting it or mutating another host registry. Keep engine schemas
and recovery dispatch on `get_tool_schemas` / `handle_tool_call`; do not
duplicate native engine tools through `@tool`.
- Redact secret-looking values in logs and avoid logging full untrusted payloads.
- Use `uv` and the Makefile for local development:
`make install`, `make test`, `make test-one T=tests.test_kit.SchemaConventionTests`,
Expand Down
35 changes: 32 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@
[hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate an
in-session slash command or terminal CLI subcommand with `@command`, a tool
with `@tool`, or a lifecycle callback with `@middleware` or `@hook`, then use
`register_plugin` to register commands, tools, middleware, hooks, and
plugin-owned skills together. Existing tool-only plugins can keep using
`register_plugin` to register commands, tools, middleware, hooks, plugin-owned
skills, and an optional Hermes context-engine instance together. Existing
tool-only plugins can keep using
`register_all` for backward compatibility, but new and migrated plugins should
use `register_plugin` so every surface and the lifecycle receipt share one
contract. The LLM-facing schema, argument validation, structured logging, and
Expand Down Expand Up @@ -261,6 +262,30 @@ single registration receipt; module registration keeps the existing manifest
and module-derived defaults. Duplicate detection and returned
`RegistrationSummary` inventories are identical for both declaration forms.

Hermes context engines use a singular native registration path:

```python
return register_plugin(
ctx,
(),
context_engine=ContinuityEngine(config),
plugin_name="continuity",
)
```

The kit preflights `ctx.register_context_engine`, the engine's non-empty
`name`, and the real `agent.context_engine.ContextEngine` type before mutating
any host registry. An affirmative host result is reported as `accepted`; older
hosts that return no result are truthfully reported as `declared/submitted`.
An explicit rejection—most commonly a second engine—fails registration before
tools, hooks, skills, or providers are registered.

Context-engine recovery operations are native engine tools. Declare their
schemas through `ContextEngine.get_tool_schemas()` and dispatch them through
`handle_tool_call()`. Do **not** duplicate them with `@tool`: ordinary plugin
registration would shadow Hermes' engine dispatch, which supplies the active
message context and other engine lifecycle state.

When a plugin exposes surfaces that must be enabled together, resolve a named
capability before calling `register_plugin` instead of making every deployment
copy the capability's members:
Expand Down Expand Up @@ -534,6 +559,10 @@ Image and video providers run through the general `PluginContext`. The kit does
not decorate provider methods or replace the `MemoryProvider`,
`ImageGenProvider`, or `VideoGenProvider` contracts.

Context engines likewise remain instances of Hermes' `ContextEngine` ABC, but
are singular rather than a provider collection. Their native schemas and
`handle_tool_call` own recovery-tool dispatch.

The ordinary path remains Hermes' host-managed `send_message`. Because that
host contract does not currently expose Telegram's `has_spoiler`, only
`spoiler=True` uses the kit's narrow Telegram extension. The extension accepts
Expand Down Expand Up @@ -608,7 +637,7 @@ include:
- `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.
optional skill names, plus the context-engine name and registration state.

The kit never logs handler result payloads. Keys containing `token`, `secret`,
`password`, `passwd`, `api_key`, `apikey`, or `auth` are replaced with `***` at
Expand Down
47 changes: 46 additions & 1 deletion hermes_plugin_kit/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,8 @@ class RegistrationSummary:
video_gen_providers: tuple[str, ...] = ()
cli_commands: tuple[str, ...] = ()
capabilities: tuple[str, ...] = ()
context_engine: str | None = None
context_engine_registration: str | None = None


class CommandType(str, Enum):
Expand All @@ -441,7 +443,8 @@ def log_registration_summary(
"commands=%s; cli_commands=%s; tools=%s; middlewares=%s; hooks=%s; "
"skills=%s; skipped_optional_skills=%s; memory_providers=%s; "
"image_gen_providers=%s; "
"video_gen_providers=%s; capabilities=%s",
"video_gen_providers=%s; capabilities=%s; context_engine=%s; "
"context_engine_registration=%s",
clean_plugin_name,
",".join(summary.commands) or "<none>",
",".join(summary.cli_commands) or "<none>",
Expand All @@ -454,6 +457,8 @@ def log_registration_summary(
",".join(summary.image_gen_providers) or "<none>",
",".join(summary.video_gen_providers) or "<none>",
",".join(summary.capabilities) or "<none>",
summary.context_engine or "<none>",
summary.context_engine_registration or "<none>",
)


Expand Down Expand Up @@ -2078,6 +2083,7 @@ def register_plugin(
memory_providers: tuple[Any, ...] | list[Any] = (),
image_gen_providers: tuple[Any, ...] | list[Any] = (),
video_gen_providers: tuple[Any, ...] | list[Any] = (),
context_engine: Any | None = None,
capabilities: tuple[str, ...] | list[str] = (),
plugin_name: str | None = None,
logger: logging.Logger | None = None,
Expand All @@ -2090,6 +2096,8 @@ def register_plugin(
Pass a module (or loaded module name) to discover all declarations, or an
iterable of decorated callables to register only a runtime-active subset.
Provider instances retain their Hermes ABC contracts and are not decorated.
A context engine retains Hermes' native schema and dispatch path; it is not
translated into kit-decorated tools.
"""
if isinstance(module, str):
module = sys.modules[module]
Expand Down Expand Up @@ -2270,6 +2278,41 @@ def register_plugin(
else (None, ())
)

context_engine_registrar = None
context_engine_name = None
if context_engine is not None:
context_engine_registrar = getattr(ctx, "register_context_engine", None)
if not callable(context_engine_registrar):
raise RuntimeError(
"this Hermes plugin context does not support register_context_engine()"
)
context_engine_name = getattr(context_engine, "name", None)
if not isinstance(context_engine_name, str) or not context_engine_name.strip():
raise ValueError("context engine requires a non-empty name")
context_engine_name = context_engine_name.strip()
try:
from agent.context_engine import ContextEngine
except (ImportError, AttributeError) as exc:
raise RuntimeError(
"hermes-agent ContextEngine is unavailable; context engine registration refused"
) from exc
if not isinstance(context_engine, ContextEngine):
raise TypeError(
"context_engine must be an instance of agent.context_engine.ContextEngine"
)

context_engine_registration = None
if context_engine_registrar is not None:
registration_result = context_engine_registrar(context_engine)
if registration_result is False:
raise RuntimeError(
f"Hermes rejected context engine {context_engine_name!r}; "
"only one context engine may be registered"
)
context_engine_registration = (
"accepted" if registration_result is True else "declared/submitted"
)

registered_slash_commands: list[str] = []
for name in sorted(slash_commands):
obj = slash_commands[name]
Expand Down Expand Up @@ -2347,6 +2390,8 @@ def register_plugin(
image_gen_providers=tuple(registered_image_gen_providers),
video_gen_providers=tuple(registered_video_gen_providers),
capabilities=resolved_capabilities,
context_engine=context_engine_name,
context_engine_registration=context_engine_registration,
)
log_registration_summary(log, resolved_plugin_name, summary)
return summary
16 changes: 12 additions & 4 deletions skills/hermes-plugins/references/plugin-kit.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,14 @@ guidance, not a second implementation specification.
| Request or execution middleware | `@middleware`, `MiddlewareKind` | `register_plugin` | Callback is synchronous; request phases replace payloads, execution phases call single-use `next_call`. |
| Lifecycle hook | `@hook` | `register_plugin` | Hermes kwargs and return values pass through; exceptions are re-raised for Hermes isolation. |
| Plugin-owned skill | `plugin_skill` | `register_plugin(..., skills=...)` | Hermes adds the plugin namespace; missing required skills fail, optional skills warn and skip. |
| Context engine | Hermes `ContextEngine` instance | `register_plugin(..., context_engine=...)` | Singular native engine registration; schemas and recovery dispatch stay in `get_tool_schemas()` / `handle_tool_call()`, never duplicated with `@tool`. |
| Host-managed call | `invoke_host_tool` | None | Use for supported non-registry capabilities such as `send_message`; pre/post-tool hooks remain active. |
| Local media delivery | `MediaPayload`, `MediaType`, `deliver_media` | Consumer registers suppression hooks | File must be absolute, present, and non-empty; `origin` resolves from task-local Hermes context. |
| Correlated lifecycle receipt | `ObservabilityEvent`, `log_observability_event`, `new_correlation_id`, `credential_identity_hash` | None | Emits bounded, redacted JSON through the supplied local logger; consumers provide domain stages and never place credentials in event fields. |

`RegistrationSummary` reports commands, tools, middleware, hooks, skills, and
skipped optional skills registered by `register_plugin`.
`RegistrationSummary` reports commands, tools, middleware, hooks, skills,
skipped optional skills, and the declared context engine plus its truthful host
registration state.

## What The Kit Owns

Expand All @@ -54,8 +56,10 @@ Use the direct Hermes API or the specialized upstream plugin interface for:
- `ctx.register_cli_command`, `ctx.dispatch_tool`, `ctx.inject_message`, or
`ctx.llm.complete*`.
- Gateway platform adapters.
- Memory, context-engine, model, image, video, browser, web-search, secret
source, desktop, or dashboard provider interfaces.
- Memory, model, image, video, browser, web-search, secret source, desktop, or
dashboard provider interfaces. The one supported context-engine seam is the
singular typed `register_plugin` adapter; the kit does not abstract engine
policy, schemas, or tool dispatch.
- Plugin discovery, enablement, platform toolset selection, or core agent-loop behavior.

Do not add a kit abstraction merely to hide one direct `PluginContext` call.
Expand All @@ -74,6 +78,10 @@ making structurally impossible.
5. Keep `plugin.yaml`, auth gates, toolsets, docs, and registration tests in parity.
6. Run the consumer suite and a real Hermes contract test when runtime APIs matter.

For a context-engine consumer, pass exactly one real `ContextEngine` instance.
Keep recovery operations on the native engine schema/handler path; decorating
the same operations with `@tool` shadows the active-context-aware dispatch.

## Failure Traps

- A top-level JSON Schema `properties` field makes the model see empty arguments.
Expand Down
102 changes: 102 additions & 0 deletions tests/test_context_engine_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
"""Focused contract for Amber's exact deployed Hermes context-engine seam."""

from __future__ import annotations

import copy
import json
import os
import subprocess
import sys
import unittest
from pathlib import Path

import hermes_plugin_kit as hpk


def _import_deployed_host():
root_value = os.environ.get("HERMES_AGENT_PATH")
if not root_value:
return None
root = Path(root_value)
if not (root / "hermes_cli" / "plugins.py").exists():
raise FileNotFoundError(
f"HERMES_AGENT_PATH has no hermes_cli/plugins.py: {root}"
)
sys.path.insert(0, str(root))
from agent.context_engine import ContextEngine # type: ignore
from hermes_cli.plugins import ( # type: ignore
PluginContext,
PluginManager,
PluginManifest,
)

return ContextEngine, PluginContext, PluginManager, PluginManifest


_HOST = _import_deployed_host()


@unittest.skipUnless(_HOST is not None, "exact Hermes host source not configured")
class DeployedContextEngineContractTests(unittest.TestCase):
@classmethod
def setUpClass(cls) -> None:
root = os.environ["HERMES_AGENT_PATH"]
commit = subprocess.run(
["git", "-C", root, "rev-parse", "HEAD"],
check=True,
capture_output=True,
text=True,
).stdout.strip()
print(f"deployed hermes-agent context-engine contract commit: {commit}")

def test_registers_singleton_and_deep_copies_without_tool_duplication(self) -> None:
ContextEngine, PluginContext, PluginManager, PluginManifest = _HOST

class ContractEngine(ContextEngine):
@property
def name(self):
return "continuity-contract"

def update_from_response(self, usage):
self.last_total_tokens = usage.get("total_tokens", 0)

def should_compress(self, prompt_tokens=None):
return False

def compress(self, messages, **kwargs):
return messages

def get_tool_schemas(self):
return [
{
"name": "continuity_recover",
"description": "Recover bounded context.",
"parameters": {"type": "object", "properties": {}},
}
]

def handle_tool_call(self, name, args, **kwargs):
return json.dumps({"name": name})

manager = PluginManager()
manifest = PluginManifest(name="contract-plugin")
ctx = PluginContext(manifest, manager)
engine = ContractEngine()

summary = hpk.register_plugin(ctx, (), context_engine=engine)

self.assertIs(manager._context_engine, engine)
self.assertEqual(summary.context_engine, "continuity-contract")
self.assertEqual(summary.context_engine_registration, "accepted")
activated = copy.deepcopy(manager._context_engine)
self.assertIsNot(activated, engine)
self.assertEqual(activated.name, engine.name)
self.assertNotIn("continuity_recover", manager._plugin_tool_names)

with self.assertRaisesRegex(RuntimeError, "context engine.*registered"):
hpk.register_plugin(ctx, (), context_engine=ContractEngine())
self.assertIs(manager._context_engine, engine)


if __name__ == "__main__":
unittest.main()
Loading