"""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:`` 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"))