12 KiB
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
consultmatches the bytes that left the host; - a
subagentturn runs on the background slot and the parent turn's following request still hits its own prefix (cache_nnear 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):
- Subagents (M6a): a
subagenttool starts an isolated session onslots.background, runs one turn (tools allowed), and returns a capped summary as the parent'sToolResult. It joins the scheduler's background queue. Nested children are out of scope. - Consult (M6b): a
consulttool, decided bybrokerd, POSTs an OpenAI-compatible chat-completions body built from an allowlist of fields. The grant'smax_taintis 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/<id>/. 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-<parent>-<n> |
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.
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):
- a compaction summary already in flight
- a subagent whose parent is currently held
- a due cron run
- heartbeat
- 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.
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:
{
"model": "<config>",
"temperature": 0.6,
"messages": [{"role": "user", "content": "<question>"}]
}
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.
# 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
subagentchild's request carriesid_slot = background; the parent's next request still carriesmainand a cache prefix that includes the child'sToolResultonly as the newest message. - Spawn:
sub-mm-…-3is created; a second spawn in the same parent is…-4. - Overflow: a parent id that cannot fit
sub-<parent>-<n>is invalid-arguments. - Nested:
subagentfrom asub-*session is denied. - Taint copy: a parent with
untrustedtrue has a child whose identityaddlands innotes.md. - Consult deny: a session at
secretwith aconsultgrantmax_taint = "private"isTaintTooHigh; the audit line holds the body. - Consult allow: a
privatesession, grantmax_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
questionin 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
bxctlsession callssubagentwith a short task; the child log is on slot 2; the parent's next turn hascache_nnear 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.tomlhas no[consult]; every call is denied.consult-secret-unreadable: the named secret is missing or too open;brokerddoes 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). consultwith 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)
- 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?
- Child depth. One child per call, no nested
subagent. A parent may callsubagentseveral times in one turn, serialised on the background slot. Enough, or a depth-2 tree? - Who waits. The parent turn waits for the child (and for
consult). The alternative is a laterToolResultafter the parent has already answered, which is a new turn protocol. - Consult URL. Stub only for the device check, or a real endpoint and secret on straylight for M6b?
subagentvs a different name.subagentmatches the milestone table.delegateis the other candidate.- Queue. Child-of-a-held-parent beats cron and heartbeat. Cron-before-child would stall a live Mattermost turn behind a weekly tiering job.