# M6 design: subagents and cloud consult Status: approved by the owner, 2026-10-04, with section 11's answers. 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 `ToolResult` that the child completed (id, status, capped summary). It joins the scheduler's background queue. A child may call `subagent` (nested); the background slot is free while a parent waits, so this is the same code path. 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`, or another `sub-*`) | 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 parent turn stays in this tool call until the child finishes. The `ToolResult` is a completion message: the child session id, `completed` or `failed`, and on success 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 `failed`, 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 may call `subagent`. The child's inference request has already finished when it waits on that tool, so the background slot is free for the grandchild; there is no deadlock with one slot. Depth is bounded by the parent's `tool_iterations` along the call stack and by `SessionId` (64 bytes): `sub--` nested a few times still fits a `mm-*` parent, then invalid-arguments. A parallel pool of children is still out of scope. ### 3.5 Delivery The parent learns the child finished the same way it learns any other tool finished: a `ToolResult` in this turn. Nothing is posted to Mattermost. The owner who wants the child's full 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 remote model calling Boxmaker's tools) are **not** in M6. They are allowed later; the leaning is only at `public` taint. M6's `consult` still has no `tools`. ### 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 M6 does not send consult traffic off the host. The device check's dummy is loopback. A live URL later is a new `docs/egress.md` row, a grant, and a `bxcore::secrets` key, the 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 creates a grandchild; the middle child's `ToolResult` contains the grandchild's completion message; the live parent's prefix still extends. An id that would exceed 64 bytes is invalid-arguments. - **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 loopback OpenAI-compatible dummy on straylight (the check starts it; it is not a standing service): a clean session's payload in the audit log matches a captured request; a session that first recalled an untrusted note is refused. No internet consult in this milestone. ## 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 - A pool of children in parallel (one background slot). - Cloud-led sessions in M6 (the remote model calling Boxmaker's tools). Later, leaning `public`. - 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 when the plans are written: - `docs/decisions.md` open row "cloud-led sessions": not in M6; later, leaning only at `public` taint. Leave the row open until that later design. - `docs/egress.md`: no internet consult row in M6. The M6b device check talks to a loopback dummy. A live URL later is its own row, grant, and secret. - Inference contract 5 already says subagents share the background slot. Queue order is section 3.3; no brief wording change. ## 11. The owner's answers (2026-10-04) 1. **Cloud-led sessions.** Later, not in M6. Leaning `public` taint only. M6 `consult` stays text-in, text-out, no tools. 2. **Child depth.** Nested `subagent` is allowed. It is the same wait-and-`ToolResult` path: the waiting session is not using the background slot. Bounded by `tool_iterations` and by `SessionId` length. A parallel pool is still out of scope. 3. **Who waits.** The parent (or middle child) stays in the tool call. Completion arrives as a `ToolResult`: session id, completed/failed, and on success the capped summary. Same for `consult`. No new turn protocol. 4. **Consult URL.** Given tests use an in-process fake. The M6b device check uses a loopback OpenAI-compatible dummy on straylight (the check starts it; it need not be a standing service). No live vendor in this milestone. A real URL is config and a grant later. 5. **Name.** `subagent`. 6. **Queue.** Heartbeat does not take precedence over a child a live turn is waiting on. Compaction in flight, then that child, then cron, then heartbeat, then a tool-output summary (section 3.3).