Files
boxmaker/docs/design.md
T
kyleandClaude Fable 5.1 367573b11e Brief P4: enforce the thinking cap through reasoning_control
Approved 2026-09-17. Evidence is in docs/decisions.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 00:38:32 -07:00

13 KiB
Raw Blame History

Boxmaker design brief

Boxmaker is a sovereign personal agent harness. The name is from Count Zero: the remnant AI in the Straylight cores that patiently assembles small boxes from fragments.

This brief is binding. Changes to it land as their own commit and are recorded in docs/decisions.md. Milestones and session prompts are in docs/milestones.md.

Crate names and llama.cpp server parameters below are from memory or secondhand research. Verify each one against primary docs before use.

Goal

A personal agent harness that maximizes sovereignty: local inference by default, no ambient authority, state in plain files the owner can read, a codebase small enough to audit.

Target environment

  • Inference host: straylight, AMD Strix Halo, 128 GB unified memory, llama.cpp llama-server, ~128k context configured.
  • Model: Ornith-1.5-35B-A3B (Qwen3.5-family MoE, hybrid linear/full attention, ~20 KB/token KV at f16, Qwen XML tool calls with thinking blocks).
  • Memory is abundant. Generation is fast. Prompt processing is slow and cache invalidation is the dominant cost. Hybrid-attention models cannot partially rewind their cache.
  • Reference numbers for the same model family on Strix Halo (Qwen3.6-35B-A3B Q4, llama.cpp, community benchmark grid, May 2026): prompt processing ~1,100 tokens/s at depth 0 and ~700 at 32k depth; generation ~60 tokens/s falling to ~49. M0 replaces these with measurements from straylight. At these rates an uncached 18k-token prompt costs 1625 s and a 64k re-read costs well over a minute.
  • Deployment: rootless containers on a host that is already a tailnet node. Containers do not get their own tailnet identities, so tailnet ACLs cannot tell one role from another. Isolation between roles is done with container networking on the host (see "Network isolation"). Tailnet ACLs restrict what the host as a whole may reach.
  • First channel: the owner's existing self-hosted Mattermost server, reached over the tailnet.

Non-goals (v0)

Plugin registry, skill marketplace, web dashboard, multi-user, voice, more than one chat channel, cloud-led orchestration as the default, in-process third-party code of any kind.

Roles (separate binaries, narrow interfaces)

Role Holds Can reach Never holds
gatewayd Mattermost bot token, allowlist, message queue Mattermost server (outbound only), loopd socket Tool secrets, grants
loopd Session logs, prompt assembly, memory files Three Unix sockets: gatewayd, brokerd, inferproxy Any credential, any network interface
inferproxy The llama-server address llama-server only Anything else; it forwards bytes and logs nothing
brokerd Grants (read-only), secrets, audit log Container runtime, gatewayd socket (for approvals) Conversation history
tool containers Only what one call was granted Only what one call was granted Anything persistent

v0 runs all roles on one host. The interfaces must not assume co-location with llama-server.

Network isolation

The host's tailnet identity is shared by every container that has a network, so a container with ordinary outbound networking can reach the whole tailnet and the internet as the host.

  1. loopd runs with no network namespace interfaces except loopback (--network=none). Its only I/O is three Unix sockets on a shared volume.
  2. inferproxy is a forwarder of about 100 lines: one Unix socket in, one fixed upstream out. If M0 shows llama-server can listen on a Unix socket and it runs on the same host, drop inferproxy and mount that socket instead.
  3. gatewayd has outbound network only. It opens no listening TCP port. v0 approvals use replies or emoji reactions, which arrive over the Mattermost WebSocket, so no callback URL is needed.
  4. Tool containers get no network unless the grant names hosts. Granted network access goes through an allowlisting egress proxy that brokerd configures per call.
  5. Tailnet ACLs (kept in deploy/) limit the host's node to llama-server and Mattermost, plus whatever tool grants need. This is defence in depth, not the role boundary.

Channel: Mattermost

  • Bot account with a token held in the SecretStore. REST API for posting, WebSocket for events. Implement directly over HTTP and WebSocket; do not adopt a community SDK without checking its maintenance. Fetch the Mattermost API reference before writing any request.
  • Identity is the Mattermost user ID. The allowlist is a config file of user IDs. Direct messages only by default; channels must be allowlisted by ID. Authentication is delegated to the server.
  • Long tasks: post a placeholder, edit or reply when finished. Threads map to sessions.
  • Mattermost stores messages unencrypted in its database. That is acceptable because the server is the owner's, and it is recorded in docs/egress.md.

Inference contract (the part existing harnesses get wrong)

  1. Baseline budget. System prompt plus tool schemas at session start: 3,000 tokens or less, measured with the server's tokenizer, enforced by a test.
  2. Append-only. The request for turn N+1 is a strict extension of the request for turn N. Nothing volatile (time, heartbeat notes, memory refreshes) goes anywhere but the newest message. Enforced by a property test on the serialized message array. The session log stores each assistant message exactly as the server returned it (content, reasoning_content, tool_calls) and replays it unchanged. Ornith's template keeps every thinking block, so dropping or editing one changes the prefix.
  3. No in-place pruning. Tool results are size-capped when first appended, never trimmed later.
  4. Compaction is an epoch change. It happens only when the session is idle, writes a summary, and starts a new epoch whose prefix is the baseline plus the summary. The old log is kept.
  5. Pinned slots. Main session, subagents, and scheduled runs each use their own server slot so they do not evict each other's cache. Verify the pinning mechanism on the deployed build.
  6. Progressive disclosure. The tools array is fixed for the whole epoch. The template renders it at the top of the prompt, so any change re-reads everything. Only a small core tool set has schemas in it. Everything else is found through find_tool, which returns schemas as an ordinary tool result, and is called through one fixed call_tool(name, arguments) meta-tool. brokerd applies grants to the target tool, not to call_tool. Never prompt the model to call a tool that is not in the tools array: the server's grammar forces such a call into a declared tool.
  7. Liveness, not deadlines. Streaming always, with return_progress: true so that prompt processing produces events. The timeout is "no bytes for N seconds", never a total-request deadline. Progress events count as bytes.
  8. Runaway control. Per-turn thinking-token cap, per-turn tool-iteration cap, detection of repeated identical tool calls. loopd enforces the thinking cap: it counts streamed reasoning tokens and ends the block with the server's reasoning_control mechanism (POST /v1/chat/completions/control, action reasoning_end). M2 exercises this on straylight before relying on it.
  9. Startup self-test. On boot loopd checks: tool-call round trip parses, turn-2 prompt processing count shows a cache hit, configured context matches what the server reports. It refuses to start if any check fails.
  10. Serving settings are part of the repo. Launch flags, chat template hash, sampling settings (temp 0.6, top-p 0.95, top-k 20), f16 KV cache, no speculative decoding.

Open question settled by measurement in M0: use the server's chat-completions endpoint with server-side tool parsing, or render the template in-process for byte-exact prefix control. Default to chat-completions. Switch only if the M0 cache measurements fail.

Authority contract

  1. loopd has no authority. Every tool call goes to brokerd as a request.
  2. A grant is a file the owner writes: tool, argument constraints (paths, hosts, patterns), allowed data classes, expiry, mode (auto, ask, deny). brokerd reads grants and cannot write them. loopd cannot see the directory.
  3. No matching grant means deny. ask routes an approval request to the owner through gatewayd and the turn suspends until answered or expired.
  4. Each approved call runs in a fresh rootless container: no network unless granted, only granted paths mounted, only the granted secret injected.
  5. Audit log: append-only JSONL, hash-chained, one record per decision including denials.
  6. Data classes: every tool result is labelled public, private, or secret. A session's taint is the union of labels it has seen. The cloud-consult tool is refused for tainted sessions unless a grant allows that class out. Every outbound cloud payload is logged in full.
  7. Scheduled jobs are re-validated against current grants on every run.

Cloud consult (M6)

  • One tool, consult, behind brokerd like any other. The endpoint is any OpenAI-compatible URL, set in config. Nothing in the code is specific to one vendor.
  • Candidate as of 2026-09-16: GLM-5.3-Flash (open weights, MIT, 320B total / 18B active). It does not run usefully on the inference host, so it is cloud-only here. Hosting choices: Z.ai's international API (Singapore entity, Singapore law, states it does not store API content), or a third-party host chosen by jurisdiction with zero-data-retention routing. Z.ai's subscription coding plan is restricted to its officially supported tools; use pay-as-you-go.
  • Call it at temperature 0.6 or lower. Community tests show malformed tool calls at the default of 1.0. Prefer giving consult no tools at all: it answers a self-contained question in text.
  • Hosted GLM always thinks. Treat consult as slow and asynchronous.
  • Payloads are built from an explicit allowlist of fields, never from raw session history.

Memory (M5)

Memory is what carries continuity across epoch compaction, so it is required. It must not break the inference contract, and it must not become a way for injected text to persist.

  1. Core block. memory/core.md, owner-curated, 300 tokens or less, part of the baseline. Edits take effect at the next epoch, never mid-session. The agent can propose changes; only an ask approval or the owner's editor applies them.
  2. Everything else is retrieved, not injected. A recall(query, k) tool returns size-capped snippets with file, line and provenance. Results are appended like any tool result, so the cache prefix is untouched.
  3. Notes. memory/notes/YYYY-MM-DD.md, append-only, written by a remember tool. Each entry records its session, time, data-class label and whether the session had seen untrusted content.
  4. Flush before compaction. When a session goes idle and is about to change epoch, a job on the scheduled slot extracts durable facts from the log into notes. The main slot is not used.
  5. Provenance on recall. Recalled text is presented as data with its provenance, never placed in the system prompt. Entries written from sessions that saw untrusted content are marked as such when recalled. Recalled entries carry their data-class label and taint the session.
  6. Both tools go through brokerd under ordinary grants: recall and note-writing auto, core and topic edits ask.
  7. Index. Lexical search first: SQLite FTS5 over the Markdown files, rebuildable with bxctl reindex. Retrieval sits behind a Retriever trait.
  8. Embeddings are a later, additive index (M5b), never a store. Local model only, served by a separate llama-server embedding instance so chat slots are unaffected. Vectors live in a flat file or SQLite table keyed by chunk hash and embedding-model hash; a model change triggers a rebuild. Combine with lexical results by rank fusion. Add M5b when logged recall misses show lexical search failing, not before.
  9. No vector database service. Search is exact brute-force cosine, in-process. Revisit only if the index passes about one million chunks or measured recall latency exceeds one second, and then prefer an embedded, file-backed approximate index over a server.

State

Files are the source of truth. SQLite is allowed only for rebuildable indexes and queues.

  • sessions/<id>/<epoch>.jsonl — append-only session log
  • memory/core.md, memory/notes/*.md, memory/topics/*.md — owner-readable memory
  • index/ — FTS5 and, later, vectors; disposable and rebuildable from memory/
  • grants/*.toml — owner-written
  • audit/*.jsonl — hash-chained
  • Secrets: behind a SecretStore trait. v0 backend is an encrypted file whose key is not stored beside it. No plaintext secrets on disk, none in config, none in the repo.

Code constraints

  • Rust stable, Cargo workspace. Crates: proto (shared types), loopd, brokerd, gatewayd, inferproxy, toolkit (tool container entrypoints), bxctl (owner CLI).
  • No source file over 500 lines. No crate depends on another role's crate, only on proto.
  • Authority is encoded in types: a tool cannot execute without a Decision value that only brokerd's policy module can construct.
  • Dependencies are few and justified in docs/dependencies.md. cargo-deny runs in the gate.
  • No telemetry, no update checks, no outbound call not listed in docs/egress.md.