diff --git a/AGENTS.md b/AGENTS.md index 1943d6c..44a5842 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,14 +1,15 @@ # hermes-plugin-kit -Convention-correct helper library for registering `hermes-agent` plugin tools, -hooks, and skills. +Convention-correct helper library for registering `hermes-agent` plugin +commands, tools, middleware, hooks, and skills. This repository is an installable Python package, not a path-loaded runtime plugin. ## Working Rules -- Keep `@tool` and `register_all` backward compatible. Use `@hook`, - `plugin_skill`, and `register_plugin` for full plugin lifecycle registration. +- Keep `@tool` and `register_all` backward compatible. Use `@command`, + `@middleware`, `@hook`, `plugin_skill`, and `register_plugin` for full plugin + lifecycle registration. - 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 c2ec7bb..450ddf1 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # hermes-plugin-kit -> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, hooks, skills, validation, and safe logging, baked in. +> Lifecycle helpers for [hermes-agent](https://github.com/NousResearch/hermes-agent) plugins — convention-correct commands, tools, middleware, hooks, skills, validation, and safe logging, baked in. [![test](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml/badge.svg)](https://github.com/offendingcommit/hermes-plugin-kit/actions/workflows/test.yml) ![python](https://img.shields.io/badge/python-3.11%2B-blue) @@ -8,8 +8,8 @@ `hermes-plugin-kit` is a tiny, dependency-free helper for authoring plugins for [hermes-agent](https://github.com/NousResearch/hermes-agent). Decorate a slash command with `@command`, a tool with `@tool`, or a lifecycle callback with -`@hook`, then use `register_plugin` to register commands, tools, hooks, and -plugin-owned skills together. Existing tool-only +`@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_all`; the LLM-facing schema, argument validation, structured logging, and the JSON result envelope are all generated for you — correctly, every time. @@ -132,19 +132,44 @@ That's it. `discord_read_thread` is registered with a `parameters`-wrapped schem self-documenting description, required-argument validation, logging, and the JSON envelope — none of which you had to write. -## Commands, hooks, and plugin skills +## Commands, middleware, hooks, and plugin skills Use the lifecycle entrypoint when a plugin provides more than tools: ```python +import time from pathlib import Path -from hermes_plugin_kit import command, hook, plugin_skill, register_plugin +from hermes_plugin_kit import ( + MiddlewareKind, + command, + hook, + middleware, + plugin_skill, + register_plugin, +) @command("valdris-status", args_hint="") def valdris_status(raw_args): """Show the current Valdris plugin status.""" return build_status(raw_args) +@middleware(MiddlewareKind.TOOL_REQUEST) +def normalize_tool_request(**kwargs): + args = {**kwargs["args"]} + args["workspace"] = normalize_workspace(args.get("workspace")) + return {"args": args, "source": "valdris"} + +@middleware(MiddlewareKind.TOOL_EXECUTION) +def measure_tool_execution(**kwargs): + started = time.perf_counter() + try: + return kwargs["next_call"](kwargs["args"]) + finally: + record_tool_latency( + kwargs["tool_name"], + time.perf_counter() - started, + ) + @hook("pre_llm_call") def inject_context(**kwargs): return {"context": build_context(kwargs)} @@ -169,6 +194,28 @@ forwarded to Hermes for native command pickers. Command logs include only the command name, elapsed time, result type, and argument character count, never the raw arguments. +`@middleware` changes runtime behavior rather than merely observing it. Request +middleware rewrites the effective payload before Hermes continues; execution +middleware wraps the actual tool or model call through the supplied +single-use `next_call`. The four current phases are: + +- `MiddlewareKind.TOOL_REQUEST`: return `{"args": {...}}` to replace tool + arguments before hooks, guardrails, approvals, and execution. +- `MiddlewareKind.TOOL_EXECUTION`: call `next_call(args)` to wrap the real tool + execution and optionally transform its result. +- `MiddlewareKind.LLM_REQUEST`: return `{"request": {...}}` to replace provider + request arguments before the model call. +- `MiddlewareKind.LLM_EXECUTION`: call `next_call(request)` to wrap the real + model execution and optionally transform its result. + +Middleware callbacks must be synchronous because Hermes does not await them. +Each execution callback must call `next_call` at most once. The decorator also +accepts a non-empty string kind for forward compatibility with future Hermes +phases. `register_plugin` rejects two callbacks for the same kind within one +plugin, which prevents registration order from silently deciding behavior. +Logs contain the kind, elapsed time, result type, and safe correlation IDs, but +never request payloads or exception messages. + `@hook` forwards Hermes keyword arguments and return values unchanged. It logs only the hook name, elapsed time, result type, and supplied `session_id` or `task_id`; callback payloads and exception messages are never logged. Exceptions diff --git a/hermes_plugin_kit/__init__.py b/hermes_plugin_kit/__init__.py index b8a7613..065e8bd 100644 --- a/hermes_plugin_kit/__init__.py +++ b/hermes_plugin_kit/__init__.py @@ -1,7 +1,8 @@ """hermes-plugin-kit — convention-correct surface registration for Hermes plugins. -Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied -for you, so the classes of bug that bite hand-written plugins cannot recur: +Reach for ``@tool`` + ``register_all`` and every Hermes tool convention is +applied for you. Use ``@command``, ``@middleware``, ``@hook``, and +``register_plugin`` for the full plugin lifecycle: - **Schema convention** — arguments are nested under a ``parameters`` wrapper (``{name, description, parameters: {type, properties, required, @@ -19,6 +20,9 @@ ``(args, **kwargs)`` signature, exactly as the registry requires. - **Host invocation** — ``invoke_host_tool`` reaches supported Hermes runtime services that are not registry-backed while preserving tool lifecycle hooks. +- **Middleware** — request callbacks can rewrite tool or model inputs, while + execution callbacks wrap the real call through Hermes' single-use + ``next_call`` chain. Usage:: @@ -62,6 +66,7 @@ def register(ctx): __all__ = [ "tool", "command", + "middleware", "hook", "plugin_skill", "register_plugin", @@ -74,6 +79,7 @@ def register(ctx): "MediaPayload", "ResolvedDeliveryTarget", "MediaDeliveryResult", + "MiddlewareKind", "PluginSkill", "RegistrationSummary", "register_all", @@ -88,6 +94,7 @@ def register(ctx): _SPEC_ATTR = "_hpk_tool_spec" _COMMAND_SPEC_ATTR = "_hpk_command_spec" +_MIDDLEWARE_SPEC_ATTR = "_hpk_middleware_spec" _HOOK_SPEC_ATTR = "_hpk_hook_spec" _REDACT_HINTS = ("token", "secret", "password", "passwd", "api_key", "apikey", "auth") _MAX_LOG_CHARS = 200 @@ -131,6 +138,16 @@ class RegistrationSummary: skills: tuple[str, ...] = () skipped_optional_skills: tuple[str, ...] = () commands: tuple[str, ...] = () + middlewares: tuple[str, ...] = () + + +class MiddlewareKind(str, Enum): + """Middleware phases currently supported by hermes-agent.""" + + TOOL_REQUEST = "tool_request" + TOOL_EXECUTION = "tool_execution" + LLM_REQUEST = "llm_request" + LLM_EXECUTION = "llm_execution" class MediaType(str, Enum): @@ -397,7 +414,7 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]: # --------------------------------------------------------------------------- -# The decorator +# Decorators # --------------------------------------------------------------------------- def command( @@ -500,6 +517,65 @@ def sync_wrapper(raw_args: str) -> str | None: return decorate +def middleware(kind: MiddlewareKind | str) -> Callable: + """Mark and instrument a synchronous Hermes middleware callback. + + Known middleware phases are available through :class:`MiddlewareKind`. + Non-empty strings are also accepted so plugins can adopt new Hermes phases + without waiting for a kit release. Keyword arguments and return values pass + through unchanged. + """ + if isinstance(kind, MiddlewareKind): + middleware_kind = kind.value + elif isinstance(kind, str) and kind.strip(): + middleware_kind = kind.strip() + else: + raise ValueError("middleware kind is required") + + def decorate(fn: Callable) -> Callable: + if inspect.iscoroutinefunction(fn): + raise TypeError( + "@middleware callbacks must be synchronous; " + "hermes-agent does not await middleware callbacks" + ) + log = logging.getLogger(fn.__module__ or "hermes_plugin_kit") + + @functools.wraps(fn) + def wrapper(**kwargs: Any) -> Any: + started = time.perf_counter() + context = _truncate(_safe_context(kwargs)) + log.debug( + "%s middleware: invoked; context=%s", + middleware_kind, + context, + ) + try: + result = fn(**kwargs) + except Exception as exc: + log.warning( + "%s middleware: callback raised; elapsed_ms=%.2f; " + "error_type=%s; context=%s", + middleware_kind, + (time.perf_counter() - started) * 1000, + type(exc).__name__, + context, + ) + raise + log.info( + "%s middleware: ok; elapsed_ms=%.2f; result=%s; context=%s", + middleware_kind, + (time.perf_counter() - started) * 1000, + type(result).__name__, + context, + ) + return result + + setattr(wrapper, _MIDDLEWARE_SPEC_ATTR, {"kind": middleware_kind}) + return wrapper + + return decorate + + def hook(name: str) -> Callable: """Mark and instrument a Hermes lifecycle hook callback. @@ -1240,7 +1316,7 @@ def register_plugin( module: Any, skills: tuple[PluginSkill, ...] | list[PluginSkill] = (), ) -> RegistrationSummary: - """Register decorated commands, tools, hooks, and skills from *module*. + """Register decorated commands, tools, middleware, hooks, and skills. Unlike the backward-compatible :func:`register_all`, this lifecycle-level entrypoint rejects distinct declarations that share a public name. Missing @@ -1252,6 +1328,7 @@ def register_plugin( commands: dict[str, Callable] = {} tools: dict[str, Callable] = {} + middlewares: dict[str, Callable] = {} hooks: dict[str, Callable] = {} for _, obj in inspect.getmembers(module): command_spec = getattr(obj, _COMMAND_SPEC_ATTR, None) @@ -1268,6 +1345,15 @@ def register_plugin( raise ValueError(f"duplicate tool name: {tool_spec['name']}") tools[tool_spec["name"]] = obj + middleware_spec = getattr(obj, _MIDDLEWARE_SPEC_ATTR, None) + if middleware_spec: + existing = middlewares.get(middleware_spec["kind"]) + if existing is not None and existing is not obj: + raise ValueError( + f"duplicate middleware kind: {middleware_spec['kind']}" + ) + middlewares[middleware_spec["kind"]] = obj + hook_spec = getattr(obj, _HOOK_SPEC_ATTR, None) if hook_spec: existing = hooks.get(hook_spec["name"]) @@ -1318,6 +1404,11 @@ def register_plugin( _register_tool(ctx, obj, spec) registered_tools.append(name) + registered_middlewares: list[str] = [] + for kind in sorted(middlewares): + ctx.register_middleware(kind, middlewares[kind]) + registered_middlewares.append(kind) + registered_hooks: list[str] = [] for name in sorted(hooks): ctx.register_hook(name, hooks[name]) @@ -1335,15 +1426,17 @@ def register_plugin( summary = RegistrationSummary( commands=tuple(registered_commands), tools=tuple(registered_tools), + middlewares=tuple(registered_middlewares), hooks=tuple(registered_hooks), skills=tuple(registered_skills), skipped_optional_skills=tuple(skipped_skills), ) log.info( "hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; " - "hooks=%s; skills=%s; skipped_optional_skills=%s", + "middlewares=%s; hooks=%s; skills=%s; skipped_optional_skills=%s", ",".join(summary.commands) or "", ",".join(summary.tools) or "", + ",".join(summary.middlewares) or "", ",".join(summary.hooks) or "", ",".join(summary.skills) or "", ",".join(summary.skipped_optional_skills) or "", diff --git a/pyproject.toml b/pyproject.toml index 97db6a7..92b60da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,8 +8,8 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-plugin-kit" -version = "0.4.0" -description = "Convention-correct command and lifecycle registration for hermes-agent plugins." +version = "0.5.0" +description = "Convention-correct middleware and lifecycle registration for hermes-agent plugins." readme = "README.md" requires-python = ">=3.11" license = { text = "MIT" } diff --git a/tests/test_hermes_contract.py b/tests/test_hermes_contract.py index b4dd53b..7531064 100644 --- a/tests/test_hermes_contract.py +++ b/tests/test_hermes_contract.py @@ -51,6 +51,13 @@ def _try(): VALID_HOOKS, resolve_plugin_command_result, ) + from hermes_cli.middleware import ( # type: ignore + VALID_MIDDLEWARE, + apply_llm_request_middleware, + apply_tool_request_middleware, + run_llm_execution_middleware, + run_tool_execution_middleware, + ) from tools.registry import registry # type: ignore # A stale checkout that predates plugin-owned skills is not the @@ -59,13 +66,22 @@ def _try(): raise ImportError("hermes-agent PluginContext.register_skill is unavailable") if not hasattr(PluginManager, "find_plugin_skill"): raise ImportError("hermes-agent PluginManager.find_plugin_skill is unavailable") + if not hasattr(PluginContext, "register_middleware"): + raise ImportError( + "hermes-agent PluginContext.register_middleware is unavailable" + ) return types.SimpleNamespace( + apply_llm_request_middleware=apply_llm_request_middleware, + apply_tool_request_middleware=apply_tool_request_middleware, PluginContext=PluginContext, PluginManager=PluginManager, PluginManifest=PluginManifest, VALID_HOOKS=set(VALID_HOOKS), + VALID_MIDDLEWARE=set(VALID_MIDDLEWARE), resolve_plugin_command_result=resolve_plugin_command_result, + run_llm_execution_middleware=run_llm_execution_middleware, + run_tool_execution_middleware=run_tool_execution_middleware, registry=registry, ) @@ -229,6 +245,123 @@ def test_command_registers_and_dispatches_through_real_plugin_context(self) -> N "command:exact raw args", ) + def test_middleware_registers_and_runs_all_real_hermes_contracts(self) -> None: + request_calls: list[tuple[str, dict]] = [] + execution_calls: list[tuple[str, dict]] = [] + + @hpk.middleware(hpk.MiddlewareKind.TOOL_REQUEST) + def rewrite_tool_request(**kwargs): + request_calls.append(("tool_request", kwargs)) + return { + "args": {**kwargs["args"], "rewritten": True}, + "source": "contract-test", + } + + @hpk.middleware(hpk.MiddlewareKind.LLM_REQUEST) + def rewrite_llm_request(**kwargs): + request_calls.append(("llm_request", kwargs)) + return { + "request": {**kwargs["request"], "rewritten": True}, + "source": "contract-test", + } + + @hpk.middleware(hpk.MiddlewareKind.TOOL_EXECUTION) + def wrap_tool_execution(**kwargs): + execution_calls.append(("tool_execution", kwargs)) + result = kwargs["next_call"]({**kwargs["args"], "wrapped": True}) + return {"middleware": "tool", "result": result} + + @hpk.middleware(hpk.MiddlewareKind.LLM_EXECUTION) + def wrap_llm_execution(**kwargs): + execution_calls.append(("llm_execution", kwargs)) + result = kwargs["next_call"]( + {**kwargs["request"], "wrapped": True} + ) + return {"middleware": "llm", "result": result} + + module = types.ModuleType("contract_middleware_plugin") + module.rewrite_tool_request = rewrite_tool_request + module.rewrite_llm_request = rewrite_llm_request + module.wrap_tool_execution = wrap_tool_execution + module.wrap_llm_execution = wrap_llm_execution + + manager = _REAL.PluginManager() + manifest = _REAL.PluginManifest(name="contract-plugin") + ctx = _REAL.PluginContext(manifest, manager) + summary = hpk.register_plugin(ctx, module) + + expected_kinds = tuple(sorted(kind.value for kind in hpk.MiddlewareKind)) + self.assertEqual(summary.middlewares, expected_kinds) + self.assertEqual(set(manager._middleware), set(_REAL.VALID_MIDDLEWARE)) + + with patch("hermes_cli.plugins.get_plugin_manager", return_value=manager): + tool_request = _REAL.apply_tool_request_middleware( + "contract_tool", + {"value": "original"}, + session_id="session-1", + ) + llm_request = _REAL.apply_llm_request_middleware( + {"model": "contract-model"}, + session_id="session-1", + ) + tool_execution = _REAL.run_tool_execution_middleware( + "contract_tool", + {"value": "effective"}, + lambda args: {"terminal": args}, + session_id="session-1", + ) + llm_execution = _REAL.run_llm_execution_middleware( + {"model": "contract-model"}, + lambda request: {"terminal": request}, + session_id="session-1", + ) + + self.assertTrue(tool_request.changed) + self.assertEqual( + tool_request.payload, + {"value": "original", "rewritten": True}, + ) + self.assertEqual(tool_request.trace, [{"source": "contract-test"}]) + self.assertTrue(llm_request.changed) + self.assertEqual( + llm_request.payload, + {"model": "contract-model", "rewritten": True}, + ) + self.assertEqual( + tool_execution, + { + "middleware": "tool", + "result": { + "terminal": {"value": "effective", "wrapped": True} + }, + }, + ) + self.assertEqual( + llm_execution, + { + "middleware": "llm", + "result": { + "terminal": { + "model": "contract-model", + "wrapped": True, + } + }, + }, + ) + self.assertEqual( + [name for name, _kwargs in request_calls], + ["tool_request", "llm_request"], + ) + self.assertEqual( + [name for name, _kwargs in execution_calls], + ["tool_execution", "llm_execution"], + ) + for _name, kwargs in request_calls + execution_calls: + self.assertEqual( + kwargs["middleware_schema_version"], + "hermes.middleware.v1", + ) + def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None: command_sig = inspect.signature(_REAL.PluginContext.register_command) command_sig.bind( @@ -242,6 +375,9 @@ def test_lifecycle_calls_bind_to_real_plugincontext_signatures(self) -> None: hook_sig = inspect.signature(_REAL.PluginContext.register_hook) hook_sig.bind(None, "pre_llm_call", lambda **kwargs: None) + middleware_sig = inspect.signature(_REAL.PluginContext.register_middleware) + middleware_sig.bind(None, "tool_request", lambda **kwargs: None) + skill_sig = inspect.signature(_REAL.PluginContext.register_skill) skill_sig.bind( None, diff --git a/tests/test_kit.py b/tests/test_kit.py index 775c2f3..09476d5 100644 --- a/tests/test_kit.py +++ b/tests/test_kit.py @@ -25,12 +25,16 @@ class FakePluginCtx(FakeCtx): def __init__(self) -> None: super().__init__() self.commands: list[dict] = [] + self.middlewares: list[tuple[str, object]] = [] self.hooks: list[tuple[str, object]] = [] self.skills: list[dict] = [] def register_command(self, **kwargs) -> None: self.commands.append(kwargs) + def register_middleware(self, kind, callback) -> None: + self.middlewares.append((kind, callback)) + def register_hook(self, hook_name, callback) -> None: self.hooks.append((hook_name, callback)) @@ -247,6 +251,75 @@ def test_hook_name_is_required(self) -> None: hpk.hook("") +class MiddlewareBehaviorTests(unittest.TestCase): + def test_known_kinds_forward_kwargs_and_return_values(self) -> None: + expected = { + hpk.MiddlewareKind.TOOL_REQUEST, + hpk.MiddlewareKind.TOOL_EXECUTION, + hpk.MiddlewareKind.LLM_REQUEST, + hpk.MiddlewareKind.LLM_EXECUTION, + } + self.assertEqual(set(hpk.MiddlewareKind), expected) + + for kind in expected: + marker = object() + + @hpk.middleware(kind) + def callback(**kwargs): + self.assertIs(kwargs["payload"], marker) + return marker + + with self.subTest(kind=kind), self.assertLogs(level="DEBUG") as cap: + self.assertIs( + callback(payload=marker, session_id="session-1"), + marker, + ) + spec = getattr(callback, "_hpk_middleware_spec") + self.assertEqual(spec["kind"], kind.value) + joined = "\n".join(cap.output) + self.assertIn(f"{kind.value} middleware: invoked", joined) + self.assertIn("session-1", joined) + self.assertNotIn(repr(marker), joined) + self.assertRegex(joined, r"elapsed_ms=\d+\.\d{2}") + + def test_accepts_future_string_kind(self) -> None: + @hpk.middleware(" future_request ") + def callback(**kwargs): + return kwargs + + self.assertEqual( + getattr(callback, "_hpk_middleware_spec"), + {"kind": "future_request"}, + ) + + def test_rejects_missing_kind_and_async_callback(self) -> None: + for kind in ("", " ", None): + with self.subTest(kind=kind), self.assertRaisesRegex( + ValueError, "middleware kind" + ): + hpk.middleware(kind) + + async def async_callback(**kwargs): + return kwargs + + with self.assertRaisesRegex(TypeError, "must be synchronous"): + hpk.middleware(hpk.MiddlewareKind.TOOL_REQUEST)(async_callback) + + def test_reraises_without_logging_payload_or_exception_message(self) -> None: + @hpk.middleware(hpk.MiddlewareKind.TOOL_REQUEST) + def callback(**kwargs): + raise RuntimeError("private middleware failure") + + with self.assertLogs(level="WARNING") as cap: + with self.assertRaisesRegex(RuntimeError, "private middleware failure"): + callback(args={"token": "private-token"}, task_id="task-1") + joined = "\n".join(cap.output) + self.assertIn("error_type=RuntimeError", joined) + self.assertIn("task-1", joined) + self.assertNotIn("private middleware failure", joined) + self.assertNotIn("private-token", joined) + + class CommandBehaviorTests(unittest.IsolatedAsyncioTestCase): def test_forwards_raw_args_and_uses_docstring_description(self) -> None: @hpk.command("valdris-status", args_hint=" ") @@ -854,7 +927,7 @@ def _module(self, **attrs): setattr(module, name, value) return module - def test_registers_commands_tools_hooks_and_skills_with_summary(self) -> None: + def test_registers_all_lifecycle_surfaces_with_summary(self) -> None: @hpk.command( "valdris-status", description="Show Valdris status.", @@ -867,6 +940,10 @@ def command_handler(raw_args): def callback(**kwargs): return kwargs + @hpk.middleware(hpk.MiddlewareKind.TOOL_REQUEST) + def request_middleware(**kwargs): + return {"args": kwargs["args"]} + with tempfile.TemporaryDirectory() as tmp: skill_path = Path(tmp) / "SKILL.md" skill_path.write_text("# Skill\n") @@ -877,6 +954,7 @@ def callback(**kwargs): module = self._module( callback=callback, command_handler=command_handler, + request_middleware=request_middleware, sample_read=sample_read, ) with self.assertLogs(level="INFO") as cap: @@ -884,6 +962,7 @@ def callback(**kwargs): self.assertEqual(summary.commands, ("valdris-status",)) self.assertEqual(summary.tools, ("sample_read_thread",)) + self.assertEqual(summary.middlewares, ("tool_request",)) self.assertEqual(summary.hooks, ("pre_llm_call",)) self.assertEqual(summary.skills, ("temporal-awareness",)) self.assertEqual(summary.skipped_optional_skills, ()) @@ -898,10 +977,15 @@ def callback(**kwargs): } ], ) + self.assertEqual( + ctx.middlewares, + [("tool_request", request_middleware)], + ) self.assertEqual(ctx.hooks, [("pre_llm_call", callback)]) self.assertEqual(ctx.skills[0]["name"], "temporal-awareness") self.assertIn("commands=valdris-status", "\n".join(cap.output)) self.assertIn("tools=sample_read_thread", "\n".join(cap.output)) + self.assertIn("middlewares=tool_request", "\n".join(cap.output)) self.assertIn("hooks=pre_llm_call", "\n".join(cap.output)) self.assertIn("skills=temporal-awareness", "\n".join(cap.output)) @@ -953,6 +1037,21 @@ def second(**kwargs): FakePluginCtx(), self._module(first=first, second=second) ) + def test_rejects_duplicate_middleware_kinds_before_registration(self) -> None: + @hpk.middleware(hpk.MiddlewareKind.TOOL_EXECUTION) + def first(**kwargs): + return kwargs["next_call"](kwargs["args"]) + + @hpk.middleware("tool_execution") + def second(**kwargs): + return kwargs["next_call"](kwargs["args"]) + + ctx = FakePluginCtx() + with self.assertRaisesRegex(ValueError, "duplicate middleware"): + hpk.register_plugin(ctx, self._module(first=first, second=second)) + self.assertEqual(ctx.middlewares, []) + self.assertEqual(ctx.tools, []) + def test_rejects_duplicate_tool_names(self) -> None: @hpk.tool(toolset="sample", name="sample_duplicate") def first(args, **kwargs): diff --git a/uv.lock b/uv.lock index ffcbe9b..b349fa4 100644 --- a/uv.lock +++ b/uv.lock @@ -87,7 +87,7 @@ wheels = [ [[package]] name = "hermes-plugin-kit" -version = "0.4.0" +version = "0.5.0" source = { editable = "." } [package.dev-dependencies]