diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 754f85c..c0b269b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 5f14a9c..f464f31 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`, diff --git a/README.md b/README.md index 6384444..4fe3016 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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: @@ -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 @@ -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 diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index e9db421..aa28f16 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -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): @@ -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 "", ",".join(summary.cli_commands) or "", @@ -454,6 +457,8 @@ def log_registration_summary( ",".join(summary.image_gen_providers) or "", ",".join(summary.video_gen_providers) or "", ",".join(summary.capabilities) or "", + summary.context_engine or "", + summary.context_engine_registration or "", ) @@ -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, @@ -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] @@ -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] @@ -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 diff --git a/skills/hermes-plugins/references/plugin-kit.md b/skills/hermes-plugins/references/plugin-kit.md index ccb5322..52afa38 100644 --- a/skills/hermes-plugins/references/plugin-kit.md +++ b/skills/hermes-plugins/references/plugin-kit.md @@ -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 @@ -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. @@ -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. diff --git a/tests/test_context_engine_contract.py b/tests/test_context_engine_contract.py new file mode 100644 index 0000000..26fea2d --- /dev/null +++ b/tests/test_context_engine_contract.py @@ -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() diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index 8b6a2c8..973f1f3 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -18,6 +18,7 @@ from __future__ import annotations import argparse +import copy import inspect import json import os @@ -64,6 +65,7 @@ def _try(): ) from tools.registry import registry # type: ignore from hermes_state import SCHEMA_VERSION, SessionDB # type: ignore + from agent.context_engine import ContextEngine # type: ignore # A stale checkout that predates plugin-owned skills is not the # lifecycle contract this suite is intended to certify. @@ -102,6 +104,7 @@ def _try(): registry=registry, SCHEMA_VERSION=SCHEMA_VERSION, SessionDB=SessionDB, + ContextEngine=ContextEngine, ) try: @@ -425,6 +428,60 @@ def contract_hook(**kwargs): ) self.assertEqual(manager.find_plugin_skill("contract-plugin:probe"), path) + def test_context_engine_registers_singleton_and_deep_copies_without_tool_duplication(self) -> None: + class ContractEngine(_REAL.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 = _REAL.PluginManager() + manifest = _REAL.PluginManifest(name="contract-plugin") + ctx = _REAL.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") + # Upstream main still returns None after accepting the engine, while + # the deployed continuity host returns True. The receipt must remain + # truthful across both contracts. + self.assertIn(summary.context_engine_registration, {"accepted", "declared/submitted"}) + activated = copy.deepcopy(manager._context_engine) + self.assertIsNot(activated, engine) + self.assertEqual(activated.name, engine.name) + self.assertNotIn("continuity_recover", manager._plugin_tool_names) + + second = ContractEngine() + if summary.context_engine_registration == "accepted": + with self.assertRaisesRegex(RuntimeError, "context engine.*registered"): + hpk.register_plugin(ctx, (), context_engine=second) + else: + second_summary = hpk.register_plugin(ctx, (), context_engine=second) + self.assertEqual(second_summary.context_engine_registration, "declared/submitted") + self.assertIs(manager._context_engine, engine) + def test_command_registers_and_dispatches_through_real_plugin_context(self) -> None: manager = _REAL.PluginManager() manifest = _REAL.PluginManifest(name="contract-plugin") diff --git a/tests/test_kit.py b/tests/test_kit.py index de17dea..34bd740 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -9,12 +9,31 @@ import tempfile import types import unittest +from contextlib import contextmanager from pathlib import Path from unittest.mock import AsyncMock, Mock, patch import hermes_plugin_kit as hpk +@contextmanager +def fake_context_engine_host(): + """Install the minimum real-type import seam used by registration preflight.""" + agent_module = types.ModuleType("agent") + context_engine_module = types.ModuleType("agent.context_engine") + + class ContextEngine: + pass + + context_engine_module.ContextEngine = ContextEngine + agent_module.context_engine = context_engine_module + with patch.dict( + sys.modules, + {"agent": agent_module, "agent.context_engine": context_engine_module}, + ): + yield ContextEngine + + class FakeCtx: def __init__(self) -> None: self.tools: list[dict] = [] @@ -34,6 +53,8 @@ def __init__(self) -> None: self.image_gen_providers: list[object] = [] self.video_gen_providers: list[object] = [] self.memory_providers: list[object] = [] + self.context_engines: list[object] = [] + self.context_engine_result = True self.subagent_lifecycle = types.SimpleNamespace( launch=lambda request: request, status=lambda handle: handle, @@ -67,6 +88,10 @@ def register_video_gen_provider(self, provider) -> None: def register_memory_provider(self, provider) -> None: self.memory_providers.append(provider) + def register_context_engine(self, engine): + self.context_engines.append(engine) + return self.context_engine_result + class SessionDBHelperTests(unittest.TestCase): def test_injected_db_is_delegated_to_and_remains_open(self) -> None: @@ -1568,6 +1593,97 @@ def test_registers_specialized_providers_without_decorating_them(self) -> None: self.assertEqual(summary.image_gen_providers, ("image",)) self.assertEqual(summary.video_gen_providers, ("video",)) + def test_registers_one_typed_context_engine_with_accepted_receipt(self) -> None: + with fake_context_engine_host() as ContextEngine: + engine = ContextEngine() + engine.name = "continuity" + ctx = FakePluginCtx() + + with self.assertLogs(level="INFO") as cap: + summary = hpk.register_plugin( + ctx, self._module(), context_engine=engine + ) + + self.assertEqual(ctx.context_engines, [engine]) + self.assertEqual(summary.context_engine, "continuity") + self.assertEqual(summary.context_engine_registration, "accepted") + receipt = "\n".join(cap.output) + self.assertIn("context_engine=continuity", receipt) + self.assertIn("context_engine_registration=accepted", receipt) + + def test_legacy_context_engine_registrar_is_reported_as_submitted(self) -> None: + with fake_context_engine_host() as ContextEngine: + engine = ContextEngine() + engine.name = "continuity" + ctx = FakePluginCtx() + ctx.context_engine_result = None + + summary = hpk.register_plugin(ctx, self._module(), context_engine=engine) + + self.assertEqual(summary.context_engine, "continuity") + self.assertEqual(summary.context_engine_registration, "declared/submitted") + + def test_omitting_context_engine_preserves_existing_behavior(self) -> None: + ctx = FakePluginCtx() + + summary = hpk.register_plugin(ctx, self._module()) + + self.assertEqual(ctx.context_engines, []) + self.assertIsNone(summary.context_engine) + self.assertIsNone(summary.context_engine_registration) + + def test_context_engine_preflight_finishes_before_host_mutation(self) -> None: + @hpk.tool(toolset="sample", name="sample_context_engine_preflight") + def sample_tool(args, **kwargs): + """Sample tool.""" + return {} + + cases = ( + ("missing registrar", object(), RuntimeError, "register_context_engine"), + ("blank name", types.SimpleNamespace(name=" "), ValueError, "non-empty name"), + ( + "wrong type", + types.SimpleNamespace(name="continuity"), + TypeError, + "ContextEngine", + ), + ) + with fake_context_engine_host(): + for label, engine, error_type, message in cases: + with self.subTest(label=label): + ctx = FakePluginCtx() + if label == "missing registrar": + ctx.register_context_engine = None + with self.assertRaisesRegex(error_type, message): + hpk.register_plugin( + ctx, + self._module(sample_tool=sample_tool), + context_engine=engine, + ) + self.assertEqual(ctx.tools, []) + self.assertEqual(ctx.context_engines, []) + + def test_rejected_second_context_engine_fails_before_other_mutation(self) -> None: + @hpk.hook("pre_llm_call") + def sample_hook(**kwargs): + return kwargs + + with fake_context_engine_host() as ContextEngine: + engine = ContextEngine() + engine.name = "continuity" + ctx = FakePluginCtx() + ctx.context_engine_result = False + + with self.assertRaisesRegex(RuntimeError, "only one context engine"): + hpk.register_plugin( + ctx, + self._module(sample_hook=sample_hook), + context_engine=engine, + ) + + self.assertEqual(ctx.context_engines, [engine]) + self.assertEqual(ctx.hooks, []) + def test_preflights_provider_support_before_registering_tools(self) -> None: @hpk.tool(toolset="sample", name="sample_tool") def sample_tool(args, **kwargs): @@ -1623,7 +1739,8 @@ def test_logs_one_stable_registration_receipt_with_actual_names(self) -> None: "skipped_optional_skills=missing-optional; " "memory_providers=; " "image_gen_providers=; video_gen_providers=; " - "capabilities=image,video", + "capabilities=image,video; context_engine=; " + "context_engine_registration=", ) def test_register_plugin_reports_selected_capabilities(self) -> None: