124 lines
7.0 KiB
Markdown
124 lines
7.0 KiB
Markdown
# 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 `a`s 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:
|
|
|
|
```sh
|
|
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.
|