194 lines
9.6 KiB
Python

"""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 hux_hook.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"))