fix(hermes): vendor HUX runtime hooks
This commit is contained in:
parent
438180a99f
commit
a1070449a7
24
services/hermes/plugins/hux-runtime/hux_hook/__init__.py
Normal file
24
services/hermes/plugins/hux-runtime/hux_hook/__init__.py
Normal file
@ -0,0 +1,24 @@
|
||||
"""Agent-side hook library for the per-tenant HUX service.
|
||||
|
||||
Stdlib only. The Hermes agent process (tenant pod or Worker) calls these
|
||||
functions around its tool loop so that HUX, not the agent, decides approvals,
|
||||
gates, budgets, stop receipts and what lands on the activity timeline.
|
||||
Side effects fail closed when the service is unreachable; telemetry fails open.
|
||||
"""
|
||||
|
||||
from .client import HuxClient, HuxServiceError, HuxUnavailable
|
||||
from .hooks import (
|
||||
Decision,
|
||||
after_tool,
|
||||
before_tool,
|
||||
canonical_argument_hash,
|
||||
emit,
|
||||
memory_gate,
|
||||
on_stop,
|
||||
record_spend,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Decision", "HuxClient", "HuxServiceError", "HuxUnavailable", "after_tool", "before_tool",
|
||||
"canonical_argument_hash", "emit", "memory_gate", "on_stop", "record_spend",
|
||||
]
|
||||
300
services/hermes/plugins/hux-runtime/hux_hook/client.py
Normal file
300
services/hermes/plugins/hux-runtime/hux_hook/client.py
Normal file
@ -0,0 +1,300 @@
|
||||
"""Thin urllib client for the HUX service on the pod loopback.
|
||||
|
||||
The client owns three things: the identity headers ``hux.identity`` expects,
|
||||
the mapping of ``hux.error.v1`` bodies to ``HuxServiceError``, and the
|
||||
per-process capabilities cache. It never logs or embeds request bodies in
|
||||
exceptions (SO-07, SO-11): an error carries status, code and the service's
|
||||
own message only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import stat
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
HEADER_SLOT = "X-Hermes-Tenant-Identity"
|
||||
HEADER_SUBJECT = "X-Hux-Subject"
|
||||
HEADER_SURFACE = "X-Hux-Surface"
|
||||
HEADER_TRUST = "X-Hux-Trust"
|
||||
HEADER_KEY = "X-Hux-Relay-Key"
|
||||
DEFAULT_BASE_URL = "http://127.0.0.1:8790"
|
||||
MESSAGE_MAX = 280
|
||||
MAX_RESPONSE_BYTES = 4 * 1024 * 1024
|
||||
MAX_KEY_BYTES = 4096
|
||||
MAX_SUBJECT_BYTES = 128
|
||||
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1"})
|
||||
SUBJECT_RE = re.compile(r"^usr_[0-9a-f]{16,64}$")
|
||||
|
||||
|
||||
class _RejectRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Never replay tenant identity or worker credentials to a redirect target."""
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, ANN201, ARG002
|
||||
"""Refuse every redirect rather than replaying the original headers."""
|
||||
return None
|
||||
|
||||
|
||||
def _validated_base_url(raw: str) -> str:
|
||||
"""Return a canonical literal-loopback HTTP origin or reject it."""
|
||||
parts = urllib.parse.urlsplit(raw)
|
||||
if (
|
||||
parts.scheme != "http"
|
||||
or parts.hostname not in LOOPBACK_HOSTS
|
||||
or parts.username is not None
|
||||
or parts.password is not None
|
||||
or parts.query
|
||||
or parts.fragment
|
||||
or parts.path not in {"", "/"}
|
||||
or parts.port is None
|
||||
):
|
||||
raise ValueError("HUX base URL must be a literal loopback HTTP origin with an explicit port")
|
||||
host = f"[{parts.hostname}]" if parts.hostname == "::1" else parts.hostname
|
||||
return f"http://{host}:{parts.port}"
|
||||
|
||||
|
||||
def _validated_path(path: str) -> str:
|
||||
"""Accept only canonical relative HUX API paths owned by this client."""
|
||||
if not isinstance(path, str):
|
||||
raise HuxServiceError(400, "invalid", "malformed request path")
|
||||
decoded = urllib.parse.unquote(path)
|
||||
if (
|
||||
not path.startswith("/hux/v1/")
|
||||
or path.startswith("//")
|
||||
or any(char in path for char in "?#\\\r\n")
|
||||
or any(segment in {".", ".."} for segment in decoded.split("/"))
|
||||
):
|
||||
raise HuxServiceError(400, "invalid", "malformed request path")
|
||||
return path
|
||||
|
||||
|
||||
def _key_from_file(key_file: str | Path) -> str:
|
||||
"""Read one 0400 regular-file credential without accepting weak permissions or unbounded data."""
|
||||
path = Path(key_file)
|
||||
try:
|
||||
mode = path.stat().st_mode
|
||||
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) != 0o400:
|
||||
raise ValueError("HUX key file must be a 0400 regular file")
|
||||
data = path.read_bytes()
|
||||
if not data or len(data) > MAX_KEY_BYTES:
|
||||
raise ValueError("HUX key file is empty or oversized")
|
||||
value = data.decode("utf-8", errors="strict").strip()
|
||||
if not value:
|
||||
raise ValueError("HUX key file is empty")
|
||||
return value
|
||||
except OSError as error:
|
||||
raise ValueError("HUX key file is unavailable") from error
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("HUX key file is not UTF-8") from error
|
||||
|
||||
|
||||
def _subject_from_file(subject_file: str | Path) -> str:
|
||||
"""Read one router-published subject binding from a read-only shared file."""
|
||||
path = Path(subject_file)
|
||||
flags = os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)
|
||||
try:
|
||||
descriptor = os.open(path, flags)
|
||||
except OSError as error:
|
||||
raise ValueError("HUX subject file is unavailable") from error
|
||||
try:
|
||||
mode = os.fstat(descriptor).st_mode
|
||||
if not stat.S_ISREG(mode) or stat.S_IMODE(mode) not in {0o400, 0o440}:
|
||||
raise ValueError("HUX subject file must be a 0400 or 0440 regular file")
|
||||
with os.fdopen(descriptor, "rb", closefd=False) as subject_stream:
|
||||
data = subject_stream.read(MAX_SUBJECT_BYTES + 1)
|
||||
except OSError as error:
|
||||
raise ValueError("HUX subject file is unavailable") from error
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if not data or len(data) > MAX_SUBJECT_BYTES:
|
||||
raise ValueError("HUX subject file is empty or oversized")
|
||||
try:
|
||||
value = data.decode("utf-8", errors="strict").strip()
|
||||
except UnicodeDecodeError as error:
|
||||
raise ValueError("HUX subject file is not UTF-8") from error
|
||||
if not SUBJECT_RE.fullmatch(value):
|
||||
raise ValueError("HUX subject file is malformed")
|
||||
return value
|
||||
|
||||
|
||||
class HuxServiceError(Exception):
|
||||
"""The service answered with a ``hux.error.v1`` body (or a non-JSON failure)."""
|
||||
|
||||
def __init__(self, status: int, code: str, message: str) -> None:
|
||||
super().__init__(f"{status} {code}: {message}")
|
||||
self.status = int(status)
|
||||
self.code = str(code)
|
||||
self.message = str(message)[:MESSAGE_MAX]
|
||||
|
||||
|
||||
class HuxUnavailable(HuxServiceError):
|
||||
"""The service could not be reached at all; callers decide open or closed."""
|
||||
|
||||
def __init__(self, message: str = "hux service unreachable") -> None:
|
||||
super().__init__(0, "unavailable", message)
|
||||
|
||||
|
||||
class HuxResponse:
|
||||
"""Status, parsed JSON body and headers of one answer."""
|
||||
|
||||
def __init__(self, status: int, body: Any, headers: Mapping[str, str]) -> None:
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.headers = {k.lower(): v for k, v in headers.items()}
|
||||
|
||||
def header(self, name: str) -> str:
|
||||
"""Case-insensitive header lookup; empty when absent."""
|
||||
return self.headers.get(name.lower(), "")
|
||||
|
||||
|
||||
def _error_from(status: int, body: Any) -> HuxServiceError:
|
||||
if isinstance(body, dict) and body.get("schema") == "hux.error.v1":
|
||||
return HuxServiceError(int(body.get("status", status)), str(body.get("code", "invalid")), str(body.get("message", "")))
|
||||
return HuxServiceError(status, "invalid", f"unexpected response {status}")
|
||||
|
||||
|
||||
class HuxClient:
|
||||
"""One tenant identity talking to one HUX base URL.
|
||||
|
||||
``identity`` carries ``tenant_slot``, ``subject``, ``surface`` and
|
||||
``trust``; ``key`` is the relay or worker shared key when ``trust`` needs
|
||||
one. The agent hook normally runs as ``surface=worker, trust=worker``.
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str = DEFAULT_BASE_URL, identity: Mapping[str, str] | None = None,
|
||||
key: str | None = None, timeout: float = 5, *, key_file: str | Path | None = None,
|
||||
subject_file: str | Path | None = None) -> None:
|
||||
identity = dict(identity or {})
|
||||
configured_subject_file = subject_file if subject_file is not None else os.environ.get("HUX_SUBJECT_FILE")
|
||||
if configured_subject_file:
|
||||
bound_subject = _subject_from_file(configured_subject_file)
|
||||
asserted_subject = str(identity.get("subject", ""))
|
||||
if asserted_subject and not hmac.compare_digest(asserted_subject, bound_subject):
|
||||
raise ValueError("HUX identity subject conflicts with the trusted binding")
|
||||
identity["subject"] = bound_subject
|
||||
self.base_url = _validated_base_url(base_url)
|
||||
self.identity = {
|
||||
"tenant_slot": str(identity.get("tenant_slot", "")), "subject": str(identity.get("subject", "")),
|
||||
"surface": str(identity.get("surface", "worker")), "trust": str(identity.get("trust", "worker")),
|
||||
}
|
||||
if key is not None and key_file is not None:
|
||||
raise ValueError("set either key or key_file, not both")
|
||||
self._key = _key_from_file(key_file) if key_file is not None else key
|
||||
if isinstance(timeout, bool) or not isinstance(timeout, int | float) or not 0.1 <= float(timeout) <= 30:
|
||||
raise ValueError("timeout must be between 0.1 and 30 seconds")
|
||||
self.timeout = float(timeout)
|
||||
self._opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _RejectRedirect())
|
||||
self._capabilities: dict[str, Any] | None = None
|
||||
self._guard = threading.Lock()
|
||||
|
||||
# -- transport -------------------------------------------------------------
|
||||
|
||||
def headers(self, extra: Mapping[str, str] | None = None) -> dict[str, str]:
|
||||
"""Identity headers exactly as ``hux.identity.resolve`` reads them, plus ``extra``."""
|
||||
out = {
|
||||
HEADER_SLOT: self.identity["tenant_slot"], HEADER_SUBJECT: self.identity["subject"],
|
||||
HEADER_SURFACE: self.identity["surface"], HEADER_TRUST: self.identity["trust"],
|
||||
"Accept": "application/json",
|
||||
}
|
||||
if self._key:
|
||||
out[HEADER_KEY] = self._key
|
||||
for name, value in (extra or {}).items():
|
||||
if value:
|
||||
out[name] = str(value)
|
||||
return out
|
||||
|
||||
def request(self, method: str, path: str, body: Any = None, *, idempotency_key: str | None = None,
|
||||
if_match: int | str | None = None, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""Send one request; raise ``HuxServiceError`` on 4xx/5xx and ``HuxUnavailable`` on transport failure."""
|
||||
extra: dict[str, str] = {}
|
||||
if idempotency_key:
|
||||
extra["Idempotency-Key"] = idempotency_key
|
||||
if if_match is not None:
|
||||
extra["If-Match"] = str(if_match)
|
||||
data = None
|
||||
if body is not None:
|
||||
data = json.dumps(body, sort_keys=True, separators=(",", ":")).encode()
|
||||
extra["Content-Type"] = "application/json"
|
||||
url = self.base_url + _validated_path(path)
|
||||
if query:
|
||||
url += "?" + urllib.parse.urlencode({str(k): str(v) for k, v in query.items()})
|
||||
req = urllib.request.Request(url, data=data, method=method, headers=self.headers(extra))
|
||||
try:
|
||||
with self._opener.open(req, timeout=self.timeout) as raw: # noqa: S310 - validated literal loopback only
|
||||
response = HuxResponse(raw.status, _decode(_bounded_read(raw)), dict(raw.headers.items()))
|
||||
except urllib.error.HTTPError as error:
|
||||
payload = _decode(_bounded_read(error))
|
||||
raise _error_from(error.code, payload) from None
|
||||
except (urllib.error.URLError, OSError, TimeoutError) as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
except http.client.InvalidURL:
|
||||
# http.client refuses control characters and spaces in the path; treat it as our own bad request.
|
||||
raise HuxServiceError(400, "invalid", "malformed request path") from None
|
||||
except http.client.HTTPException as error:
|
||||
raise HuxUnavailable(f"hux service unreachable: {type(error).__name__}") from None
|
||||
if response.status >= 400:
|
||||
raise _error_from(response.status, response.body)
|
||||
return response
|
||||
|
||||
def get(self, path: str, query: Mapping[str, str] | None = None) -> HuxResponse:
|
||||
"""``GET path``."""
|
||||
return self.request("GET", path, query=query)
|
||||
|
||||
def post(self, path: str, body: Any, idempotency_key: str | None = None) -> HuxResponse:
|
||||
"""``POST path`` with an optional ``Idempotency-Key``."""
|
||||
return self.request("POST", path, body, idempotency_key=idempotency_key)
|
||||
|
||||
def put(self, path: str, body: Any, if_match: int | str | None = None) -> HuxResponse:
|
||||
"""``PUT path`` with an optional ``If-Match`` revision."""
|
||||
return self.request("PUT", path, body, if_match=if_match)
|
||||
|
||||
# -- capabilities ------------------------------------------------------------
|
||||
|
||||
def capabilities(self, refresh: bool = False) -> dict[str, Any]:
|
||||
"""Which cards are on, cached per process; unreachable or off means every card reads as off."""
|
||||
with self._guard:
|
||||
if self._capabilities is not None and not refresh:
|
||||
return self._capabilities
|
||||
try:
|
||||
body = self.get("/hux/v1/capabilities").body
|
||||
except HuxServiceError as error:
|
||||
return {"reachable": error.code != "unavailable", "cards": {}, "contract_version": ""}
|
||||
cards = {c["card"]: bool(c.get("enabled")) for c in body.get("cards", []) if isinstance(c, dict) and "card" in c}
|
||||
self._capabilities = {"reachable": True, "cards": cards, "contract_version": str(body.get("contract_version", ""))}
|
||||
return self._capabilities
|
||||
|
||||
def card_enabled(self, card: str) -> bool:
|
||||
"""True only when the service answered and reports ``card`` on."""
|
||||
return bool(self.capabilities()["cards"].get(card))
|
||||
|
||||
def forget_capabilities(self) -> None:
|
||||
"""Drop the cache so the next call re-reads flags (used after a reload signal)."""
|
||||
with self._guard:
|
||||
self._capabilities = None
|
||||
|
||||
|
||||
def _decode(raw: bytes) -> Any:
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _bounded_read(raw: Any) -> bytes:
|
||||
"""Read one bounded response so a compromised sidecar cannot exhaust the worker."""
|
||||
data = raw.read(MAX_RESPONSE_BYTES + 1)
|
||||
if len(data) > MAX_RESPONSE_BYTES:
|
||||
raise HuxUnavailable("hux service returned an oversized response")
|
||||
return data
|
||||
193
services/hermes/plugins/hux-runtime/hux_hook/hooks.py
Normal file
193
services/hermes/plugins/hux-runtime/hux_hook/hooks.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""Pure hook functions the agent calls around its tool loop.
|
||||
|
||||
Every function takes a ``HuxClient`` and never touches the agent's own state.
|
||||
``before_tool`` is the only call whose answer the agent must obey: it asks for
|
||||
an approval, then asks the gate, and fails closed on any doubt (SO-37, SO-39,
|
||||
SO-40). ``after_tool``, ``record_spend`` and ``emit`` are telemetry: they never
|
||||
raise into the loop and never carry raw arguments or tool output (SO-11).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .client import HuxClient, HuxServiceError
|
||||
|
||||
CARD_EVENTS = "HUX-01"
|
||||
CARD_AUTONOMY = "HUX-05"
|
||||
CARD_PRIVACY = "HUX-10"
|
||||
SPEND_KEYS = ("tokens", "tool_calls", "wall_clock_seconds", "delegations", "spend_units", "subagents")
|
||||
RISKS = ("low", "medium", "high")
|
||||
PATH_SEGMENT = re.compile(r"^[A-Za-z0-9._:-]{1,120}$")
|
||||
KEY_CHARS = re.compile(r"[^A-Za-z0-9._:-]")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Decision:
|
||||
"""What the agent may do next; ``proceed`` is False unless the gate released the call."""
|
||||
|
||||
proceed: bool
|
||||
approval_id: str | None
|
||||
reason: str
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
"""Canonical JSON: sorted keys, no whitespace, ASCII escapes; key order and formatting cannot change it."""
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def canonical_argument_hash(tool_name: str, arguments: Any) -> str:
|
||||
"""``sha256:<hex>`` over the canonical JSON of ``{"tool": name, "arguments": ...}``; the gate compares this exact value."""
|
||||
return "sha256:" + hashlib.sha256(canonical_json({"tool": tool_name, "arguments": arguments})).hexdigest()
|
||||
|
||||
|
||||
def call_ref(tool_name: str, argument_hash: str) -> str:
|
||||
"""Evidence id for one tool call: name plus a hash prefix, never the arguments."""
|
||||
return f"{KEY_CHARS.sub('-', tool_name)[:40]}:{argument_hash[7:23]}"
|
||||
|
||||
|
||||
def idempotency_key(run_id: str, kind: str, token: str) -> str:
|
||||
"""A key that satisfies the contract pattern whatever the run id looks like."""
|
||||
key = KEY_CHARS.sub("-", f"{run_id}:{kind}:{token}")
|
||||
return key[:120] if len(key) >= 8 else (key + ".pad").ljust(8, "0")
|
||||
|
||||
|
||||
def _safe_summary(tool_name: str, capability: str, argument_hash: str, size: int) -> str:
|
||||
return f"{tool_name} ({capability}) arguments {argument_hash[:23]} {size} bytes"[:280]
|
||||
|
||||
|
||||
def before_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, arguments: Any, capability: str,
|
||||
external: bool = False, risk: str = "medium", turn: int | None = None) -> Decision:
|
||||
"""Request (or replay) the approval for this exact call, then ask the gate. Fails closed.
|
||||
|
||||
A pending approval returns ``proceed=False, reason="approval_required"`` with
|
||||
the approval id so the UI can prompt; the agent calls again with the same
|
||||
arguments once the human has decided and the idempotent replay reaches the gate.
|
||||
"""
|
||||
if not PATH_SEGMENT.match(str(run_id)):
|
||||
return Decision(False, None, "invalid_run_id")
|
||||
if not client.card_enabled(CARD_AUTONOMY):
|
||||
return Decision(False, None, "hux_unavailable" if not client.capabilities()["reachable"] else "autonomy_off")
|
||||
try:
|
||||
# A zero-spend write pins the trusted worker's run -> conversation
|
||||
# binding before a scoped session grant can create a new approval.
|
||||
client.post(f"/hux/v1/runs/{run_id}/budget", {"conversation_id": conversation_id})
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, None, _failure_reason(error))
|
||||
argument_hash = canonical_argument_hash(tool_name, arguments)
|
||||
size = len(canonical_json(arguments))
|
||||
body = {
|
||||
"run_id": run_id, "conversation_id": conversation_id, "capability": capability,
|
||||
"request": {
|
||||
"summary": _safe_summary(tool_name, capability, argument_hash, size),
|
||||
"risk": risk if risk in RISKS else "high", "external": bool(external),
|
||||
"evidence": [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}],
|
||||
},
|
||||
}
|
||||
try:
|
||||
approval = client.post("/hux/v1/approvals", body, idempotency_key(run_id, "approval", argument_hash[7:39])).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, None, _failure_reason(error))
|
||||
approval_id = approval.get("id") if isinstance(approval, dict) else None
|
||||
status = approval.get("status") if isinstance(approval, dict) else None
|
||||
if status == "pending":
|
||||
return Decision(False, approval_id, "approval_required")
|
||||
if status != "approved":
|
||||
return Decision(False, approval_id, f"approval_{status or 'invalid'}")
|
||||
gate_body = {"capability": capability, "argument_hash": argument_hash, "external": bool(external), "conversation_id": conversation_id}
|
||||
try:
|
||||
verdict = client.post(f"/hux/v1/runs/{run_id}/gate", gate_body).body
|
||||
except HuxServiceError as error:
|
||||
return Decision(False, approval_id, _failure_reason(error))
|
||||
if not isinstance(verdict, dict) or verdict.get("proceed") is not True:
|
||||
reason = verdict.get("reason", "gate_blocked") if isinstance(verdict, dict) else "gate_invalid"
|
||||
return Decision(False, approval_id, str(reason)[:280])
|
||||
return Decision(True, str(verdict.get("approval_id") or approval_id), "released")
|
||||
|
||||
|
||||
def _failure_reason(error: HuxServiceError) -> str:
|
||||
if error.code in {"unavailable", "budget_exhausted", "flag_off", "approval_required"}:
|
||||
return "hux_unavailable" if error.code == "unavailable" else error.code
|
||||
return f"service_error:{error.code}"
|
||||
|
||||
|
||||
def emit(client: HuxClient, conversation_id: str, kind: str, summary: str, detail: dict[str, Any] | None = None,
|
||||
evidence: list[dict[str, Any]] | None = None, sensitivity: str = "personal", run_id: str | None = None,
|
||||
turn: int | None = None, correlation_id: str | None = None, idempotency_key: str | None = None) -> dict[str, Any] | None:
|
||||
"""Best-effort activity event; returns the stored record, or None when it could not be recorded."""
|
||||
body: dict[str, Any] = {"kind": kind, "summary": str(summary)[:280], "sensitivity": sensitivity}
|
||||
if detail:
|
||||
body["detail"] = detail
|
||||
if evidence:
|
||||
body["evidence"] = evidence
|
||||
if run_id:
|
||||
body["run_id"] = run_id
|
||||
if turn is not None:
|
||||
body["turn"] = int(turn)
|
||||
if correlation_id:
|
||||
body["correlation_id"] = correlation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/conversations/{conversation_id}/events", body, idempotency_key)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def after_tool(client: HuxClient, run_id: str, conversation_id: str, tool_name: str, ok: bool, bytes_out: int,
|
||||
turn: int | None = None, argument_hash: str | None = None, duration_ms: int | None = None,
|
||||
exit_code: int | None = None) -> dict[str, Any] | None:
|
||||
"""Emit ``tool.result`` with status and sizes only; the output itself never leaves the agent."""
|
||||
detail: dict[str, Any] = {"tool": tool_name, "ok": bool(ok), "bytes": max(0, int(bytes_out))}
|
||||
if duration_ms is not None:
|
||||
detail["duration_ms"] = max(0, int(duration_ms))
|
||||
if exit_code is not None:
|
||||
detail["exit_code"] = int(exit_code)
|
||||
evidence = [{"kind": "tool_call", "id": call_ref(tool_name, argument_hash), "hash": argument_hash}] if argument_hash else None
|
||||
summary = f"{tool_name} {'succeeded' if ok else 'failed'} ({detail['bytes']} bytes)"
|
||||
key = idempotency_key(run_id, "result", f"{turn or 0}:{(argument_hash or 'none')[7:23]}")
|
||||
return emit(client, conversation_id, "tool.result", summary, detail, evidence, run_id=run_id, turn=turn, idempotency_key=key)
|
||||
|
||||
|
||||
def record_spend(client: HuxClient, run_id: str, conversation_id: str | None = None, **increments: int) -> dict[str, Any] | None:
|
||||
"""Add spend to the run budget; returns the new ``hux.budget_state.v1`` or None. Never raises."""
|
||||
body: dict[str, Any] = {k: max(0, int(v)) for k, v in increments.items() if k in SPEND_KEYS}
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/budget", body)
|
||||
except (HuxServiceError, TypeError, ValueError):
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def on_stop(client: HuxClient, run_id: str, conversation_id: str | None, process_registry_empty: bool,
|
||||
side_effects: list[dict[str, Any]] | None = None, already_complete: bool = False) -> dict[str, Any] | None:
|
||||
"""Write the cancellation receipt (SO-41). None means no receipt exists and the stop is not done."""
|
||||
body: dict[str, Any] = {"process_registry_empty": bool(process_registry_empty), "side_effects": list(side_effects or [])}
|
||||
if already_complete:
|
||||
body["already_complete"] = True
|
||||
if conversation_id:
|
||||
body["conversation_id"] = conversation_id
|
||||
try:
|
||||
response = client.post(f"/hux/v1/runs/{run_id}/stop", body)
|
||||
except HuxServiceError:
|
||||
return None
|
||||
return response.body if isinstance(response.body, dict) else None
|
||||
|
||||
|
||||
def memory_gate(client: HuxClient, conversation_id: str) -> bool:
|
||||
"""May the agent propose a memory write from this conversation? Conservative False when in doubt.
|
||||
|
||||
Requires the privacy card to answer (SO-27 header is passed through as a
|
||||
hint only); a conversation whose mode is ``private`` never writes (SO-28).
|
||||
The service still enforces forget and disable state on the write itself.
|
||||
"""
|
||||
try:
|
||||
state = client.get(f"/hux/v1/conversations/{conversation_id}/privacy").body
|
||||
except HuxServiceError:
|
||||
return False
|
||||
return bool(isinstance(state, dict) and state.get("memory_writes_allowed"))
|
||||
@ -9,7 +9,7 @@ import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from hux_hook import (
|
||||
from .hux_hook import (
|
||||
HuxClient,
|
||||
after_tool,
|
||||
before_tool,
|
||||
|
||||
89
testing/tests/test_hermes_hux_runtime_vendor_parity.py
Normal file
89
testing/tests/test_hermes_hux_runtime_vendor_parity.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""Keep the Kustomize-local HUX hook package identical to its canonical source."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import importlib.util
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
CANONICAL = ROOT / "dockerfiles" / "hermes-worker-hux" / "hux_hook"
|
||||
VENDORED = ROOT / "services" / "hermes" / "plugins" / "hux-runtime" / "hux_hook"
|
||||
FILES = ("__init__.py", "client.py", "hooks.py")
|
||||
|
||||
|
||||
def _canonical_bytes(path: Path, vendored: bool) -> bytes:
|
||||
"""Normalize only the package relocation imports before byte comparison."""
|
||||
value = path.read_bytes()
|
||||
if vendored:
|
||||
value = value.replace(b"from .client import", b"from hux_hook.client import")
|
||||
value = value.replace(b"from .hooks import", b"from hux_hook.hooks import")
|
||||
return value
|
||||
|
||||
|
||||
class _CanonicalImports(ast.NodeTransformer):
|
||||
"""Normalize the same relative imports for structural comparison."""
|
||||
|
||||
def visit_ImportFrom(self, node: ast.ImportFrom): # noqa: N802
|
||||
if node.level == 1 and node.module in {"client", "hooks"}:
|
||||
node.level = 0
|
||||
node.module = f"hux_hook.{node.module}"
|
||||
return node
|
||||
|
||||
|
||||
def _canonical_ast(value: bytes) -> str:
|
||||
tree = _CanonicalImports().visit(ast.parse(value))
|
||||
return ast.dump(ast.fix_missing_locations(tree), include_attributes=False)
|
||||
|
||||
|
||||
def _load(name: str, root: Path):
|
||||
"""Load one package under a unique name so both copies coexist."""
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
name,
|
||||
root / "__init__.py",
|
||||
submodule_search_locations=[str(root)],
|
||||
)
|
||||
assert spec and spec.loader
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def test_vendored_hook_bytes_and_ast_match_canonical_source():
|
||||
"""Any canonical change requires an intentional vendor refresh in the same diff."""
|
||||
for name in FILES:
|
||||
canonical = _canonical_bytes(CANONICAL / name, False)
|
||||
vendored = _canonical_bytes(VENDORED / name, True)
|
||||
assert vendored == canonical, f"refresh vendored hux_hook/{name}"
|
||||
assert _canonical_ast((VENDORED / name).read_bytes()) == _canonical_ast(
|
||||
(CANONICAL / name).read_bytes()
|
||||
)
|
||||
|
||||
|
||||
def test_vendored_hook_exports_and_signatures_match_canonical_api():
|
||||
"""Protect the runtime from a byte-equal but incorrectly loaded package surface."""
|
||||
for name in tuple(sys.modules):
|
||||
if name == "hux_hook" or name.startswith("hux_hook."):
|
||||
sys.modules.pop(name)
|
||||
canonical = _load("hux_hook", CANONICAL)
|
||||
vendored = _load("vendored_hux_hook", VENDORED)
|
||||
assert vendored.__all__ == canonical.__all__
|
||||
for name in canonical.__all__:
|
||||
canonical_value = getattr(canonical, name)
|
||||
vendored_value = getattr(vendored, name)
|
||||
assert type(vendored_value).__name__ == type(canonical_value).__name__
|
||||
assert inspect.signature(vendored_value) == inspect.signature(canonical_value)
|
||||
|
||||
|
||||
def test_runtime_uses_only_its_kustomize_local_hook_package():
|
||||
"""A rendered plugin must not depend on the Docker build-context path."""
|
||||
source = (VENDORED.parent / "runtime.py").read_text(encoding="utf-8")
|
||||
tree = ast.parse(source)
|
||||
imports = [node for node in ast.walk(tree) if isinstance(node, ast.ImportFrom)]
|
||||
assert any(node.level == 1 and node.module == "hux_hook" for node in imports)
|
||||
assert not any(node.level == 0 and node.module == "hux_hook" for node in imports)
|
||||
for path in (*CANONICAL.glob("*.py"), *VENDORED.glob("*.py")):
|
||||
assert len(path.read_text(encoding="utf-8").splitlines()) <= 500
|
||||
Loading…
x
Reference in New Issue
Block a user