mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
484 lines
16 KiB
Python
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
|