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
84 lines
5.1 KiB
Markdown
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}`.
|