Files
boxmaker/docs/plans/M6a/README.md
T
2026-10-03 19:39:35 -07:00

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 is tools/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)

  1. Nested subagent is allowed; the waiting session is not using the background slot.
  2. Completion is a ToolResult in this turn (id, completed/failed, capped summary).
  3. 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_turn sends session.slot(). Context::hold exists. There is no subagent tool.
  • dispatch sends every non-core name, including a future subagent, to brokerd.
  • Runtime has no Context, so a tool call cannot hold a second session.
  • StateStore::read of a missing file is a fresh private trusted session. A child would launder.

Global constraints

  • Roles depend on bxcore only. No unsafe, 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-id has 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.lock is in the git add line (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.

  • subagent is not in the epoch tools array, so a new session's prefix is unchanged (task 01).
  • call_tool must not send subagent to brokerd (task 02).
  • A parent id of 59 as cannot form sub-<parent>-1; the denial names the id and subagent-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-1 returns through the middle child's ToolResult (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 opens sessions/sub-…/.
  • One background slot. Nested children serialize on SlotGate. A parallel pool is out of scope.
  • brokerd does not see the spawn. Taint is copied on the first StateStore::read of a new sub-* id, including memoryd's session-state question. Identity add from that child is already a note (M5a).
  • consult is 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.