hermes-agent/hermes_cli/middleware.py

484 lines
16 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. Keep the small
contract helpers here so agent-loop call sites and plugins share one vocabulary.
"""
from __future__ import annotations
import contextvars
import fnmatch
import json
import logging
import threading
from copy import deepcopy
from dataclasses import dataclass, field
from typing import Any, Callable, Dict, List, Optional
logger = logging.getLogger(__name__)
_TOOL_POLICY_PRIORITY = {"allow": 0, "ask": 1, "deny": 2}
class _RegistryDispatchCapability:
"""Opaque, single-use permission for one wrapper→registry handoff.
Context copies share this object's consumed state, so only one dispatch can
redeem it even if a caller copies the Context or reuses the same args.
"""
__slots__ = ("_registry", "_tool_name", "_args", "_consumed", "_lock")
def __init__(self, registry: Any, tool_name: str, args: Dict[str, Any]) -> None:
self._registry = registry
self._tool_name = tool_name
self._args = args
self._consumed = False
self._lock = threading.Lock()
def consume(self, registry: Any, tool_name: str, args: Dict[str, Any]) -> bool:
if registry is not self._registry or tool_name != self._tool_name or args is not self._args:
return False
with self._lock:
if self._consumed:
return False
self._consumed = True
return True
_pending_registry_dispatch: contextvars.ContextVar[Optional[_RegistryDispatchCapability]] = (
contextvars.ContextVar("pending_registry_dispatch", default=None)
)
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"
# Back-compat aliases for older PoC branches that used API terminology.
API_REQUEST_MIDDLEWARE = LLM_REQUEST_MIDDLEWARE
API_EXECUTION_MIDDLEWARE = LLM_EXECUTION_MIDDLEWARE
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.
Request payloads are normally plain JSON-shaped dicts, but an LLM request
can occasionally carry non-deepcopyable objects (clients, callbacks, file
handles). A hard ``deepcopy`` failure there would otherwise abort the whole
request-middleware pass. Fall back to a shallow ``dict`` copy so middleware
still runs and the original nested objects are shared by reference rather
than corrupting the live payload.
"""
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)
if isinstance(payload, dict):
return dict(payload)
return payload
def apply_llm_request_middleware(
request: Dict[str, Any],
**context: Any,
) -> RequestMiddlewareResult:
"""Apply registered LLM request middleware.
Middleware may return ``{"request": {...}}`` to replace the effective
provider kwargs before Hermes sends them.
"""
if not _has_middleware(LLM_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=request,
original_payload=request,
changed=False,
trace=[],
)
original_request = _safe_copy(request)
current_request = _safe_copy(original_request)
trace: List[Dict[str, Any]] = []
for result in _invoke_middleware(
LLM_REQUEST_MIDDLEWARE,
request=current_request,
original_request=original_request,
**context,
):
if not isinstance(result, dict):
continue
next_request = result.get("request")
if not isinstance(next_request, dict):
continue
current_request = _safe_copy(next_request)
trace.append(_trace_entry(result))
return RequestMiddlewareResult(
payload=current_request,
original_payload=original_request,
changed=bool(trace),
trace=trace,
)
def apply_tool_request_middleware(
tool_name: str,
args: Dict[str, Any],
**context: Any,
) -> RequestMiddlewareResult:
"""Apply registered tool request middleware.
Middleware may return ``{"args": {...}}`` to replace the effective tool
arguments before hooks, guardrails, approvals, and execution see them.
"""
if not _has_middleware(TOOL_REQUEST_MIDDLEWARE):
return RequestMiddlewareResult(
payload=args,
original_payload=args,
changed=False,
trace=[],
)
original_args = _safe_copy(args)
current_args = _safe_copy(original_args)
trace: List[Dict[str, Any]] = []
for result in _invoke_middleware(
TOOL_REQUEST_MIDDLEWARE,
tool_name=tool_name,
args=current_args,
original_args=original_args,
**context,
):
if not isinstance(result, dict):
continue
next_args = result.get("args")
if not isinstance(next_args, dict):
continue
current_args = _safe_copy(next_args)
trace.append(_trace_entry(result))
return RequestMiddlewareResult(
payload=current_args,
original_payload=original_args,
changed=bool(trace),
trace=trace,
)
def apply_api_request_middleware(
request: Dict[str, Any],
**context: Any,
) -> RequestMiddlewareResult:
"""Compatibility wrapper for older ``api_request`` naming."""
return apply_llm_request_middleware(request, **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."""
callbacks = _get_middleware_callbacks(LLM_EXECUTION_MIDDLEWARE)
if not callbacks:
return next_call(request)
return _run_execution_chain(
LLM_EXECUTION_MIDDLEWARE,
callbacks,
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 a tool through the shared policy gate and plugin middleware chain.
``dispatch_registry`` marks a wrapper whose immediate ``next_call`` enters
that registry. Only that exact wrapper→registry handoff is deduplicated;
nested dispatches from inside the handler remain genuine executions.
"""
dispatch_registry = context.pop("dispatch_registry", None)
registry_entry = context.pop("registry_entry", None)
registry_dispatch = context.pop("registry_dispatch", None)
expected_dispatch = _pending_registry_dispatch.get()
is_wrapped_registry_dispatch = bool(
registry_dispatch
and expected_dispatch is not None
and expected_dispatch.consume(registry_dispatch, tool_name, args)
)
if is_wrapped_registry_dispatch:
_pending_registry_dispatch.set(None)
return next_call(args)
policy = (
resolve_tool_approval_policy(tool_name, entry=registry_entry)
if registry_entry is not None
else resolve_tool_approval_policy(tool_name)
)
blocked = _apply_tool_approval_policy(tool_name, policy)
if blocked is not None:
return blocked
terminal_allow_token = None
if tool_name == "terminal" and policy == "allow":
from tools.approval import set_tool_policy_terminal_allow
terminal_allow_token = set_tool_policy_terminal_allow()
def call_next(next_args: Dict[str, Any]) -> Any:
if dispatch_registry is None:
return next_call(next_args)
token = _pending_registry_dispatch.set(
_RegistryDispatchCapability(dispatch_registry, tool_name, next_args)
)
try:
return next_call(next_args)
finally:
_pending_registry_dispatch.reset(token)
try:
callbacks = _get_middleware_callbacks(TOOL_EXECUTION_MIDDLEWARE)
if not callbacks:
return call_next(args)
return _run_execution_chain(
TOOL_EXECUTION_MIDDLEWARE,
callbacks,
call_next,
tool_name=tool_name,
args=args,
original_args=context.pop("original_args", args),
**context,
)
finally:
if terminal_allow_token is not None:
from tools.approval import reset_tool_policy_terminal_allow
reset_tool_policy_terminal_allow(terminal_allow_token)
def resolve_tool_approval_policy(
tool_name: str, *, entry: Any = None
) -> Optional[str]:
"""Resolve glob policies for a tool name and its actual dispatch entry."""
try:
from hermes_cli.config import load_config
config = load_config() or {}
approvals = config.get("approvals", {}) or {}
patterns = approvals.get("tool_policies", {}) or {}
except Exception as exc:
logger.warning("Failed to load tool approval policies: %s", exc)
return None
if not isinstance(patterns, dict):
return None
identifiers = [str(tool_name or "").strip().lower()]
toolset = str(getattr(entry, "toolset", "") or "").strip().lower()
if toolset:
identifiers.append(toolset)
matches: list[str] = []
for pattern, raw_policy in patterns.items():
if not isinstance(pattern, str) or not isinstance(raw_policy, str):
continue
normalized_pattern = pattern.strip().lower()
policy = raw_policy.strip().lower()
if not normalized_pattern or policy not in _TOOL_POLICY_PRIORITY:
continue
if any(
fnmatch.fnmatchcase(identifier, normalized_pattern)
for identifier in identifiers
):
matches.append(policy)
if not matches:
return None
return max(matches, key=_TOOL_POLICY_PRIORITY.__getitem__)
def _apply_tool_approval_policy(
tool_name: str, policy: Optional[str]
) -> Optional[str]:
"""Return a synthetic blocked result, or None to continue execution."""
if policy == "deny":
return json.dumps(
{
"error": (
f"BLOCKED: Tool '{tool_name}' is denied by "
"approvals.tool_policies in config.yaml."
),
"policy": "deny",
"tool": tool_name,
},
ensure_ascii=False,
)
if policy != "ask":
return None
try:
from tools.approval import request_tool_approval
decision = request_tool_approval(
tool_name,
f"config.yaml requires approval for tool '{tool_name}'",
rule_key=f"tool_policy:{tool_name}",
enforce_under_yolo=True,
)
except Exception as exc:
logger.warning("Tool approval policy check failed for %s: %s", tool_name, exc)
decision = {
"approved": False,
"message": "BLOCKED: tool approval policy could not be evaluated.",
}
if decision.get("approved"):
return None
payload = dict(decision)
payload["error"] = payload.pop("message", None) or (
f"BLOCKED: Tool '{tool_name}' was not approved."
)
payload.setdefault("policy", "ask")
payload.setdefault("tool", tool_name)
return json.dumps(payload, ensure_ascii=False)
def run_api_execution_middleware(
request: Dict[str, Any],
next_call: Callable[[Dict[str, Any]], Any],
**context: Any,
) -> Any:
"""Compatibility wrapper for older ``api_execution`` naming."""
return run_llm_execution_middleware(request, next_call, **context)
def _invoke_middleware(kind: str, **kwargs: Any) -> List[Any]:
from hermes_cli.plugins import invoke_middleware
return invoke_middleware(kind, **middleware_payload(**kwargs))
def _has_middleware(kind: str) -> bool:
from hermes_cli.plugins import has_middleware
return has_middleware(kind)
def _get_middleware_callbacks(kind: str) -> List[Callable]:
from hermes_cli.plugins import get_plugin_manager
return list(get_plugin_manager()._middleware.get(kind, []))
def _run_execution_chain(
kind: str,
callbacks: List[Callable],
terminal_call: Callable[[Any], Any],
**kwargs: Any,
) -> Any:
payload_key = "request" if "request" in kwargs else "args"
class _DownstreamExecutionError(Exception):
def __init__(self, original: BaseException) -> None:
super().__init__(str(original))
self.original = original
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
# ``next_call`` is single-use per middleware frame. Calling it more
# than once would re-run the downstream provider/tool, so a second
# invocation is a contract violation rather than a retry. Surface it
# instead of silently executing the terminal call twice.
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:
logger.warning(
"Middleware '%s' callback %s raised: %s",
kind,
getattr(callback, "__name__", repr(callback)),
exc,
)
if next_succeeded:
return next_result
if next_called:
raise
return call_at(index + 1, payload)
return call_at(0, kwargs[payload_key])
def _trace_entry(result: Dict[str, Any]) -> Dict[str, Any]:
entry: Dict[str, Any] = {}
for key in ("source", "reason", "name"):
value = result.get(key)
if isinstance(value, str) and value:
entry[key] = value
if not entry:
entry["source"] = "plugin"
return entry