7.0 KiB
M6a implementation plan: subagents
For the implementer: each task file is one closed task for one fresh session. Read
AGENTS.md, then the task file, and do exactly that task. The driver istools/run-plan.sh.
Goal: 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). Nested subagent is allowed. Heartbeat and cron skip a tick while a sub-* session
is held. brokerd copies the parent's taint onto a new sub-* session on first read.
Architecture: subagent is not core. loopd answers it itself (Dispatch::Subagent).
The parent stays in the tool call. The child is a second hold, a fresh baseline, user text
with M5d's time line, run_turn then finish_turn, speaker None. Spawn is not a grant.
The child's later tool calls go to brokerd as today. brokerd infers the parent from
sub-<parent>-<n> so it never sees the spawn as a tool.
Tech stack: Rust 1.98.1, edition 2024; no new crate.
Spec: docs/specs/2026-10-04-m6-subagents-consult.md, sections 3, 5, 6, 7 (the M6a
tests), and 8 (subagent-id). M6b (consult) is a later plan.
The owner's decisions (2026-10-04)
- Nested
subagentis allowed; the waiting session is not using the background slot. - Completion is a
ToolResultin this turn (id, completed/failed, capped summary). - Name
subagent. Queue: compaction in flight, then a child whose parent is held, then cron, then heartbeat, then a tool-output summary.
What the code already has, and what it lacks
- Heartbeat and cron are sessions on the background slot.
run_turnsendssession.slot().Context::holdexists. There is nosubagenttool. dispatchsends every non-core name, including a futuresubagent, tobrokerd.Runtimehas noContext, so a tool call cannot hold a second session.StateStore::readof a missing file is a fresh private trusted session. A child would launder.
Global constraints
- Roles depend on
bxcoreonly. Nounsafe, no async runtime, no new crate. - No file the implementer reads holds a chat-template tag whole (T33).
- Every path a given test creates is removed by a guard (T44); no test depends on timing
(T27): the scheduler is
tick(now), never a sleep. - Fail-closed state
subagent-idhas a runbook entry, added by the task that adds its pointer (T49). - Each task gives its baseline and its gate count (T50, T53); the branch line is a check,
never
git switch(T51). Cargo.lockis in thegit addline (T24). Gate timeout 600000 ms (T40).docs/implementer-log.md: at log-and-commit, read only the first 12 lines (T62).
Tasks
Every task below has been written to be replayed from its text. The gate count is the passed
total of cargo test --workspace plus one, for the ignored end_to_end test the gate runs on
its own. Before task 01 it is 1575 (straylight, rustc 1.98.1 / clippy 0.1.98; M5d-2 task 11).
| # | Delivers | Crates | Gate after | Tests added | Mutations tried, caught |
|---|---|---|---|---|---|
| 01 | subagent in m5c(), not core; find_tool finds it; the epoch tools array is unchanged |
loopd | 1577 | 2 | 1, 1 |
| 02 | Dispatch::Subagent; unknown keys are invalid-arguments; call_tool does not send it to the port; child_id |
loopd | 1580 | 3 | 1, 1 |
| 03 | Spawn: Runtime.ctx, hold, background slot, time line, ToolResult completed/failed, nested, overflow pointer |
loopd | 1585 | 5 | 2, 2 |
| 04 | Scheduler skips heartbeat and cron while any sub-* id is held |
loopd | 1587 | 2 | 2, 2 |
| 05 | brokerd copies parent taint onto a missing sub-* state file |
brokerd | 1590 | 3 | 2, 2 |
Mutations (reference, one line each, restored after): core: true fails both task-01 tests; dropping the unknown-key walk fails unknown_keys_and_a_missing_task_are_invalid_arguments; child slots.main fails the slot and nested slot asserts; skipping the overflow denial fails an_overflowing_parent_id_is_invalid_arguments; dropping only the heartbeat holds_prefix skip fails the heartbeat test and leaves the cron test green; dropping only the post-heartbeat return fails the cron test and leaves the heartbeat test green; inherit_or_default always default fails both inherit tests and leaves the plain-session test green; a missing middle parent treated as default fails only the nested inherit test.
After task 05, the design model runs the M6a device check (spec 7) on straylight: 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. Slot 1 is left busy.
Review Focus
These are implied by the spec and pinned in the named task. A reviewer who only reads the happy-path tests will miss them.
subagentis not in the epochtoolsarray, so a new session's prefix is unchanged (task 01).call_toolmust not sendsubagenttobrokerd(task 02).- A parent id of 59
as cannot formsub-<parent>-1; the denial names the id andsubagent-id(tasks 02, 03). - The parent's request after the child still extends its own prefix; the child used
slots.background(task 03). - Nested
sub-sub-<parent>-1-1returns through the middle child'sToolResult(task 03). - A child whose last completion is still a tool call is
failed, not a hang (task 03). - A held
sub-*skips both a due heartbeat and a due cron job (task 04). - A grandchild with no middle state file still inherits the live parent's untrusted flag (task 05).
- A plain
chat-*read still creates no state file (task 05).
Known limits
These are known, accepted for M6a, and not bugs to fix in a task:
- No Mattermost delivery. The parent learns the child finished as a
ToolResult. The owner who wants the child's full log openssessions/sub-…/. - One background slot. Nested children serialize on
SlotGate. A parallel pool is out of scope. brokerddoes not see the spawn. Taint is copied on the firstStateStore::readof a newsub-*id, includingmemoryd's session-state question. Identityaddfrom that child is already a note (M5a).consultis M6b.
Before hand-over
Work through docs/plans/CHECKLIST.md with a reference in a scratch worktree with its own
build directory, replayed from its task text; mutations; /tmp compared around a gate run;
the reference moved to straylight as a bundle before the run (T18). Task files name their log
row (T47).
Running it
The reference (m6a-ref at 87c3fa7, tasks 01 to 05) is a git bundle
~/boxmaker-refs/m6a-ref.bundle. Copy it to straylight and delete the local worktree
(/home/kyle/src/boxmaker-m6a-ref) and branch before the run (T18). Then, from the
repository root, with the OpenCode TUI closed, after M5d-2 has landed on master:
git switch -c m6a master
OPENCODE_MODEL=llama.cpp/ornith-1.5-35b-a3b RESUME=1 \
SLOTS_URL='http://straylight:11434/slots?model=ornith-1.5-35b-a3b' \
tools/run-plan.sh docs/plans/M6a 01
After task 05, the gate count is 1590. Then the device check above.