jenkins aeef0f8484 hermes(hux): worker-side hook library for approvals, gates, budgets, stop receipts and events
Stdlib client the agent runtime calls around its tool loop; fails closed for
side effects, fails open for telemetry, never carries raw arguments or outputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RNPhwu2bsaRNg3DETSAZoM
2026-08-24 00:48:20 -03:00

5.1 KiB

hux_hook: wiring notes for the agent runtime patch

hux_hook is a stdlib-only library the Hermes agent process imports. HUX (hermes-hux-foundation, loopback 127.0.0.1:8790 in the same pod) is the source of truth; the agent never decides an approval, never reads the tenant ledger directly and never persists raw tool arguments or output.

Environment the agent process needs

Variable Purpose
HUX_BASE_URL default http://127.0.0.1:8790; the service is loopback-only
HUX_TENANT_SLOT slot-N, same value the service was started with (HUX_TENANT_SLOT on the service side)
HUX_SUBJECT usr_<hash> of the slot owner; the router derives it, the pod env carries it
HUX_WORKER_KEY shared key mounted read-only under /runtime-access; sent as X-Hux-Relay-Key
HUX_SURFACE / HUX_TRUST default worker / worker; the agent hook is never a human surface (SO-35)
HUX_TIMEOUT_SECONDS default 5

Headers the client sends (exactly hux/identity.py): X-Hermes-Tenant-Identity, X-Hux-Subject, X-Hux-Surface, X-Hux-Trust, X-Hux-Relay-Key, plus Idempotency-Key on creates and If-Match on revisioned PUTs.

Construction

from hux_hook import HuxClient
client = HuxClient(os.environ.get("HUX_BASE_URL", "http://127.0.0.1:8790"),
                   {"tenant_slot": os.environ["HUX_TENANT_SLOT"], "subject": os.environ["HUX_SUBJECT"],
                    "surface": "worker", "trust": "worker"},
                   key=os.environ.get("HUX_WORKER_KEY"), timeout=float(os.environ.get("HUX_TIMEOUT_SECONDS", "5")))

One client per process. client.capabilities() is cached; call client.forget_capabilities() on SIGHUP or when the WebUI reports a flag change.

Where each call goes in the tool loop

  1. Run start: emit(client, conversation_id, "run.started", "...", run_id=run_id).
  2. Immediately before executing any tool that is not read-only: d = before_tool(client, run_id, conversation_id, tool_name, arguments, capability, external, risk).
    • d.proceed is True: execute now, with exactly the arguments object that was hashed. Re-serialising or "normalising" arguments after the gate breaks SO-37.
    • d.reason == "approval_required": do not execute. Surface d.approval_id to the UI (chat posts POST /hux/v1/approvals/{id} with once|session|always|deny from a human surface), park the turn, and call before_tool again with the same arguments after the decision. The idempotent replay reaches the gate; a once approval releases exactly once.
    • Any other reason (approval_denied, budget_exhausted, hux_unavailable, autonomy_off, service_error:*, a gate reason): refuse the tool and tell the model why. Never fall back to the upstream gateway approval prompt while hux.autonomy is on. Capability mapping is the runtime's job: read_files, write_files, shell, network, web_search, send_message, memory_write, artifact_write, spend_tokens, delegate, deploy, external_side_effect (see hux/rules.py). external=True for anything that leaves the tenant (messages, network, deploy).
  3. After every tool: after_tool(client, run_id, conversation_id, tool_name, ok, bytes_out, turn, argument_hash=canonical_argument_hash(tool_name, arguments), duration_ms=..., exit_code=...) then record_spend(client, run_id, conversation_id, tool_calls=1, tokens=<delta>). Both are best-effort: they return None on failure and never raise.
  4. Delegation: record_spend(..., delegations=1) / subagents=1 when spawning; the child run uses its own run_id and the same conversation_id.
  5. Memory: before proposing a memory write call memory_gate(client, conversation_id); False means do not even propose. The write itself still goes through POST /hux/v1/memory, which enforces forget, disable and topic rules server-side.
  6. Stop: when the user cancels, kill the tool processes, then receipt = on_stop(client, run_id, conversation_id, process_registry_empty=<real registry check>, side_effects=[{"description": ..., "reverted": bool}, ...]). None means no receipt exists and the stop is NOT done: retry, and never report "cancelled" to the user without a receipt (SO-41). process_registry_empty must come from the gateway's process registry, not from a timer.
  7. Run end: emit(..., "run.completed" | "run.failed", ...).

What must never happen

  • Raw arguments, file contents, command output, prompts or secrets in any summary, detail or evidence. The library only ever sends tool name, capability, hash, byte counts, status.
  • Deciding an approval with the worker identity (the service answers 403; do not retry as chat).
  • Executing a tool after proceed=False for any reason, including HUX being unreachable.
  • Logging HuxServiceError bodies with request payloads: exceptions carry status/code/message only.

Routes used

GET /hux/v1/capabilities, POST /hux/v1/approvals, POST /hux/v1/runs/{id}/gate, POST /hux/v1/runs/{id}/budget, POST /hux/v1/runs/{id}/stop, POST /hux/v1/conversations/{id}/events, GET /hux/v1/privacy/policy, GET /hux/v1/conversations/{id}.