Files
hermes-agent/hermes_cli/middleware.py
Teknium a5bd246865 Old pre-decomposition import paths are gone: plugin compat layer removed on schedule (#126164)
* refactor(plugins): remove the Sep 2026 decomposition compat layer on schedule

The PLUGIN-COMPAT layer (2776813df3 + d63e380324 + 0a5164cebe) kept pre-#102117 import paths
alive for external plugins until 2026-09-14. That window closed two weeks ago; since then the loader
has already been skipping plugins that use the old paths. This removes the layer itself:

- 328 appended `PLUGIN-COMPAT` blocks (lazy `__getattr__` pointer tables, re-exported third-party
  names, restored dead definitions) and the three re-export stub modules
  (gateway/startup_watchdog, hermes_cli/observability/relay_runtime, tools/environments/modal_utils)
- COMPAT_MANIFEST.md, compat_manifest.json, scripts/check_compat_pointers.py and its lint step
- the reporting surfaces: CLI banner notice, `hermes plugins compat`, the `hermes doctor` section,
  the post-update notice, the Desktop one-time dialog, the loader's pre-import skip and the
  `plugins.allow_deprecated_imports` escape hatch

An external plugin that still imports an old path now fails to load with its ImportError as the
reason in `hermes plugins list`, the same path as any broken plugin.

hermes_cli/plugin_compat.py stays as three inert stubs (compat_report, removal_in_effect,
summary_lines): an already-running pre-removal `hermes update` lazy-imports them after the checkout
swap (tests/compat/old_updater_surface.json).

In-tree fallout, both already dead: hermes_cli/setup.py::_check_espeak_ng (no callers; its
`shutil` came from a compat block) and gateway/config.py::SessionResetPolicy ("retained solely for
the scheduled plugin-compat window"). Two test_run_agent patches targeted the removed
`run_agent.handle_function_call` pointer; they now patch `model_tools.handle_function_call`, the
seam production reads, like every sibling test in that file.

* chore: retrigger CI (zero-job startup_failure phantom)

* test: drop resolution allowlist rows for the two deleted which() sites

hermes_cli/setup.py::_check_espeak_ng (dead) and tools/skillevaluator_scan.py::scanner_available
(a restored definition inside a PLUGIN-COMPAT block) no longer exist; the stale-row gate requires
their allowlist entries go with them.
2026-09-28 10:21:41 -07:00

215 lines
8.2 KiB
Python

"""Hermes middleware contract helpers.
Observer hooks report what happened. Middleware can change what happens by rewriting a request or
wrapping the actual execution callback. Agent-loop call sites and plugins share this vocabulary.
"""
from __future__ import annotations
import logging
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List
logger = logging.getLogger(__name__)
OBSERVER_SCHEMA_VERSION = "hermes.observer.v1"
MIDDLEWARE_SCHEMA_VERSION = "hermes.middleware.v1"
TOOL_REQUEST_MIDDLEWARE = "tool_request"
TOOL_EXECUTION_MIDDLEWARE = "tool_execution"
LLM_REQUEST_MIDDLEWARE = "llm_request"
LLM_EXECUTION_MIDDLEWARE = "llm_execution"
VALID_MIDDLEWARE: set[str] = {
TOOL_REQUEST_MIDDLEWARE, TOOL_EXECUTION_MIDDLEWARE, LLM_REQUEST_MIDDLEWARE, LLM_EXECUTION_MIDDLEWARE,
}
@dataclass
class RequestMiddlewareResult:
"""Result of applying request middleware to a mutable payload."""
payload: Any
original_payload: Any
changed: bool = False
trace: List[Dict[str, Any]] = field(default_factory=list)
def observer_payload(**kwargs: Any) -> Dict[str, Any]:
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
return kwargs
def middleware_payload(**kwargs: Any) -> Dict[str, Any]:
kwargs.setdefault("telemetry_schema_version", OBSERVER_SCHEMA_VERSION)
kwargs.setdefault("middleware_schema_version", MIDDLEWARE_SCHEMA_VERSION)
return kwargs
def _safe_copy(payload: Any) -> Any:
"""Deep-copy a request payload, tolerating non-deepcopyable members.
An LLM request can carry clients/callbacks/file handles; a hard ``deepcopy`` failure would
otherwise abort the whole request-middleware pass.
"""
try:
return deepcopy(payload)
except Exception as exc: # pragma: no cover - exercised via fallback test
logger.debug("deepcopy failed for request payload (%s); using shallow copy", exc)
return dict(payload) if isinstance(payload, dict) else payload
def _apply_request_chain(
kind: str, payload_key: str, trace: List[Dict[str, Any]], original: Any, **kwargs: Any
) -> RequestMiddlewareResult:
"""Feed ``kwargs[payload_key]`` through every ``kind`` middleware; each may return ``{payload_key: {...}}``."""
from hermes_cli.plugins import invoke_middleware
current = kwargs[payload_key]
for result in invoke_middleware(kind, **middleware_payload(**kwargs)):
if not isinstance(result, dict):
continue
next_payload = result.get(payload_key)
if not isinstance(next_payload, dict):
continue
current = _safe_copy(next_payload)
entry = {
key: value
for key in ("source", "reason", "name")
if isinstance(value := result.get(key), str) and value
}
trace.append(entry or {"source": "plugin"})
return RequestMiddlewareResult(
payload=current, original_payload=original, changed=bool(trace), trace=trace,
)
def apply_llm_request_middleware(request: Dict[str, Any], **context: Any) -> RequestMiddlewareResult:
"""Apply registered LLM request middleware; ``{"request": {...}}`` replaces the provider kwargs."""
from hermes_cli.plugins import has_middleware
if not has_middleware(LLM_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(payload=request, original_payload=request)
original_request = _safe_copy(request)
return _apply_request_chain(
LLM_REQUEST_MIDDLEWARE, "request", [], original_request,
request=_safe_copy(original_request), original_request=original_request, **context,
)
def apply_tool_request_middleware(
tool_name: str, args: Dict[str, Any], **context: Any
) -> RequestMiddlewareResult:
"""Apply registered tool request middleware; ``{"args": {...}}`` replaces the effective tool
arguments before hooks, guardrails, approvals, and execution see them."""
original_args = _safe_copy(args)
current_args = _safe_copy(original_args)
trace: List[Dict[str, Any]] = []
session_id = str(context.get("session_id") or "")
skip_relay = bool(context.pop("skip_relay", False))
if session_id and not skip_relay:
from agent import relay_runtime
relay_args = relay_runtime.apply_tool_request_intercepts(
session_id=session_id, tool_name=tool_name, args=current_args)
if relay_args != current_args:
current_args = _safe_copy(relay_args)
trace.append({"source": "nemo_relay"})
from hermes_cli.plugins import has_middleware
if not has_middleware(TOOL_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=args if not trace else current_args, original_payload=args,
changed=bool(trace), trace=trace,
)
return _apply_request_chain(
TOOL_REQUEST_MIDDLEWARE, "args", trace, original_args,
tool_name=tool_name, args=current_args, original_args=original_args, **context,
)
def run_llm_execution_middleware(
request: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any) -> Any:
"""Run provider execution through registered LLM execution middleware."""
return _run_execution_chain(
LLM_EXECUTION_MIDDLEWARE, next_call,
request=request, original_request=context.pop("original_request", request), **context)
def run_tool_execution_middleware(
tool_name: str, args: Dict[str, Any], next_call: Callable[[Dict[str, Any]], Any], **context: Any,
) -> Any:
"""Run tool execution through registered tool execution middleware."""
return _run_execution_chain(
TOOL_EXECUTION_MIDDLEWARE, next_call,
tool_name=tool_name, args=args, original_args=context.pop("original_args", args), **context)
class _DownstreamExecutionError(Exception):
"""Marks an exception raised BELOW a middleware frame so the frame's own failure handling
(skip-and-continue) doesn't swallow it."""
def __init__(self, original: BaseException) -> None:
super().__init__(str(original))
self.original = original
def _run_execution_chain(kind: str, terminal_call: Callable[[Any], Any], **kwargs: Any) -> Any:
from hermes_cli.plugins import _delivery_manager
payload_key = "request" if "request" in kwargs else "args"
manager = _delivery_manager()
callbacks = list(manager._middleware.get(kind, []))
if not callbacks:
return terminal_call(kwargs[payload_key])
def call_at(index: int, payload: Any) -> Any:
if index >= len(callbacks):
return terminal_call(payload)
callback = callbacks[index]
next_called = False
next_succeeded = False
next_result: Any = None
def next_call(next_payload: Any = None) -> Any:
nonlocal next_called, next_succeeded, next_result
# Single-use per frame: a second call would re-run the downstream provider/tool, so it
# is a contract violation, not a retry.
if next_called:
raise RuntimeError(
f"Middleware '{kind}' callback "
f"{getattr(callback, '__name__', repr(callback))} called "
"next_call() more than once; downstream execution is single-use"
)
next_called = True
try:
next_result = call_at(index + 1, payload if next_payload is None else next_payload)
next_succeeded = True
return next_result
except Exception as exc:
raise _DownstreamExecutionError(exc) from exc
call_kwargs = middleware_payload(**kwargs)
call_kwargs[payload_key] = payload
call_kwargs["next_call"] = next_call
try:
return callback(**call_kwargs)
except _DownstreamExecutionError as exc:
raise exc.original
except Exception as exc:
# Runs once per tool/LLM call: a mis-declared callback fails identically every time,
# so it goes through the manager's warn-once reporter (#111922).
manager._report_hook_failure(kind, callback, call_kwargs, exc, surface="Middleware")
if next_succeeded:
return next_result
if next_called:
raise
return call_at(index + 1, payload)
return call_at(0, kwargs[payload_key])