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

84 lines
5.1 KiB
Markdown

# 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
```python
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}`.