From 4cb8c8c3c993149e700620dbafb9873c3dc90f7a Mon Sep 17 00:00:00 2001 From: Kyle Isom Date: Sat, 3 Oct 2026 18:52:01 -0700 Subject: [PATCH] docs: proposed M6 spec (subagents and consult) M5d-2 is on master. The spec is proposed; section 11 is the owner's questions. --- CLAUDE.md | 5 +- docs/specs/2026-10-04-m6-subagents-consult.md | 270 ++++++++++++++++++ 2 files changed, 273 insertions(+), 2 deletions(-) create mode 100644 docs/specs/2026-10-04-m6-subagents-consult.md diff --git a/CLAUDE.md b/CLAUDE.md index 8ea9946..05d7024 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -34,10 +34,11 @@ sections 3, 8, 9, 10 and 13) is done, reviewed in `docs/implementer-log.md` and straylight against rift; no finding is open. M5d (scheduler and compaction, spec `docs/specs/2026-10-03-m5d-scheduler.md`) is split: M5d-1 (epochs and compaction) is done, reviewed in `docs/implementer-log.md` and checked on straylight (spec 7); no finding is open. -M5d-2 (heartbeat, cron, ToolSummary, live-slot isolation) is done on branch `m5d2`, reviewed in +M5d-2 (heartbeat, cron, ToolSummary, live-slot isolation) is done and on master, reviewed in `docs/implementer-log.md`; finding 1 closed by task 11; spec 7 device-checked on straylight (heartbeat on slot 2 left a following slot-0 turn's `cache_n` at 1398 of 1399; `jobs/tier.toml` -promoted only a trusted note). +promoted only a trusted note). M6 (subagents and cloud consult) is proposed in +`docs/specs/2026-10-04-m6-subagents-consult.md`, awaiting the owner's answers in section 11. P22 (proposed, not applied) makes Boxmaker a fleet of independent agents, one per host, deployed with Nix in a last milestone, M8; `flake.nix` builds every binary, the tool image and a dev shell. Work happens on titan (macOS, no Nix); build and run diff --git a/docs/specs/2026-10-04-m6-subagents-consult.md b/docs/specs/2026-10-04-m6-subagents-consult.md new file mode 100644 index 0000000..0ba3e9a --- /dev/null +++ b/docs/specs/2026-10-04-m6-subagents-consult.md @@ -0,0 +1,270 @@ +# M6 design: subagents and cloud consult + +Status: proposed. Awaits the owner's answers in section 11. The implementer is Ornith (through +`tools/run-plan.sh`), as for M5d. + +It rests on the brief (`docs/design.md`, Inference contract 5 and 6, Authority 6, Cloud consult), +on M5d's scheduler (`docs/specs/2026-10-03-m5d-scheduler.md`, out of scope: subagents join that +queue), and on M3a's grants (`max_taint` already covers consult). Where this document and the +brief disagree, section 10 names the brief change. + +## 1. What M6 proves + +Work that does not fit one Ornith turn can still run under the same authority rules: a child +session on the background slot returns a summary, and a cloud completion leaves the host only +when a grant allows that class out, with the payload in the audit log. + +The milestone is checked by: + +- a tainted session is refused `consult`; +- the audit record of an allowed `consult` matches the bytes that left the host; +- a `subagent` turn runs on the background slot and the parent turn's following request still + hits its own prefix (`cache_n` near its previous total), as M5d-2 did for heartbeat. + +## 2. Two halves + +One spec, two plans, each run and device-checked on its own (owner): + +1. **Subagents (M6a)**: a `subagent` tool starts an isolated session on `slots.background`, runs + one turn (tools allowed), and returns a capped summary as the parent's `ToolResult`. It joins + the scheduler's background queue. Nested children are out of scope. +2. **Consult (M6b)**: a `consult` tool, decided by `brokerd`, POSTs an OpenAI-compatible + chat-completions body built from an allowlist of fields. The grant's `max_taint` is the class + gate. The audit log holds the request body and the answer. + +## 3. Subagents (M6a) + +Scheduled work in M5d-2 is already a session: same core tools, `brokerd` path, memory tools, +background slot, answers in `sessions//`. A subagent is that shape, started by a tool call +from a live session instead of by the clock. + +### 3.1 Disk and ids + +| Kind | Id | Lives | +|---|---|---| +| Subagent run | `sub--` | one session per call; `n` is the parent's next `CallId` | +| Parent | unchanged (`mm-*`, `chat-*`, `job-*`, `heartbeat`) | holds until the child `TurnDone` | + +`SessionId` is `[a-z0-9-]`, max 64. A Mattermost parent id is `mm-` plus 26 characters. +`sub-` + parent + `-` + a decimal call id stays under 64. A parent that would overflow is +refused with invalid-arguments (the reason names the id). + +The child is created on `cfg.slots.background` with a fresh baseline (current memory files), +not a copy of the parent's messages. The parent passes a task string; that string is the child's +user text, with the same time line M5d prepends so the clock stays out of the child's prefix. + +### 3.2 The tool + +`subagent` is **not** core. The epoch's `tools` array stays `clock`, `recall`, `memory`, +`ask_memory`, `find_tool`, `call_tool`. The parent finds it and calls it through `call_tool`. + +```text +subagent(task: string) +``` + +`task` is capped to the tool-result cap. Unknown keys are errors. The result is the child's +final assistant `content`, tag-neutralised, capped to `[compact] summary_chars`. Thinking is +not returned. If the child ends on a tool call, that is a failed run: the parent sees a +`Failed` result, not a hang. + +`loopd` answers `subagent` itself (it is the only role that can start inference). `brokerd` +does not see the spawn; every tool the child then calls still goes to `brokerd` as today. + +### 3.3 Queue, hold, slots + +The parent remains held for the live turn. The child is a second `hold`. The child's inference +uses the background slot; the parent's slot stays idle until the result is appended. That is +the M5d-2 isolation check with a child in place of heartbeat. + +Priority on the background slot, when contended (M5d spec 4, plus this): + +1. a compaction summary already in flight +2. a subagent whose parent is currently held +3. a due cron run +4. heartbeat +5. a tool-output summary + +`SlotGate` still serialises the slot. A child skipped because the slot is busy waits inside +the parent's tool call (liveness timeout, not a total deadline), the same way a live turn waits +on a busy main slot. + +### 3.4 Trust, Honcho, memory + +The child copies the parent's session taint and `untrusted` flag at spawn. It cannot launder: +an identity write from a tainted parent is still a note (existing M5a rule). + +No speaker on the child, so Honcho skips it (same skip as heartbeat). The local index still +holds the log. `recall` in the child is ordinary. + +A child does not spawn a child: `subagent` in a `sub-*` session is denied as invalid-arguments. + +### 3.5 Delivery + +The summary is the parent's tool result. Nothing is posted to Mattermost. The owner who wants +the child's log opens `sessions/sub-…/`. + +## 4. Consult (M6b) + +One tool, behind `brokerd` like any other. Not core; found through `find_tool`. + +```text +consult(question: string) +``` + +Unknown keys are errors. `question` is capped to the tool-result cap. `loopd` does not add +session history, memory files, or tool schemas. The payload `brokerd` sends is exactly: + +```json +{ + "model": "", + "temperature": 0.6, + "messages": [{"role": "user", "content": ""}] +} +``` + +No `tools`. Temperature is config, default 0.6, and must be ≤ 0.6. The URL, model name, and +secret name are `[consult]` in `brokerd.toml`. Nothing in the code names a vendor. + +`brokerd` performs the HTTP itself (it already holds secrets and the audit log). This is not a +tool container and not `http_fetch`: a container would see the payload, and `http_fetch` has no +allowlist of body fields. The grant still decides; the runner is a dedicated client. + +### 4.1 Grant and taint + +No matching grant means deny. A grant for `consult` names `max_taint` as today: a session whose +taint is above that class is `TaintTooHigh`. That is the milestone's "tainted session is +refused". `untrusted` on the grant labels the answer; default `true`, so a consult taints the +session unless the owner writes otherwise. + +The result class is the grant's `result_class` (default `private`). + +Cloud-led sessions (the model at the far end driving Boxmaker's tools) are **not** in M6. The +brief's non-goal is cloud-led orchestration as the default; section 11 asks whether they are +ever allowed later. + +### 4.2 Audit + +Every decision record for `consult` includes the JSON body that was sent and the text that came +back (capped, tag-neutralised). A denial still records the body that would have been sent, so a +refused tainted call is visible. The device check diffs that recorded body against a captured +wire dump. + +### 4.3 Timing + +Hosted GLM always thinks. The call is slow. It is still one tool result in the parent's turn: +`brokerd`'s `[consult] timeout_ms` (default 180_000) is the wait; `loopd`'s broker timeout must +be at least that. The parent turn does not continue without the answer. "Asynchronous" in the +brief means the owner should not expect a heartbeat-speed reply, not a second channel. + +### 4.4 Egress + +A new `docs/egress.md` row: run time, `brokerd`, the configured consult URL, the allowlisted +JSON body. The secret is `bxcore::secrets`, same store as Mattermost and Honcho. + +## 5. Config + +Unknown keys are errors. Defaults disable both features: no `[consult]` means every `consult` +call is denied as not configured (runbook pointer). `subagent` is always in the registry; with +no further config it already works, because it uses the existing background slot. + +```toml +# brokerd.toml +[consult] +url = "https://api.example.com/v1/chat/completions" +model = "glm-5.3-flash" +temperature = 0.6 +timeout_ms = 180000 +secret = "consult" # SecretStore key; the value is the Authorization bearer +``` + +`loopd` gains no new table. `[compact] summary_chars` caps the child's answer. + +## 6. Components + +| Piece | Change | +|---|---| +| `bxcore` | none required if `consult`'s audit fields fit the existing decision record; otherwise a dedicated payload field on that record. | +| `loopd` | `subagent` in the registry (not core); spawn, hold, one child turn on the background slot, summary as `ToolResult`; queue priority in the scheduler. | +| `brokerd` | `[consult]`; grant path for `consult`; HTTP client; audit of body and answer; `TaintTooHigh` as today. | +| `memoryd` | none. | +| `gatewayd` | none. | +| `bxctl` | none. Child sessions are ordinary `--session` ids. | +| `inferproxy` | none. | + +`loopd` still depends only on `bxcore`. `brokerd` already speaks HTTP for Mattermost-less +approvals and for nothing else outbound except tool containers; the consult client is a small +module next to the existing HTTP helper, vendor-agnostic. + +## 7. Testing + +Given tests, first, against the fake llama server and a fake `brokerd`: + +- **Slot**: a `subagent` child's request carries `id_slot = background`; the parent's next + request still carries `main` and a cache prefix that includes the child's `ToolResult` only + as the newest message. +- **Spawn**: `sub-mm-…-3` is created; a second spawn in the same parent is `…-4`. +- **Overflow**: a parent id that cannot fit `sub--` is invalid-arguments. +- **Nested**: `subagent` from a `sub-*` session is denied. +- **Taint copy**: a parent with `untrusted` true has a child whose identity `add` lands in + `notes.md`. +- **Consult deny**: a session at `secret` with a `consult` grant `max_taint = "private"` is + `TaintTooHigh`; the audit line holds the body. +- **Consult allow**: a `private` session, grant `max_taint = "private"`, fake HTTP 200; the + audit body equals the allowlisted JSON; the tool result is the answer text. +- **No history**: a parent with earlier user messages still sends only `question` in the + consult body. +- **Unknown field** on either tool is invalid-arguments. +- **No `[consult]`**: deny with the runbook pointer. + +**On the device** (straylight, slot 0 live / slot 2 background, slot 1 left busy, private +`$HOME`, production untouched): + +- M6a: a `bxctl` session calls `subagent` with a short task; the child log is on slot 2; the + parent's next turn has `cache_n` near its previous total. +- M6b: against a private OpenAI-compatible stub (or the configured consult URL if the owner + names one): a clean session's payload in the audit log matches a captured request; a session + that first recalled an untrusted note is refused. + +## 8. Runbook + +New entries, each with a pointer from the message: + +- `consult-not-configured`: `brokerd.toml` has no `[consult]`; every call is denied. +- `consult-secret-unreadable`: the named secret is missing or too open; `brokerd` does not + start (existing secret-store start failure, named in the message). +- `subagent-id`: the child session id would not fit; the parent call is invalid-arguments. + +## 9. Out of scope + +- Nested subagents; a pool of children in parallel (one background slot). +- Cloud-led sessions (the remote model calling Boxmaker's tools). Section 11. +- Optional delivery of a child answer to Mattermost. +- Session-scoped approvals (open row in `docs/decisions.md`). +- `consult` with tools, streaming to Mattermost, or a vendor SDK. +- M7 containers for `loopd`; M8 fleet endpoints. + +## 10. Changes to the brief and decisions + +With this spec, as their own commits, if section 11 agrees: + +- Close "Whether cloud-led sessions are ever allowed, and for which data classes" for v0: + they are not allowed. Revisit after v0. +- `docs/egress.md`: the consult row in section 4.4. +- Inference contract 5 already says subagents share the background slot; no wording change + unless section 11 puts children on a third slot. + +## 11. The owner's questions (2026-10-04) + +1. **Cloud-led sessions.** The brief's non-goal is cloud-led orchestration as the default. M6 + as written never lets the remote model call tools. Allowed later, and at which taint, or + never in v0? +2. **Child depth.** One child per call, no nested `subagent`. A parent may call `subagent` + several times in one turn, serialised on the background slot. Enough, or a depth-2 tree? +3. **Who waits.** The parent turn waits for the child (and for `consult`). The alternative is + a later `ToolResult` after the parent has already answered, which is a new turn protocol. +4. **Consult URL.** Stub only for the device check, or a real endpoint and secret on + straylight for M6b? +5. **`subagent` vs a different name.** `subagent` matches the milestone table. `delegate` is + the other candidate. +6. **Queue.** Child-of-a-held-parent beats cron and heartbeat. Cron-before-child would stall a + live Mattermost turn behind a weekly tiering job.