Nested subagent allowed; completion is a ToolResult; name stays subagent. Cloud-led sessions later at public taint. Consult URL and heartbeat priority still open.
324 lines
16 KiB
Markdown
324 lines
16 KiB
Markdown
# 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`.
|
|
|
|
```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-<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`.
|
|
|
|
```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": "<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.
|
|
|
|
```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-<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 `recall`s) 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.
|