Files
boxmaker/docs/specs/2026-10-04-m6-subagents-consult.md
T
kyle b6022378de docs: M6 spec, owner's first answers
Nested subagent allowed; completion is a ToolResult; name stays subagent.
Cloud-led sessions later at public taint. Consult URL and heartbeat
priority still open.
2026-10-03 18:58:51 -07:00

16 KiB

M6 design: subagents and cloud consult

Status: proposed. Section 11 records the owner's 2026-10-04 answers; questions 4 and 6 are still open. 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/<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, 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.

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-<parent>-<n> 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.

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 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

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 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-<parent>-<n> 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 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

  • 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, once 4 and 6 are settled:

  • 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: the consult row in section 4.4, once the URL is chosen.
  • Inference contract 5 already says subagents share the background slot; no wording change unless question 6 puts heartbeat on that slot ahead of a waiting live child.

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. Open; discussion below.
  5. Name. subagent. No reason to pick delegate.
  6. Queue. Open; discussion below.

Consult URL (question 4)

Three shapes, and they can combine.

  • Given tests always hit a fake HTTP server in-process. That does not need a network or a secret.
  • Device check, stub. A private OpenAI-compatible listener on straylight (loopback, one shot) so M6b proves audit-body == wire bytes without sending owner text off the host. Enough to close the milestone's "payload log matches what was sent".
  • Device check, live. A real [consult] url and secret on straylight. The brief's candidate is GLM-5.3-Flash over Z.ai pay-as-you-go (Singapore entity; they state they do not store API content). That is the first new internet egress since http_fetch: it needs a grant, an egress.md row, and a secret in the store. It also means the device check sends a planted question to that host.

Recommendation: stub for the M6b device check (the audit match is a property of brokerd, not of GLM). Add a live URL later, when you want Ornith to actually consult, as config and a grant, without another milestone. A live check can still be a follow-up task if you want the first real call witnessed.

If we do live in M6b, the planted question should be boring and public (What is 2+2, one word.), and the grant max_taint = "public" so a private session cannot use it until you raise that on purpose.

Heartbeat vs subagent (question 6)

M5d's order on a contended background slot is compaction, then cron, then heartbeat, then a tool-output summary. A subagent serving a held live parent is the owner's current turn waiting on the slot. Heartbeat is the pulse for when the owner is not talking.

Heartbeat-first would mean a Mattermost turn that called subagent waits out a heartbeat (often several tool calls; the M5d-2 check saw ~20 s of recalls) and, with last_heartbeat stamped at tick start, a long pulse can come due again immediately. The live user pays that.

Subagent-first (the spec's current list) delays heartbeat until the child finishes. That is the same as today when a compaction summary or a cron run holds the slot: heartbeat skips or waits. Heartbeat is already written to skip when its own session is busy.

Cron is owner-scheduled and can be a long tiering turn. Putting cron ahead of a held live child has the same stall. Compaction in flight should stay first: it is already holding a session the way a child is, and it unblocks that session's next live turn.

Recommendation: keep compaction first, then a subagent whose parent is held, then cron, then heartbeat, then tool-output summaries. Heartbeat should not take precedence over a child that a live turn is waiting on. It should still beat a child whose parent is not held, if we ever queue one; under answer 3 we do not, because the parent stays in the tool call.