11"""hermes-plugin-kit — convention-correct surface registration for Hermes plugins.
22
3- Reach for ``@tool`` + ``register_all`` and every hermes tool convention is applied
4- for you, so the classes of bug that bite hand-written plugins cannot recur:
3+ Reach for ``@tool`` + ``register_all`` and every Hermes tool convention is
4+ applied for you. Use ``@command``, ``@middleware``, ``@hook``, and
5+ ``register_plugin`` for the full plugin lifecycle:
56
67- **Schema convention** — arguments are nested under a ``parameters`` wrapper
78 (``{name, description, parameters: {type, properties, required,
1920 ``(args, **kwargs)`` signature, exactly as the registry requires.
2021- **Host invocation** — ``invoke_host_tool`` reaches supported Hermes runtime
2122 services that are not registry-backed while preserving tool lifecycle hooks.
23+ - **Middleware** — request callbacks can rewrite tool or model inputs, while
24+ execution callbacks wrap the real call through Hermes' single-use
25+ ``next_call`` chain.
2226
2327Usage::
2428
@@ -62,6 +66,7 @@ def register(ctx):
6266__all__ = [
6367 "tool" ,
6468 "command" ,
69+ "middleware" ,
6570 "hook" ,
6671 "plugin_skill" ,
6772 "register_plugin" ,
@@ -74,6 +79,7 @@ def register(ctx):
7479 "MediaPayload" ,
7580 "ResolvedDeliveryTarget" ,
7681 "MediaDeliveryResult" ,
82+ "MiddlewareKind" ,
7783 "PluginSkill" ,
7884 "RegistrationSummary" ,
7985 "register_all" ,
@@ -88,6 +94,7 @@ def register(ctx):
8894
8995_SPEC_ATTR = "_hpk_tool_spec"
9096_COMMAND_SPEC_ATTR = "_hpk_command_spec"
97+ _MIDDLEWARE_SPEC_ATTR = "_hpk_middleware_spec"
9198_HOOK_SPEC_ATTR = "_hpk_hook_spec"
9299_REDACT_HINTS = ("token" , "secret" , "password" , "passwd" , "api_key" , "apikey" , "auth" )
93100_MAX_LOG_CHARS = 200
@@ -131,6 +138,16 @@ class RegistrationSummary:
131138 skills : tuple [str , ...] = ()
132139 skipped_optional_skills : tuple [str , ...] = ()
133140 commands : tuple [str , ...] = ()
141+ middlewares : tuple [str , ...] = ()
142+
143+
144+ class MiddlewareKind (str , Enum ):
145+ """Middleware phases currently supported by hermes-agent."""
146+
147+ TOOL_REQUEST = "tool_request"
148+ TOOL_EXECUTION = "tool_execution"
149+ LLM_REQUEST = "llm_request"
150+ LLM_EXECUTION = "llm_execution"
134151
135152
136153class MediaType (str , Enum ):
@@ -397,7 +414,7 @@ def _safe_context(kwargs: dict[str, Any]) -> dict[str, Any]:
397414
398415
399416# ---------------------------------------------------------------------------
400- # The decorator
417+ # Decorators
401418# ---------------------------------------------------------------------------
402419
403420def command (
@@ -500,6 +517,65 @@ def sync_wrapper(raw_args: str) -> str | None:
500517 return decorate
501518
502519
520+ def middleware (kind : MiddlewareKind | str ) -> Callable :
521+ """Mark and instrument a synchronous Hermes middleware callback.
522+
523+ Known middleware phases are available through :class:`MiddlewareKind`.
524+ Non-empty strings are also accepted so plugins can adopt new Hermes phases
525+ without waiting for a kit release. Keyword arguments and return values pass
526+ through unchanged.
527+ """
528+ if isinstance (kind , MiddlewareKind ):
529+ middleware_kind = kind .value
530+ elif isinstance (kind , str ) and kind .strip ():
531+ middleware_kind = kind .strip ()
532+ else :
533+ raise ValueError ("middleware kind is required" )
534+
535+ def decorate (fn : Callable ) -> Callable :
536+ if inspect .iscoroutinefunction (fn ):
537+ raise TypeError (
538+ "@middleware callbacks must be synchronous; "
539+ "hermes-agent does not await middleware callbacks"
540+ )
541+ log = logging .getLogger (fn .__module__ or "hermes_plugin_kit" )
542+
543+ @functools .wraps (fn )
544+ def wrapper (** kwargs : Any ) -> Any :
545+ started = time .perf_counter ()
546+ context = _truncate (_safe_context (kwargs ))
547+ log .debug (
548+ "%s middleware: invoked; context=%s" ,
549+ middleware_kind ,
550+ context ,
551+ )
552+ try :
553+ result = fn (** kwargs )
554+ except Exception as exc :
555+ log .warning (
556+ "%s middleware: callback raised; elapsed_ms=%.2f; "
557+ "error_type=%s; context=%s" ,
558+ middleware_kind ,
559+ (time .perf_counter () - started ) * 1000 ,
560+ type (exc ).__name__ ,
561+ context ,
562+ )
563+ raise
564+ log .info (
565+ "%s middleware: ok; elapsed_ms=%.2f; result=%s; context=%s" ,
566+ middleware_kind ,
567+ (time .perf_counter () - started ) * 1000 ,
568+ type (result ).__name__ ,
569+ context ,
570+ )
571+ return result
572+
573+ setattr (wrapper , _MIDDLEWARE_SPEC_ATTR , {"kind" : middleware_kind })
574+ return wrapper
575+
576+ return decorate
577+
578+
503579def hook (name : str ) -> Callable :
504580 """Mark and instrument a Hermes lifecycle hook callback.
505581
@@ -1240,7 +1316,7 @@ def register_plugin(
12401316 module : Any ,
12411317 skills : tuple [PluginSkill , ...] | list [PluginSkill ] = (),
12421318) -> RegistrationSummary :
1243- """Register decorated commands, tools, hooks, and skills from *module* .
1319+ """Register decorated commands, tools, middleware, hooks, and skills.
12441320
12451321 Unlike the backward-compatible :func:`register_all`, this lifecycle-level
12461322 entrypoint rejects distinct declarations that share a public name. Missing
@@ -1252,6 +1328,7 @@ def register_plugin(
12521328
12531329 commands : dict [str , Callable ] = {}
12541330 tools : dict [str , Callable ] = {}
1331+ middlewares : dict [str , Callable ] = {}
12551332 hooks : dict [str , Callable ] = {}
12561333 for _ , obj in inspect .getmembers (module ):
12571334 command_spec = getattr (obj , _COMMAND_SPEC_ATTR , None )
@@ -1268,6 +1345,15 @@ def register_plugin(
12681345 raise ValueError (f"duplicate tool name: { tool_spec ['name' ]} " )
12691346 tools [tool_spec ["name" ]] = obj
12701347
1348+ middleware_spec = getattr (obj , _MIDDLEWARE_SPEC_ATTR , None )
1349+ if middleware_spec :
1350+ existing = middlewares .get (middleware_spec ["kind" ])
1351+ if existing is not None and existing is not obj :
1352+ raise ValueError (
1353+ f"duplicate middleware kind: { middleware_spec ['kind' ]} "
1354+ )
1355+ middlewares [middleware_spec ["kind" ]] = obj
1356+
12711357 hook_spec = getattr (obj , _HOOK_SPEC_ATTR , None )
12721358 if hook_spec :
12731359 existing = hooks .get (hook_spec ["name" ])
@@ -1318,6 +1404,11 @@ def register_plugin(
13181404 _register_tool (ctx , obj , spec )
13191405 registered_tools .append (name )
13201406
1407+ registered_middlewares : list [str ] = []
1408+ for kind in sorted (middlewares ):
1409+ ctx .register_middleware (kind , middlewares [kind ])
1410+ registered_middlewares .append (kind )
1411+
13211412 registered_hooks : list [str ] = []
13221413 for name in sorted (hooks ):
13231414 ctx .register_hook (name , hooks [name ])
@@ -1335,15 +1426,17 @@ def register_plugin(
13351426 summary = RegistrationSummary (
13361427 commands = tuple (registered_commands ),
13371428 tools = tuple (registered_tools ),
1429+ middlewares = tuple (registered_middlewares ),
13381430 hooks = tuple (registered_hooks ),
13391431 skills = tuple (registered_skills ),
13401432 skipped_optional_skills = tuple (skipped_skills ),
13411433 )
13421434 log .info (
13431435 "hermes_plugin_kit: registered plugin lifecycle; commands=%s; tools=%s; "
1344- "hooks=%s; skills=%s; skipped_optional_skills=%s" ,
1436+ "middlewares=%s; hooks=%s; skills=%s; skipped_optional_skills=%s" ,
13451437 "," .join (summary .commands ) or "<none>" ,
13461438 "," .join (summary .tools ) or "<none>" ,
1439+ "," .join (summary .middlewares ) or "<none>" ,
13471440 "," .join (summary .hooks ) or "<none>" ,
13481441 "," .join (summary .skills ) or "<none>" ,
13491442 "," .join (summary .skipped_optional_skills ) or "<none>" ,
0 commit comments