docs: M6a implementation plan

This commit is contained in:
2026-10-03 19:39:35 -07:00
parent a2979bd434
commit 25278b4c03
12 changed files with 2027 additions and 0 deletions
@@ -0,0 +1,184 @@
# M6a task 01: `subagent` in the M5c registry, not core
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: subagent in the M5c registry, not core`
**Log row:** the Task cell is `M6a/01-loopd-subagent-registry`.
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
Do not Read `tests/support/`, `turn.rs`, `session.rs`, `docs/implementer-log.md` (except the
first 12 lines at the log step). The signatures below are complete.
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs crates/loopd/tests/
```
## Goal
`subagent` is in `Registry::m5c()`, not core. `find_tool` finds it. A new session's epoch
`tools` array stays `clock`, `recall`, `memory`, `ask_memory`, `find_tool`, `call_tool`
(spec 3.2). `loopd` does not answer the call yet (task 02).
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/subagent.rs` | new: `SUBAGENT`, `schema()` |
| `crates/loopd/src/lib.rs` | `pub mod subagent;` |
| `crates/loopd/src/tools.rs` | `m5c()` pushes the non-core entry |
| `crates/loopd/tests/subagent_registry.rs` | new, given |
| everything else | **no change**: not `dispatch`, not `turn.rs` |
## Files
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
`crates/loopd/tests/subagent_registry.rs`.
- Create: `crates/loopd/src/subagent.rs` (the module below).
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
At the log-and-commit step, read only the first 12 lines of `docs/implementer-log.md`.
## Facts you need
- **Not core.** `core_schemas()` already filters `e.core`. A non-core `Entry` is found by
`find` and by `get`, and is absent from `Baseline.tools`.
- **`m5c()` only.** Do not add the entry to `m2b`, `m3a`, or `m5a`. `loopd serve` uses `m5c`.
- **`push`, not `insert`.** `ask_memory` stays at index 3. `subagent` is last among the
non-core names, after `http_fetch`.
- rustfmt `mod` order: `subagent` after `sse` / before `summarise` as rustfmt decides.
- Same-crate modules may import each other. This task's module imports nothing from
`turn` or `tools`.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `ToolSchema` | `{ name: String, description: String, parameters: serde_json::Value }` in `crate::llama` |
| `Entry` | `{ schema: ToolSchema, core: bool }` |
| `Registry::m5c` | `m5c() -> Registry` |
| `Registry::get` | `get(&self, name: &str) -> Option<&Entry>` |
| `Registry::find` | `find(&self, query: &str) -> Vec<&ToolSchema>` |
| `Registry::core_schemas` | `core_schemas(&self) -> Vec<ToolSchema>` |
## The edits, exactly
### `crates/loopd/src/subagent.rs`
Write this module. Do not add `parse_task`, `dispatch_args`, or `run` (task 02 / 03).
```rust
//! The `subagent` tool: loopd answers it itself (M6 spec, section 3).
//! It is not core; the parent finds it with `find_tool` and calls it through `call_tool`.
pub const SUBAGENT: &str = "subagent";
pub fn schema() -> crate::llama::ToolSchema {
crate::llama::ToolSchema {
name: SUBAGENT.to_string(),
description: "Run a short task in an isolated child session on the background slot."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"task": {
"type": "string",
"description": "What the child should do."
}
},
"required": ["task"]
}),
}
}
```
### `crates/loopd/src/lib.rs`
**L1.** After `pub mod sse;` add `pub mod subagent;`. rustfmt decides the final order.
### `crates/loopd/src/tools.rs`
**R1.** The lines (exactly once, inside `m5c`)
```rust
entries.insert(
3,
Entry {
schema: crate::memory::ask_memory_schema(),
core: true,
},
);
Registry::new(entries)
```
become
```rust
entries.insert(
3,
Entry {
schema: crate::memory::ask_memory_schema(),
core: true,
},
);
entries.push(Entry {
schema: crate::subagent::schema(),
core: false,
});
Registry::new(entries)
```
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, with a shell timeout of
600000 ms: test target `subagent_registry` does not compile (`E0432` unresolved
`loopd::subagent`). Every other target compiles.
- [ ] **3. The edits.** Write `subagent.rs`, L1, R1. `cargo check -p loopd` prints no error
and no warning. `wc -l crates/loopd/src/subagent.rs` and `wc -l crates/loopd/src/tools.rs`
each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test subagent_registry --test ask_memory_tool
--test tools --test baseline`: every test passes, 2 in `subagent_registry`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/subagent.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`. `git status --short` also shows
the new test as untracked.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of
600000 ms. Expected last line: `gate: ok`. That is done counting: **1577** (1575 before,
plus the 2 of `subagent_registry.rs`). After `gate: ok`, do not add up `test result:`
lines and do not run `cargo test --workspace`.
- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
`M6a/01-loopd-subagent-registry`. Read only the first 12 lines of that file; insert the
row under the header. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says. `Cargo.lock` does not change in this task; the `git add` is harmless.
## Done when
- `make gate` prints `gate: ok` with 1577 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/subagent.rs`,
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`,
`crates/loopd/tests/subagent_registry.rs` and `docs/implementer-log.md`.
## Stop and report if
- The branch check at the top fails.
- A "before" block is not in its file exactly as shown, or is there more than once.
- Step 2 shows a different result: the tests compile, or a different crate fails.
- `cargo check` in step 3 prints an error or a warning. Quote it, and stop.
- A test fails in step 4. Do not change the test: stop, and give the failing tests' names
and their messages.
- The fix seems to need a change anywhere else. It does not; stop and say why you think so.
- A test outside `subagent_registry.rs` fails in the gate: check once whether it fails
without your change (`git stash -u`, run it, `git stash pop`), then stop and report both
results.
## What the given tests pin
`subagent` is in `m5c()`, `core` is false, `schema()` matches the entry, `find("subagent")`
and `find("isolated")` both hit it, and a baseline assembled from `m5c()` still lists only
the six core names in `tools`.
@@ -0,0 +1,282 @@
# M6a task 02: `Dispatch::Subagent`, unknown keys, child ids
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: dispatch subagent, not the port`
**Log row:** the Task cell is `M6a/02-loopd-subagent-dispatch`.
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
Do not Read `tests/support/`, `session.rs`, `docs/implementer-log.md` (except the first 12
lines at the log step). The signatures below are complete.
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_dispatch.rs crates/loopd/tests/
```
## Goal
`loopd` answers `subagent` itself: `dispatch` and `call_tool` return `Dispatch::Subagent`,
never `Port` (spec 3.2). Unknown keys, a missing `task`, a non-string `task`, and non-object
arguments are invalid-arguments. `child_id` builds `sub-<parent>-<n>` or returns the raw
string that would not fit a `SessionId`.
Spawn (hold, turn, `ToolResult`) is task 03. This task's `run` is a compiling stub so
`run_call` can match the new variant.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/subagent.rs` | `child_id`, `parse_task`, `dispatch_args`, stub `run` |
| `crates/loopd/src/tools.rs` | `Dispatch::Subagent`; `dispatch` and `dispatch_call_tool` route it |
| `crates/loopd/src/turn.rs` | `run_call` matches `Subagent` and calls the stub |
| `crates/loopd/tests/subagent_dispatch.rs` | new, given |
| everything else | **no change**: not `Runtime.ctx`, not the scheduler |
## Files
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
`crates/loopd/tests/subagent_dispatch.rs`.
- Modify: `crates/loopd/src/subagent.rs`, `crates/loopd/src/tools.rs`,
`crates/loopd/src/turn.rs`, `docs/implementer-log.md`.
- Do not touch: any other file.
At the log-and-commit step, read only the first 12 lines of `docs/implementer-log.md`.
## Facts you need
- **Unknown keys are errors.** Walk every key of the object. A key other than `task` is
`unknown field: {key}`. Missing `task` is `task is required`. A non-string `task` is
`task must be a string`. A non-object or unparseable value is
`arguments must be a JSON object`.
- **`denial_message(InvalidArguments, Some(&detail))`** is the Local text. It already
formats `Denied: the arguments are not valid for this tool: <detail>.`
- **Both routes.** `dispatch(registry, "subagent", args)` and
`dispatch(registry, "call_tool", {"name":"subagent","arguments":{...}})` both call
`dispatch_args`. A check in only one of those two functions fails a given test.
- **`child_id`.** `format!("sub-{}-{}", parent.as_str(), n.0)` then `SessionId::new`.
On `Err`, return that raw `String` (the given test asserts `len() > 64` and
`starts_with("sub-")`). A parent of 59 `a`s plus `-1` is 65 bytes.
- **`CallId(pub u64)`.** The field is `.0`.
- **`run_call` is exhaustive.** Adding a variant without a match arm does not compile
(`E0004`). The stub body is replaced in task 03; do not hold a session here.
- Same-crate circular imports (`subagent` → `turn` / `tools`, `turn` → `subagent`) are
allowed.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `SessionId::new` | `new(s: &str) -> Result<SessionId, ValueError>` (rejects empty or `len() > 64`) |
| `SessionId::as_str` | `as_str(&self) -> &str` |
| `CallId` | `CallId(pub u64)` |
| `denial_message` | `denial_message(reason: DenyReason, detail: Option<&str>) -> String` |
| `DenyReason::InvalidArguments` | in `bxcore` |
| `dispatch` | `dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch` |
| `Runtime` | `{ cfg, client, port, registry }` (no `ctx` yet; task 03) |
| `Session::id` | `id(&self) -> &SessionId` |
## The edits, exactly
### `crates/loopd/src/tools.rs`
**D1.** The lines (exactly once)
```rust
pub enum Dispatch {
Local(String),
Port { tool: String, arguments: String },
}
```
become
```rust
pub enum Dispatch {
Local(String),
Port { tool: String, arguments: String },
Subagent { task: String },
}
```
**D2.** In `dispatch`, the lines (exactly once)
```rust
if name == CLOCK {
return Dispatch::Local(bxcore::Timestamp::now().to_rfc3339());
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
}
```
become
```rust
if name == CLOCK {
return Dispatch::Local(bxcore::Timestamp::now().to_rfc3339());
}
if name == crate::subagent::SUBAGENT {
return crate::subagent::dispatch_args(arguments);
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
}
```
**D3.** In `dispatch_call_tool`, inside `Some(_)`, the lines (exactly once)
```rust
let args_text =
serde_json::to_string(args_value).unwrap_or_else(|_| "null".to_string());
Dispatch::Port {
tool: name.to_string(),
arguments: args_text,
}
```
become
```rust
let args_text =
serde_json::to_string(args_value).unwrap_or_else(|_| "null".to_string());
if name == crate::subagent::SUBAGENT {
return crate::subagent::dispatch_args(&args_text);
}
Dispatch::Port {
tool: name.to_string(),
arguments: args_text,
}
```
### `crates/loopd/src/turn.rs`
**T1.** In `run_call`, the lines (exactly once)
```rust
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
crate::tools::Dispatch::Port { tool, arguments } => {
```
become
```rust
crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
crate::tools::Dispatch::Subagent { task } => {
crate::subagent::run(rt, session, &task, call_id, on_event)
}
crate::tools::Dispatch::Port { tool, arguments } => {
```
### `crates/loopd/src/subagent.rs`
Append these items after `schema`. Keep `SUBAGENT` and `schema` as task 01 left them. Fill
`parse_task` then `dispatch_args` then the stub `run`, `cargo check -p loopd` after each
(T25).
```rust
/// `sub-<parent>-<n>`, or the raw string that would not fit a `SessionId`.
pub fn child_id(parent: &bxcore::SessionId, n: bxcore::CallId) -> Result<bxcore::SessionId, String> {
let raw = format!("sub-{}-{}", parent.as_str(), n.0);
bxcore::SessionId::new(&raw).map_err(|_| raw)
}
/// Inner arguments of `subagent` (or of `call_tool` after unwrap). Unknown keys are errors.
pub fn parse_task(arguments: &str) -> Result<String, String> {
let value: serde_json::Value = match serde_json::from_str(arguments) {
Ok(value) => value,
Err(_) => return Err("arguments must be a JSON object".to_string()),
};
let obj = match value.as_object() {
Some(obj) => obj,
None => return Err("arguments must be a JSON object".to_string()),
};
for key in obj.keys() {
if key != "task" {
return Err(format!("unknown field: {key}"));
}
}
match obj.get("task") {
Some(serde_json::Value::String(text)) => Ok(text.clone()),
Some(_) => Err("task must be a string".to_string()),
None => Err("task is required".to_string()),
}
}
pub fn dispatch_args(arguments: &str) -> crate::tools::Dispatch {
match parse_task(arguments) {
Ok(task) => crate::tools::Dispatch::Subagent { task },
Err(detail) => crate::tools::Dispatch::Local(crate::tools::denial_message(
bxcore::DenyReason::InvalidArguments,
Some(&detail),
)),
}
}
/// Spawn is task 03. The match arm in `run_call` needs this name now.
pub fn run(
rt: &crate::turn::Runtime<'_>,
parent: &crate::session::Session,
task: &str,
call_id: bxcore::CallId,
on_event: &mut dyn FnMut(&bxcore::TurnEvent),
) -> (String, bxcore::DataClass, bool) {
let _ = (rt, parent, task, call_id, on_event);
(
"subagent stub".to_string(),
bxcore::DataClass::Public,
false,
)
}
```
rustfmt may collapse the `use` lines; that is fine. Do not add `Runtime.ctx`.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_dispatch.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms:
test target `subagent_dispatch` does not compile (`E0599` no variant `Subagent`, and/or
unresolved `child_id`). Every other target compiles.
- [ ] **3. The edits.** D1, D2, D3, T1, then the functions in `subagent.rs` (`cargo check -p
loopd` after `parse_task`, after `dispatch_args`, after the stub `run`). `cargo check -p
loopd` prints no error and no warning. `wc -l` of `subagent.rs`, `tools.rs`, `turn.rs`
each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test subagent_dispatch --test subagent_registry
--test tools`: every test passes, 3 in `subagent_dispatch` and 2 in `subagent_registry`.
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/subagent.rs`,
`crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`.
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1580** (1577 before, plus 3). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **7. Log and commit.** Task cell `M6a/02-loopd-subagent-dispatch`. Read only the first
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says.
## Done when
- `make gate` prints `gate: ok` with 1580 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/subagent.rs`,
`crates/loopd/src/tools.rs`, `crates/loopd/src/turn.rs`,
`crates/loopd/tests/subagent_dispatch.rs` and the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles or
fails a different target; `cargo check` warns; a test in `subagent_registry` or `tools`
fails; you add `Runtime.ctx`; you implement spawn; a test outside the named files fails
in the gate (`git stash -u`, rerun, `git stash pop`, report both).
## What the given tests pin
`call_tool` of `subagent` is `Dispatch::Subagent`, not `Port`; a direct `subagent` dispatch
is the same; unknown keys / missing / wrong-type `task` / non-object arguments are
invalid-arguments naming the given detail; `child_id` of a 59-character parent and
`CallId(1)` is `Err` of a 65-byte `sub-…` string, and `mm-thread` with `CallId(3)` is `Ok`.
+474
View File
@@ -0,0 +1,474 @@
# M6a task 03: spawn a child on the background slot
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: subagent spawn on the background slot`
**Log row:** the Task cell is `M6a/03-loopd-subagent-spawn`.
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
Do not Read `tests/support/` except the one `Runtime` literal this task names. Do not Read
`docs/implementer-log.md` except the first 12 lines at the log step. The signatures below
are complete.
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/
```
## Goal
A `subagent` call holds a child `sub-<parent>-<n>` (`n` is the `CallId` already consumed
for this call), runs one turn on `slots.background` with a fresh baseline and the M5d time
line, and returns a `ToolResult` `{id} completed\n{summary}` or `{id} failed` (spec 3).
The parent stays in this tool call. Nested `subagent` is the same path. A parent id that
cannot fit the child is invalid-arguments naming `subagent-id`.
`Runtime` gains `ctx: Option<&Context>` so spawn can `hold`. Tests that do not spawn leave
it `None`. Every existing `Runtime {` literal must name the new field or the crate does
not compile.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/turn.rs` | `Runtime.ctx` |
| `crates/loopd/src/channel.rs` | live `Runtime` passes `Some(&*ctx)` |
| `crates/loopd/src/scheduler.rs` | both `Runtime` literals pass `Some(ctx)` |
| `crates/loopd/src/subagent.rs` | replace the stub `run` |
| `docs/runbook.md` | append `subagent-id` |
| nine test `Runtime {` literals | `ctx: None` |
| `crates/loopd/tests/subagent_spawn.rs` | new, given |
| everything else | **no change**: not the scheduler skip (task 04), not `brokerd` (task 05) |
## Files
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
`crates/loopd/tests/subagent_spawn.rs`.
- Append, from `docs/plans/M6a/files/runbook-subagent-id.md`, to `docs/runbook.md`.
- Modify: `crates/loopd/src/turn.rs`, `crates/loopd/src/channel.rs`,
`crates/loopd/src/scheduler.rs`, `crates/loopd/src/subagent.rs`,
`crates/loopd/tests/support/turn.rs`, `crates/loopd/tests/session_slot.rs`,
`crates/loopd/tests/end_to_end.rs`, `crates/loopd/tests/turn_broker.rs`,
`crates/loopd/tests/tool_summary_call.rs`, `crates/loopd/tests/compact_turn.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file.
At the log-and-commit step, read only the first 12 lines of `docs/implementer-log.md`.
## Facts you need
- **`CallId` is already consumed.** `run_turn` calls `session.next_call()` then `run_call`.
The child's `n` is that `call_id`, not `next_call` again. First call on `mm-thread` is
`sub-mm-thread-1`. A nested call from that child is `sub-sub-mm-thread-1-1`.
- **Overflow.** `SessionId` max 64. Build `raw = format!("sub-{}-{}", parent.id().as_str(),
call_id.0)` first. `SessionId::new` `Err` →
`denial_message(InvalidArguments, Some(&format!("child session id `{raw}` would not fit; {RUNBOOK}")))`
with `RUNBOOK = "see docs/runbook.md#subagent-id"`. Do not `hold`.
- **`rt.ctx`.** `None` (unit tests that never set it) → `{id} failed`. `hold` `Busy` →
`{id} failed`. Memory `block` / `assemble_with` errors → drop the hold, `{id} failed`.
- **Baseline.** `ctx.memory.block()` then `Baseline::assemble_with(rt.cfg, rt.registry,
&block)`, not `assemble`. Speaker `None`.
- **User text.** Cap `task` with `cap_result(task, rt.cfg.r#loop.tool_result_cap)`. Then
`format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339())` — same shape as
heartbeat, current time (the given tests only check the prefix `It is ` and that the
task string is in the child user message).
- **Session.** `Session::create(..., slots.background)`. `Exists` → `Session::open`. Other
errors → drop hold, `{id} failed`.
- **The turn.** `run_turn(&mut session, rt, &text, None, on_event)` then
`compact::finish_turn(..., &*ctx.memory, ..., on_event, outcome)`. Then
`ctx.memory.updated(session.id(), session.turns())` (ignore the result). Read
`session.untrusted()` **before** `drop(held)`.
- **Result.** `Ok(done)` →
`format!("{} completed\n{summary}", child.as_str())` where
`summary = compact::cap_chars(neutralise(&done.content).as_ref(), rt.cfg.compact.summary_chars)`.
`Err(_)` (including `TurnLimit` with pending tool_calls) → `format!("{} failed", ...)`.
Class `Public`. The untrusted flag is the child's, after the turn.
- **Reuse the same `Runtime`.** The child turn uses `rt`, so it can spawn a grandchild
(`rt.ctx` is still the live context). Nested is the same wait path; no new queue.
- **`Some(&*ctx)` vs `Some(ctx)`.** `handle` has `Arc<Context>`; deref to `&Context`.
The scheduler already holds `&Context`.
- **Every `Runtime {` in `crates/loopd`.** Adding a field without filling a literal is
`E0063`. Production: `Some`. Tests that do not spawn: `None`. The given spawn test
already writes `ctx: Some(ctx)`.
- `cap_chars` is `pub(crate)` in `compact.rs`. `neutralise` returns `Cow<'_, str>`.
- rustfmt may wrap `Session::create`. That is fine.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `Context::hold` | `hold(&self, id: SessionId) -> Result<Held<'_>, Busy>` |
| `MemorySource::block` | `block(&self) -> Result<String, String>` |
| `MemorySource::updated` | `updated(&self, session: &SessionId, turn: u64) -> Result<(), String>` |
| `Baseline::assemble_with` | `assemble_with(cfg, registry, memory: &str) -> Result<Baseline, _>` |
| `Session::create` | `create(home, id, baseline, slot: u32) -> Result<Session, SessionError>` |
| `Session::open` | `open(home, id) -> Result<Session, SessionError>` |
| `SessionError::Exists` | `_` |
| `run_turn` | `run_turn(&mut Session, &Runtime, &str, Option<&str>, &mut dyn FnMut(&TurnEvent))` |
| `compact::finish_turn` | `finish_turn(&mut Session, &Runtime, &dyn MemorySource, &str, Option<&str>, &mut dyn FnMut(&TurnEvent), Result<TurnOutcome, TurnError>)` |
| `compact::cap_chars` | `cap_chars(s: &str, n: usize) -> String` |
| `cap_result` | `cap_result(text: &str, cap: usize) -> (String, bool)` |
| `denial_message` | as task 02 |
| `Timestamp::now` | `now() -> Timestamp`; `to_rfc3339() -> String` |
| `Session::untrusted` | `untrusted(&self) -> bool` |
| `Session::turns` | `turns(&self) -> u64` |
| `Session::id` | `id(&self) -> &SessionId` |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 02.
### `crates/loopd/src/turn.rs`
**R1.** The lines
```rust
pub struct Runtime<'a> {
pub cfg: &'a Config,
pub client: &'a Client,
pub port: &'a dyn ToolPort,
pub registry: &'a Registry,
}
```
become
```rust
pub struct Runtime<'a> {
pub cfg: &'a Config,
pub client: &'a Client,
pub port: &'a dyn ToolPort,
pub registry: &'a Registry,
/// Present on a live `loopd` turn so `subagent` can hold the child. Tests that do not spawn
/// leave this `None`.
pub ctx: Option<&'a crate::channel::Context>,
}
```
### Production `Runtime` literals: `ctx: Some(...)`
**C1.** In `crates/loopd/src/channel.rs`, the lines
```rust
let runtime = Runtime {
cfg: &ctx.cfg,
client: &ctx.client,
port: &*ctx.port,
registry: &ctx.registry,
};
let mut session = if turn.resume {
```
become the same struct with `ctx: Some(&*ctx),` after `registry`.
**S1.** In `crates/loopd/src/scheduler.rs`, the cron literal (the one followed by
`job.prompt`)
```rust
let runtime = Runtime {
cfg: &ctx.cfg,
client: &ctx.client,
port: &*ctx.port,
registry: &ctx.registry,
};
let text = format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt);
```
gains `ctx: Some(ctx),` after `registry`.
**S2.** In the same file, the heartbeat literal (the one followed by `{file}`)
```rust
let runtime = Runtime {
cfg: &ctx.cfg,
client: &ctx.client,
port: &*ctx.port,
registry: &ctx.registry,
};
let text = format!("It is {}.\n\n{file}", now.to_rfc3339());
```
gains `ctx: Some(ctx),` after `registry`.
### Test `Runtime` literals: `ctx: None`
Add `ctx: None,` after `registry` in each of these, using the unique surrounding lines.
**U1.** `crates/loopd/tests/support/turn.rs`
```rust
pub fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
```
**U2.** `crates/loopd/tests/compact_turn.rs` helper (not `pub`)
```rust
fn runtime(&self) -> Runtime<'_> {
Runtime {
cfg: &self.cfg,
client: &self.client,
port: &self.port,
registry: &self.registry,
}
}
```
**U3.** `crates/loopd/tests/session_slot.rs`
```rust
) -> Runtime<'a> {
Runtime {
cfg,
client,
port,
registry,
}
}
```
**U4.** `crates/loopd/tests/end_to_end.rs`
```rust
let runtime = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(
&mut session,
&runtime,
"what is this host called?",
```
**U5.** `crates/loopd/tests/turn_broker.rs`
```rust
let runtime = loopd::turn::Runtime {
cfg: &s.cfg,
client: &s.client,
port: &ReturnsPendingPort,
registry: &s.registry,
};
```
**U6.** `crates/loopd/tests/tool_summary_call.rs`
```rust
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
```
**U7.** `crates/loopd/tests/compact_turn.rs` second literal
```rust
let rt = Runtime {
cfg: &cfg,
client: &client,
port: &port,
registry: &registry,
};
let mut events = Vec::new();
let outcome = run_turn(&mut s, &rt, "hi", None, &mut |e| events.push(e.clone()));
```
There are ten `Runtime {` literals in `crates/loopd` after task 02 (three production,
seven test). After this task every one names `ctx`. If `cargo check -p loopd` reports
`E0063 missing field ctx`, the file it names is one of the rows above; fill it, do not
search the tree.
### `crates/loopd/src/subagent.rs`
Replace the stub `run` (the whole function task 02 added, from `/// Spawn is task 03`
through its closing `}`) with the body below. Add `pub const RUNBOOK` next to `SUBAGENT`.
Add the `use` lines the body needs (`Baseline`, `Busy`, `compact`, `Session` /
`SessionError`, `Dispatch` is already used, `cap_result`, `denial_message`, `Runtime`,
`run_turn`, `neutralise`, `CallId`, `DataClass`, `DenyReason`, `SessionId`, `Timestamp`,
`TurnEvent`). rustfmt decides `use` order.
```rust
pub const RUNBOOK: &str = "see docs/runbook.md#subagent-id";
/// Spawn the child, run one turn on the background slot, and return the parent's tool text.
pub fn run(
rt: &Runtime<'_>,
parent: &Session,
task: &str,
call_id: CallId,
on_event: &mut dyn FnMut(&TurnEvent),
) -> (String, DataClass, bool) {
let raw = format!("sub-{}-{}", parent.id().as_str(), call_id.0);
let child = match SessionId::new(&raw) {
Ok(id) => id,
Err(_) => {
return (
denial_message(
DenyReason::InvalidArguments,
Some(&format!(
"child session id `{raw}` would not fit; {RUNBOOK}"
)),
),
DataClass::Public,
false,
);
}
};
let Some(ctx) = rt.ctx else {
return failed(&child);
};
let held = match ctx.hold(child.clone()) {
Ok(held) => held,
Err(Busy) => return failed(&child),
};
let block = match ctx.memory.block() {
Ok(block) => block,
Err(_) => {
drop(held);
return failed(&child);
}
};
let baseline = match Baseline::assemble_with(rt.cfg, rt.registry, &block) {
Ok(baseline) => baseline,
Err(_) => {
drop(held);
return failed(&child);
}
};
let (task, _) = cap_result(task, rt.cfg.r#loop.tool_result_cap);
let text = format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339());
let mut session = match Session::create(
&rt.cfg.paths.home,
child.clone(),
baseline,
rt.cfg.slots.background,
) {
Ok(session) => session,
Err(SessionError::Exists(_)) => match Session::open(&rt.cfg.paths.home, child.clone()) {
Ok(session) => session,
Err(_) => {
drop(held);
return failed(&child);
}
},
Err(_) => {
drop(held);
return failed(&child);
}
};
let outcome = run_turn(&mut session, rt, &text, None, on_event);
let outcome = compact::finish_turn(
&mut session,
rt,
&*ctx.memory,
&text,
None,
on_event,
outcome,
);
let _ = ctx.memory.updated(session.id(), session.turns());
let untrusted = session.untrusted();
drop(held);
match outcome {
Ok(done) => {
let summary = compact::cap_chars(
neutralise(&done.content).as_ref(),
rt.cfg.compact.summary_chars,
);
(
format!("{} completed\n{summary}", child.as_str()),
DataClass::Public,
untrusted,
)
}
Err(_) => (
format!("{} failed", child.as_str()),
DataClass::Public,
untrusted,
),
}
}
fn failed(id: &SessionId) -> (String, DataClass, bool) {
(format!("{} failed", id.as_str()), DataClass::Public, false)
}
```
Fill `run` in this order, `cargo check -p loopd` after each (T25): overflow / `rt.ctx` /
`hold`; baseline and create; `run_turn` / `finish_turn` / the `ToolResult` text.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms:
test target `subagent_spawn` does not compile (`E0560` / `E0063`: `Runtime` has no field
`ctx`). Every other target compiles.
- [ ] **3. The edits.** R1, then C1, S1, S2, U1–U7 (`cargo check -p loopd` after the struct
field will fail on each still-missing literal; fill them, do not grep). Then replace
`run`. Then the runbook:
```sh
{ echo; cat docs/plans/M6a/files/runbook-subagent-id.md; } >> docs/runbook.md
```
`grep -c '^## subagent-id$' docs/runbook.md` prints `1`. `cargo check -p loopd` prints
no error and no warning. `wc -l` of `subagent.rs`, `turn.rs`, `channel.rs`,
`scheduler.rs` each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test subagent_spawn --test subagent_dispatch
--test subagent_registry --test session_slot --test channel --test compact_turn --test
tool_summary_call`: every test passes, 5 in `subagent_spawn`.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1585** (1580 before, plus 5). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **6. Log and commit.** Task cell `M6a/03-loopd-subagent-spawn`. Read only the first
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit
as `AGENTS.md` says.
## Done when
- `make gate` prints `gate: ok` with 1585 tests.
- `git diff HEAD~1 --stat` lists only the files in the Files section plus the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 compiles or
fails a different target; `cargo check` names a `Runtime` literal this task did not
list; you add `holds_prefix` (task 04) or edit `brokerd` (task 05); a test in
`session_slot` / `compact_turn` / `tool_summary_call` fails; `wc -l` of any named
source file is over 500; a test outside the named files fails in the gate
(`git stash -u`, rerun, `git stash pop`, report both).
## What the given tests pin
The child's request uses `slots.background` and a user line `It is …\n\n{task}`; the
parent's next request still extends its own prefix and carries `slots.main`; a second
spawn in the same parent is `sub-mm-thread-2`; a 59-character parent is invalid-arguments
naming `subagent-id` and does not start a child request; nested
`sub-sub-mm-thread-1-1` returns through the middle child's `ToolResult`; a child that
hits `TurnLimit` still on a tool call is `{id} failed`.
+184
View File
@@ -0,0 +1,184 @@
# M6a task 04: heartbeat and cron yield to a held subagent
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `loopd: scheduler yields to a held subagent`
**Log row:** the Task cell is `M6a/04-loopd-scheduler-skip`.
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
Do not Read `tests/support/`, `turn.rs`, `session.rs`, `docs/implementer-log.md` (except the
first 12 lines at the log step). The quoted blocks are the only edits.
```sh
cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/
```
## Goal
While any held session id starts with `sub-`, a due heartbeat does not start and a due
cron job does not start (spec 3.3). `SlotGate` is FIFO; skipping here is what stops
heartbeat and cron from jumping a child that is waiting on the background slot. Drop the
hold and the next `tick` runs them.
Leftovers still close on the first tick. Compaction already holds its session. Tests call
`tick(ctx, now)`; no sleeps (T27).
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/loopd/src/channel.rs` | `holds_prefix` |
| `crates/loopd/src/scheduler.rs` | skip heartbeat after due; skip jobs after heartbeat returns |
| `crates/loopd/tests/scheduler_subagent.rs` | new, given |
| everything else | **no change**: not `subagent.rs`, not `brokerd` |
## Files
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
`crates/loopd/tests/scheduler_subagent.rs`.
- Modify: `crates/loopd/src/channel.rs`, `crates/loopd/src/scheduler.rs`,
`docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `crates/loopd/tests/scheduler_heartbeat.rs`
or `scheduler_cron.rs`.
## Facts you need
- **`busy` is private.** Tests cannot see the set. `holds_prefix` locks it the same way
`hold` does (`unwrap_or_else(|p| p.into_inner())`) and returns whether any id
`as_str().starts_with(prefix)`.
- **Two skips, both required.** Heartbeat runs *before* the job loop. A skip only after
`heartbeat(...)` still lets a due heartbeat start. A skip only inside `heartbeat` still
lets a due cron job start. The given tests cover each.
- **Where in `heartbeat`.** After the `if !due { return; }` block, before
`SessionId::new("heartbeat")`. `every_s == 0` still returns earlier. Busy-skip of the
`heartbeat` session itself is unchanged.
- **Where in `tick`.** Immediately after `self.heartbeat(...)`, before `jobs_dir`.
`return work` so leftover-close on this first tick is already done.
- **Prefix `sub-`.** A hold of `heartbeat` or `job-…` does not skip. The given tests hold
`sub-mm-thread-1`.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `Context::hold` | already `pub`; the given tests keep the `Held` |
| `SessionId::as_str` | `as_str(&self) -> &str` |
| `Scheduler::tick` | `tick(&mut self, ctx: &Context, now: Timestamp) -> Vec<TickWork>` |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 03.
### `crates/loopd/src/channel.rs`
**H1.** After `hold`'s closing `}` and still inside `impl Context`, add:
```rust
/// True when any held session id starts with `prefix` (`sub-` for a live child).
pub fn holds_prefix(&self, prefix: &str) -> bool {
let busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
busy.iter().any(|id| id.as_str().starts_with(prefix))
}
```
The unique anchor is the end of `hold`:
```rust
busy.insert(id.clone());
drop(busy);
Ok(Held { ctx: self, id })
}
}
```
becomes that same `hold` body, then `holds_prefix`, then the `impl` closing `}`.
### `crates/loopd/src/scheduler.rs`
**K1.** In `tick`, the lines
```rust
self.heartbeat(ctx, now, &mut work);
let jobs_dir = ctx.cfg.paths.home.join("jobs");
```
become
```rust
self.heartbeat(ctx, now, &mut work);
if ctx.holds_prefix("sub-") {
return work;
}
let jobs_dir = ctx.cfg.paths.home.join("jobs");
```
**K2.** In `heartbeat`, the lines
```rust
if !due {
return;
}
let id = match SessionId::new("heartbeat") {
```
become
```rust
if !due {
return;
}
if ctx.holds_prefix("sub-") {
return;
}
let id = match SessionId::new("heartbeat") {
```
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/
```
- [ ] **2. See it fail.** `cargo test -p loopd --test scheduler_subagent`, timeout
600000 ms. The tests compile (`hold` exists). Both fail: `work` is not empty (a
`Heartbeat` or a `Job`) and `requests_to(CHAT)` is not empty while the child is held.
- [ ] **3. The edits.** H1, K2, K1. `cargo check -p loopd` prints no error and no
warning. `wc -l crates/loopd/src/channel.rs` and `wc -l crates/loopd/src/scheduler.rs`
each ≤ 500.
- [ ] **4. The tests.** `cargo test -p loopd --test scheduler_subagent --test
scheduler_heartbeat --test scheduler_cron --test scheduler_heartbeat_failed`: every
test passes, 2 in `scheduler_subagent`, 6 in `scheduler_heartbeat`, 6 in
`scheduler_cron`, 1 in `scheduler_heartbeat_failed`.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1587** (1585 before, plus 2). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **6. Log and commit.** Task cell `M6a/04-loopd-scheduler-skip`. Read only the first
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says.
## Done when
- `make gate` prints `gate: ok` with 1587 tests.
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/channel.rs`,
`crates/loopd/src/scheduler.rs`, `crates/loopd/tests/scheduler_subagent.rs` and the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 does not
compile, or only one of the two tests fails, or they fail for a different assertion;
`cargo check` warns; a heartbeat / cron / failed-heartbeat test fails; you skip only
one of K1/K2; a test outside the named files fails in the gate (`git stash -u`, rerun,
`git stash pop`, report both).
## What the given tests pin
A held `sub-mm-thread-1` makes a due heartbeat tick return empty work and send nothing,
then after `drop(held)` the next due tick is `Heartbeat` and one chat request; a held
`sub-*` likewise skips a due `0 7 * * *` job at 07:00, then the same timestamp after
the drop runs the job.
+168
View File
@@ -0,0 +1,168 @@
# M6a task 05: `brokerd` copies parent taint onto a new `sub-*` session
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
is empty; otherwise stop. Never switch branch.
**Commit subject:** `brokerd: inherit parent taint for a new subagent`
**Log row:** the Task cell is `M6a/05-brokerd-state-inherit`.
**First action.** Run this before any Read of crate source. Do not `ls`
`crates/brokerd/tests/`. Do not Read `policy.rs`, `loopd`, or `docs/implementer-log.md`
(except the first 12 lines at the log step). The quoted block is the only edit in
`read`.
```sh
cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/
```
## Goal
`brokerd` does not see the spawn (spec 3.4). The child's later tool calls, and
`memoryd`'s session-state question, go through `StateStore::read`. A missing file for a
`sub-<parent>-<n>` id copies the parent's taint and `untrusted` flag via `raise` (so the
child file exists). Nested ids recurse through a missing middle child. A missing file
for any other id is still a fresh private trusted session and still creates nothing.
Spawn is not a grant. `loopd` does not write `{home}/state/`.
## Every place that changes
| Place | What happens there |
|---|---|
| `crates/brokerd/src/state.rs` | missing `sub-*` inherits; other missing ids unchanged |
| `crates/brokerd/tests/state_subagent.rs` | new, given |
| everything else | **no change**: not `loopd`, not `raise`'s merge rules |
## Files
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
`crates/brokerd/tests/state_subagent.rs`.
- Modify: `crates/brokerd/src/state.rs`, `docs/implementer-log.md`.
- Do not touch: any other file. Do not edit `crates/brokerd/tests/state.rs`.
## Facts you need
- **`parent_of_sub`.** `session.as_str().strip_prefix("sub-")`, then `rsplit_once('-')`.
The right piece must be non-empty and all ASCII digits. `SessionId::new(parent)` on the
left piece. Anything else (`chat-1`, `sub-x` with no trailing digits, an empty parent)
is `None` and `read` returns `SessionState::default()` without writing.
- **Recursion.** `sub-sub-mm-thread-1-2` → parent `sub-mm-thread-1` → parent `mm-thread`.
`read` of a missing middle child inherits from the live parent the same way. Do not
special-case depth.
- **`raise`, not a silent struct.** `inherit_or_default` calls `store.read(&parent)?`
then `store.raise(session, SessionState::default(), Label { class: inherited.taint,
untrusted: inherited.untrusted })`. `raise` already maxes class at `Private` and ORs
`untrusted`, and creates the file. A later `read` of the child hits the file.
- **Plain sessions.** `a_session_with_no_file_is_private_and_trusted` in `state.rs` still
requires that `read("chat-1")` creates no `broker/` directory. Only a `sub-*` miss
writes.
- **`Label` and `SessionState`** are already imported in `state.rs`.
- The items the edits use. This table is complete.
| Item | Signature |
|---|---|
| `StateStore::read` | `read(&self, session: &SessionId) -> Result<SessionState, StateError>` |
| `StateStore::raise` | `raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result<SessionState, StateError>` |
| `SessionState::default` | private + trusted |
| `Label` | `{ class: DataClass, untrusted: bool }` |
| `SessionId::new` | `new(s: &str) -> Result<SessionId, ValueError>` |
| `SessionId::as_str` | `as_str(&self) -> &str` |
## The edits, exactly
Each quoted "before" block occurs exactly once after task 04 (the file is unchanged
since master).
### `crates/brokerd/src/state.rs`
**I1.** The line (exactly once, inside `read`)
```rust
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()),
```
becomes
```rust
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return inherit_or_default(self, session);
}
```
**I2.** After `impl StateStore` (after `persist`'s closing `}` and the `impl`'s closing
`}`, before `/// One line on disk`), add:
```rust
/// A missing `sub-<parent>-<n>` file copies the parent's taint and untrusted flag (M6 spec 3.4).
fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result<SessionState, StateError> {
let Some(parent) = parent_of_sub(session) else {
return Ok(SessionState::default());
};
let inherited = store.read(&parent)?;
store.raise(
session,
SessionState::default(),
Label {
class: inherited.taint,
untrusted: inherited.untrusted,
},
)
}
fn parent_of_sub(session: &SessionId) -> Option<SessionId> {
let rest = session.as_str().strip_prefix("sub-")?;
let (parent, n) = rest.rsplit_once('-')?;
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
SessionId::new(parent).ok()
}
```
Do not change `raise`. Do not change `persist`.
## Steps
- [ ] **1. Copy the given test.**
```sh
cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/
```
- [ ] **2. See it fail.** `cargo test -p brokerd --test state_subagent`, timeout 600000
ms. The tests compile (`StateStore::read` exists).
`a_new_subagent_copies_the_parents_untrusted_flag` fails: the child is the default
(private, trusted) and `sub-mm-thread-3.json` does not exist.
`a_nested_subagent_copies_through_a_missing_middle_child` fails: the leaf is not
`Secret` / untrusted. `a_plain_session_still_creates_nothing_on_read` passes.
- [ ] **3. The edits.** I1, I2. `cargo check -p brokerd` prints no error and no warning.
`wc -l crates/brokerd/src/state.rs` ≤ 500.
- [ ] **4. The tests.** `cargo test -p brokerd --test state_subagent --test state`: every
test passes, 3 in `state_subagent`. `a_session_with_no_file_is_private_and_trusted`
still passes.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
line `gate: ok`. Test count **1590** (1587 before, plus 3). After `gate: ok`, do not add
up `test result:` lines.
- [ ] **6. Log and commit.** Task cell `M6a/05-brokerd-state-inherit`. Read only the first
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
`git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
says.
## Done when
- `make gate` prints `gate: ok` with 1590 tests.
- `git diff HEAD~1 --stat` lists only `crates/brokerd/src/state.rs`,
`crates/brokerd/tests/state_subagent.rs` and the log.
## Stop and report if
- The branch check fails; a "before" block is missing or duplicated; step 2 does not
compile, or the plain-session test fails, or a different assertion fails; `cargo check`
warns; `state.rs` existing tests fail; you write a state file from `loopd`; a test
outside the named files fails in the gate (`git stash -u`, rerun, `git stash pop`,
report both).
## What the given tests pin
`read("sub-mm-thread-3")` of a parent raised to private+untrusted returns that pair and
creates the child file; `read("sub-sub-mm-thread-1-2")` with only `mm-thread` on disk
returns secret+untrusted; `read("chat-1")` still creates no `broker/` directory.
+123
View File
@@ -0,0 +1,123 @@
# 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.
@@ -0,0 +1,71 @@
//! A `sub-*` session copies its parent's taint. Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use brokerd::policy::{Label, SessionState};
use brokerd::state::StateStore;
use bxcore::{DataClass, SessionId};
use tmp::TempDir;
fn id(text: &str) -> SessionId {
SessionId::new(text).unwrap()
}
fn store(home: &TempDir) -> StateStore {
StateStore::new(&home.path().join("broker/sessions"))
}
#[test]
fn a_new_subagent_copies_the_parents_untrusted_flag() {
let home = TempDir::new("state-sub");
let store = store(&home);
let parent = id("mm-thread");
store
.raise(
&parent,
SessionState::default(),
Label {
class: DataClass::Private,
untrusted: true,
},
)
.unwrap();
let child = store.read(&id("sub-mm-thread-3")).unwrap();
assert_eq!(
child,
SessionState {
taint: DataClass::Private,
untrusted: true,
}
);
let path = home.path().join("broker/sessions/sub-mm-thread-3.json");
assert!(path.exists());
}
#[test]
fn a_nested_subagent_copies_through_a_missing_middle_child() {
let home = TempDir::new("state-sub-nest");
let store = store(&home);
store
.raise(
&id("mm-thread"),
SessionState::default(),
Label {
class: DataClass::Secret,
untrusted: true,
},
)
.unwrap();
let leaf = store.read(&id("sub-sub-mm-thread-1-2")).unwrap();
assert_eq!(leaf.taint, DataClass::Secret);
assert!(leaf.untrusted);
}
#[test]
fn a_plain_session_still_creates_nothing_on_read() {
let home = TempDir::new("state-plain");
let store = store(&home);
let _ = store.read(&id("chat-1")).unwrap();
assert!(!home.path().join("broker").exists());
}
@@ -0,0 +1,89 @@
//! Heartbeat and cron yield to a held subagent. Do not edit.
mod support;
use bxcore::{SessionId, Timestamp};
use loopd::channel::Context;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::scheduler::{Scheduler, TickWork};
use loopd::tools::Registry;
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
fn sse() -> Reply {
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"noted"}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn ts(s: &str) -> Timestamp {
Timestamp::parse(s).unwrap()
}
#[test]
fn a_held_subagent_skips_a_due_heartbeat() {
let home = Home::new();
home.write("heartbeat.md", "Look around.\n");
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.heartbeat.every_s = 60;
cfg.heartbeat.prompt = home.dir.join("heartbeat.md");
server.route(CHAT, vec![sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
let held = ctx
.hold(SessionId::new("sub-mm-thread-1").unwrap())
.expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
assert!(work.is_empty(), "{work:?}");
assert!(server.requests_to(CHAT).is_empty());
drop(held);
let work = sched.tick(&ctx, ts("2026-10-03T07:02:00.000Z"));
assert_eq!(work, vec![TickWork::Heartbeat]);
assert_eq!(server.requests_to(CHAT).len(), 1);
}
#[test]
fn a_held_subagent_skips_a_due_cron_job() {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
home.write(
"jobs/mail.toml",
"schedule = \"0 7 * * *\"\nprompt = \"Check mail.\"\n",
);
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, vec![sse()]);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m2b(),
Box::new(NoMemory),
));
let held = ctx
.hold(SessionId::new("sub-mm-thread-1").unwrap())
.expect("hold");
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert!(work.is_empty(), "{work:?}");
assert!(server.requests_to(CHAT).is_empty());
drop(held);
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
assert_eq!(work.len(), 1);
assert!(matches!(work[0], TickWork::Job { .. }), "{work:?}");
}
@@ -0,0 +1,57 @@
//! Dispatch of `subagent`: not the port, unknown keys, child ids. Do not edit.
use bxcore::CallId;
use bxcore::SessionId;
use loopd::subagent::{self, SUBAGENT};
use loopd::tools::{Dispatch, Registry, dispatch};
#[test]
fn call_tool_unwraps_subagent_and_does_not_send_it_to_the_port() {
let r = Registry::m5c();
match dispatch(
&r,
"call_tool",
r#"{"name":"subagent","arguments":{"task":"look around"}}"#,
) {
Dispatch::Subagent { task } => assert_eq!(task, "look around"),
other => panic!("{other:?}"),
}
match dispatch(&r, SUBAGENT, r#"{"task":"look around"}"#) {
Dispatch::Subagent { task } => assert_eq!(task, "look around"),
other => panic!("{other:?}"),
}
}
#[test]
fn unknown_keys_and_a_missing_task_are_invalid_arguments() {
let r = Registry::m5c();
let cases = [
(r#"{"task":"x","extra":1}"#, "unknown field: extra"),
(r#"{"text":"x"}"#, "unknown field: text"),
("{}", "task is required"),
(r#"{"task":1}"#, "task must be a string"),
("[]", "arguments must be a JSON object"),
("not json", "arguments must be a JSON object"),
];
for (args, want) in cases {
match dispatch(&r, SUBAGENT, args) {
Dispatch::Local(text) => {
assert!(
text.starts_with("Denied: the arguments are not valid for this tool:"),
"{args}: {text}"
);
assert!(text.contains(want), "{args}: {text}");
}
other => panic!("{args}: {other:?}"),
}
}
}
#[test]
fn a_parent_id_that_cannot_fit_a_child_is_named_in_the_denial() {
let parent = SessionId::new(&"a".repeat(59)).unwrap();
let err = subagent::child_id(&parent, CallId(1)).unwrap_err();
assert!(err.len() > 64, "{err}");
assert!(err.starts_with("sub-"), "{err}");
assert!(subagent::child_id(&SessionId::new("mm-thread").unwrap(), CallId(3)).is_ok());
}
@@ -0,0 +1,57 @@
//! `subagent` in the M5c registry, not core. Do not edit.
mod support;
use loopd::baseline::Baseline;
use loopd::subagent::{self, SUBAGENT};
use loopd::tools::Registry;
use std::path::Path;
use support::Home;
fn core_names(registry: &Registry) -> Vec<String> {
registry
.core_schemas()
.into_iter()
.map(|s| s.name)
.collect()
}
#[test]
fn subagent_is_in_m5c_and_is_not_core() {
let r = Registry::m5c();
let entry = r.get(SUBAGENT).expect("subagent");
assert!(!entry.core);
assert_eq!(entry.schema, subagent::schema());
assert_eq!(
core_names(&r),
[
"clock",
"recall",
"memory",
"ask_memory",
"find_tool",
"call_tool"
]
);
assert!(r.find("subagent").iter().any(|s| s.name == SUBAGENT));
assert!(r.find("isolated").iter().any(|s| s.name == SUBAGENT));
}
#[test]
fn a_baseline_from_m5c_does_not_put_subagent_in_the_tools_array() {
let home = Home::new();
let cfg = home.config(Path::new("/tmp/unused.sock"));
let baseline = Baseline::assemble(&cfg, &Registry::m5c()).unwrap();
let names: Vec<&str> = baseline.tools.iter().map(|t| t.name.as_str()).collect();
assert_eq!(
names,
[
"clock",
"recall",
"memory",
"ask_memory",
"find_tool",
"call_tool"
]
);
}
@@ -0,0 +1,313 @@
//! A `subagent` turn on the background slot. Do not edit.
mod support;
use bxcore::{LogRecord, SessionId};
use loopd::baseline::Baseline;
use loopd::channel::Context;
use loopd::llama::Client;
use loopd::memory::NoMemory;
use loopd::session::Session;
use loopd::tools::Registry;
use loopd::turn::{Runtime, run_turn};
use std::sync::Arc;
use support::{FakeServer, Home, Reply, ScriptedPort};
const CHAT: &str = "/v1/chat/completions";
fn sse_chunks(chunks: &[serde_json::Value]) -> Reply {
let mut body = String::new();
for chunk in chunks {
body.push_str("data: ");
body.push_str(&chunk.to_string());
body.push_str("\n\n");
}
body.push_str("data: [DONE]\n\n");
Reply::raw(format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
))
}
fn timings() -> serde_json::Value {
serde_json::json!({"cache_n": 0, "prompt_n": 10, "predicted_n": 1})
}
fn sse_text(text: &str) -> Reply {
sse_chunks(&[
serde_json::json!({
"choices": [{"finish_reason": null, "index": 0, "delta": {"content": text}}],
"id": "t",
"timings": timings()
}),
serde_json::json!({
"choices": [{"finish_reason": "stop", "index": 0, "delta": {}}],
"id": "t",
"timings": timings()
}),
])
}
fn sse_call_tool(tool: &str, arguments: serde_json::Value) -> Reply {
let args = serde_json::json!({"name": tool, "arguments": arguments}).to_string();
sse_named("call_tool", &args)
}
fn sse_named(name: &str, arguments: &str) -> Reply {
sse_chunks(&[
serde_json::json!({
"choices": [{
"finish_reason": null,
"index": 0,
"delta": {
"tool_calls": [{
"index": 0,
"id": "c1",
"type": "function",
"function": {"name": name, "arguments": arguments}
}]
}
}],
"id": "t",
"timings": timings()
}),
serde_json::json!({
"choices": [{"finish_reason": "tool_calls", "index": 0, "delta": {}}],
"id": "t",
"timings": timings()
}),
])
}
fn world(replies: Vec<Reply>) -> (Home, FakeServer, Arc<Context>) {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let cfg = home.config(&server.socket);
server.route(CHAT, replies);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m5c(),
Box::new(NoMemory),
));
(home, server, ctx)
}
fn runtime(ctx: &Context) -> Runtime<'_> {
Runtime {
cfg: &ctx.cfg,
client: &ctx.client,
port: &*ctx.port,
registry: &ctx.registry,
ctx: Some(ctx),
}
}
fn parent(ctx: &Context, id: &str) -> Session {
let baseline = Baseline::assemble(&ctx.cfg, &ctx.registry).unwrap();
Session::create(
&ctx.cfg.paths.home,
SessionId::new(id).unwrap(),
baseline,
ctx.cfg.slots.main,
)
.unwrap()
}
fn last_tool_text(session: &Session) -> String {
for record in session.records().iter().rev() {
if let LogRecord::ToolResult { content, .. } = record {
return content.clone();
}
}
panic!("no tool result");
}
#[test]
fn a_child_runs_on_the_background_slot_and_the_parent_prefix_still_extends() {
let (home, server, ctx) = world(vec![
sse_call_tool("subagent", serde_json::json!({"task": "look around"})),
sse_text("the drawer is empty"),
sse_text("ok"),
]);
let mut session = parent(&ctx, "mm-thread");
let rt = runtime(&ctx);
let mut events = Vec::new();
run_turn(&mut session, &rt, "search", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let sent = server.requests_to(CHAT);
assert_eq!(sent.len(), 3);
assert_eq!(sent[0].json()["id_slot"], ctx.cfg.slots.main);
assert_eq!(sent[1].json()["id_slot"], ctx.cfg.slots.background);
assert_eq!(sent[2].json()["id_slot"], ctx.cfg.slots.main);
let first = sent[0].json()["messages"].as_array().unwrap().clone();
let third = sent[2].json()["messages"].as_array().unwrap().clone();
assert!(third.len() > first.len());
assert_eq!(&third[..first.len()], &first[..]);
let tool = third[first.len() + 1].clone();
assert_eq!(tool["role"], "tool");
assert!(
tool["content"]
.as_str()
.unwrap()
.starts_with("sub-mm-thread-1 completed\n"),
"{tool}"
);
assert!(
tool["content"]
.as_str()
.unwrap()
.contains("the drawer is empty"),
"{tool}"
);
let child_body = sent[1].json();
let child_msgs = child_body["messages"].as_array().unwrap();
let last = child_msgs.last().unwrap();
assert_eq!(last["role"], "user");
let content = last["content"].as_str().unwrap();
assert!(content.starts_with("It is "), "{content}");
assert!(content.contains("look around"), "{content}");
let child = Session::open(&home.dir, SessionId::new("sub-mm-thread-1").unwrap()).unwrap();
assert_eq!(child.slot(), ctx.cfg.slots.background);
}
#[test]
fn a_second_spawn_in_the_same_parent_uses_the_next_call_id() {
let (_home, _server, ctx) = world(vec![
sse_call_tool("subagent", serde_json::json!({"task": "one"})),
sse_text("first"),
sse_call_tool("subagent", serde_json::json!({"task": "two"})),
sse_text("second"),
sse_text("ok"),
]);
let mut session = parent(&ctx, "mm-thread");
let rt = runtime(&ctx);
let mut events = Vec::new();
run_turn(&mut session, &rt, "both", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let texts: Vec<String> = session
.records()
.iter()
.filter_map(|r| match r {
LogRecord::ToolResult { content, .. } => Some(content.clone()),
_ => None,
})
.collect();
assert!(
texts[0].starts_with("sub-mm-thread-1 completed\n"),
"{texts:?}"
);
assert!(
texts[1].starts_with("sub-mm-thread-2 completed\n"),
"{texts:?}"
);
}
#[test]
fn an_overflowing_parent_id_is_invalid_arguments() {
let parent_id = "a".repeat(59);
let (_home, server, ctx) = world(vec![
sse_call_tool("subagent", serde_json::json!({"task": "x"})),
sse_text("ok"),
]);
let mut session = parent(&ctx, &parent_id);
let rt = runtime(&ctx);
let mut events = Vec::new();
run_turn(&mut session, &rt, "go", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let text = last_tool_text(&session);
assert!(
text.contains("would not fit") && text.contains("subagent-id"),
"{text}"
);
assert_eq!(server.requests_to(CHAT).len(), 2);
}
#[test]
fn a_nested_child_returns_through_the_middle_tool_result() {
let (home, server, ctx) = world(vec![
sse_call_tool("subagent", serde_json::json!({"task": "outer"})),
sse_call_tool("subagent", serde_json::json!({"task": "inner"})),
sse_text("leaf"),
sse_text("middle"),
sse_text("ok"),
]);
let mut session = parent(&ctx, "mm-thread");
let rt = runtime(&ctx);
let mut events = Vec::new();
run_turn(&mut session, &rt, "nest", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let middle = Session::open(&home.dir, SessionId::new("sub-mm-thread-1").unwrap()).unwrap();
let leaf = Session::open(&home.dir, SessionId::new("sub-sub-mm-thread-1-1").unwrap()).unwrap();
assert_eq!(leaf.slot(), ctx.cfg.slots.background);
let middle_text = last_tool_text(&middle);
assert!(
middle_text.starts_with("sub-sub-mm-thread-1-1 completed\n"),
"{middle_text}"
);
assert!(middle_text.contains("leaf"), "{middle_text}");
let parent_text = last_tool_text(&session);
assert!(
parent_text.starts_with("sub-mm-thread-1 completed\n"),
"{parent_text}"
);
assert!(parent_text.contains("middle"), "{parent_text}");
let sent = server.requests_to(CHAT);
assert_eq!(sent[0].json()["id_slot"], 0);
assert_eq!(sent[1].json()["id_slot"], 1);
assert_eq!(sent[2].json()["id_slot"], 1);
let parent_first = sent[0].json()["messages"].as_array().unwrap().clone();
let parent_last = sent[4].json()["messages"].as_array().unwrap().clone();
assert_eq!(&parent_last[..parent_first.len()], &parent_first[..]);
}
#[test]
fn a_child_that_ends_on_a_tool_call_is_failed() {
let home = Home::new();
home.write("compact.md", "Summarise.\n");
let server = FakeServer::start();
let mut cfg = home.config(&server.socket);
cfg.r#loop.tool_iterations = 1;
server.route(
CHAT,
vec![
sse_call_tool("subagent", serde_json::json!({"task": "loop"})),
sse_named("clock", "{}"),
sse_named("clock", "{}"),
sse_text("ok"),
],
);
let ctx = Arc::new(Context::new(
cfg.clone(),
Client::new(cfg),
Box::new(ScriptedPort::new(vec![])),
Registry::m5c(),
Box::new(NoMemory),
));
let mut session = parent(&ctx, "mm-thread");
let rt = runtime(&ctx);
let mut events = Vec::new();
run_turn(&mut session, &rt, "go", None, &mut |e| {
events.push(e.clone())
})
.unwrap();
let text = last_tool_text(&session);
assert_eq!(text, "sub-mm-thread-1 failed");
drop(home);
}
@@ -0,0 +1,25 @@
## subagent-id
**What you see.** A `subagent` call is denied as invalid-arguments. The tool result names the
child id that would not fit and this entry:
```text
Denied: the arguments are not valid for this tool: child session id `sub-…` would not fit; see docs/runbook.md#subagent-id.
```
**Why.** A session id is `[a-z0-9-]`, at most 64 bytes. The child is `sub-<parent>-<n>` with
`n` the parent's next call id. A parent already near that limit, or a nested chain of
`sub-` prefixes, cannot spawn another child.
**Confirm.**
```sh
printf '%s' '<the id named in the denial>' | wc -c
```
**Fix.** Do the work in the current session, or start a shorter parent id (`bxctl chat` without
a long `--session`). Nested children share one background slot; a parallel pool is out of
scope.
**Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and
writes `sessions/sub-<parent>-<n>/`.