diff --git a/docs/plans/M6a/01-loopd-subagent-registry.md b/docs/plans/M6a/01-loopd-subagent-registry.md new file mode 100644 index 0000000..21f6eb1 --- /dev/null +++ b/docs/plans/M6a/01-loopd-subagent-registry.md @@ -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` | + +## 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`. diff --git a/docs/plans/M6a/02-loopd-subagent-dispatch.md b/docs/plans/M6a/02-loopd-subagent-dispatch.md new file mode 100644 index 0000000..eb9c725 --- /dev/null +++ b/docs/plans/M6a/02-loopd-subagent-dispatch.md @@ -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--` 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: .` +- **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` (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--`, or the raw string that would not fit a `SessionId`. +pub fn child_id(parent: &bxcore::SessionId, n: bxcore::CallId) -> Result { + 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 { + 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`. diff --git a/docs/plans/M6a/03-loopd-subagent-spawn.md b/docs/plans/M6a/03-loopd-subagent-spawn.md new file mode 100644 index 0000000..851a512 --- /dev/null +++ b/docs/plans/M6a/03-loopd-subagent-spawn.md @@ -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--` (`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`; 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, Busy>` | + | `MemorySource::block` | `block(&self) -> Result` | + | `MemorySource::updated` | `updated(&self, session: &SessionId, turn: u64) -> Result<(), String>` | + | `Baseline::assemble_with` | `assemble_with(cfg, registry, memory: &str) -> Result` | + | `Session::create` | `create(home, id, baseline, slot: u32) -> Result` | + | `Session::open` | `open(home, id) -> Result` | + | `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)` | + | `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: ®istry, + }; + 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: ®istry, + }; + 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: ®istry, + }; + 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`. diff --git a/docs/plans/M6a/04-loopd-scheduler-skip.md b/docs/plans/M6a/04-loopd-scheduler-skip.md new file mode 100644 index 0000000..8e9c4bf --- /dev/null +++ b/docs/plans/M6a/04-loopd-scheduler-skip.md @@ -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` | + +## 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. diff --git a/docs/plans/M6a/05-brokerd-state-inherit.md b/docs/plans/M6a/05-brokerd-state-inherit.md new file mode 100644 index 0000000..f68e0b7 --- /dev/null +++ b/docs/plans/M6a/05-brokerd-state-inherit.md @@ -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--` 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` | + | `StateStore::raise` | `raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result` | + | `SessionState::default` | private + trusted | + | `Label` | `{ class: DataClass, untrusted: bool }` | + | `SessionId::new` | `new(s: &str) -> Result` | + | `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--` file copies the parent's taint and untrusted flag (M6 spec 3.4). +fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result { + 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 { + 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. diff --git a/docs/plans/M6a/README.md b/docs/plans/M6a/README.md new file mode 100644 index 0000000..1fd9a25 --- /dev/null +++ b/docs/plans/M6a/README.md @@ -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--` 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--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--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. diff --git a/docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs b/docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs new file mode 100644 index 0000000..0dff951 --- /dev/null +++ b/docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs @@ -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()); +} diff --git a/docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs b/docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs new file mode 100644 index 0000000..562a8cb --- /dev/null +++ b/docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs @@ -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:?}"); +} diff --git a/docs/plans/M6a/files/crates/loopd/tests/subagent_dispatch.rs b/docs/plans/M6a/files/crates/loopd/tests/subagent_dispatch.rs new file mode 100644 index 0000000..4d2c314 --- /dev/null +++ b/docs/plans/M6a/files/crates/loopd/tests/subagent_dispatch.rs @@ -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()); +} diff --git a/docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs b/docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs new file mode 100644 index 0000000..b5ab677 --- /dev/null +++ b/docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs @@ -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 { + 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" + ] + ); +} diff --git a/docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs b/docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs new file mode 100644 index 0000000..5f9f348 --- /dev/null +++ b/docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs @@ -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) -> (Home, FakeServer, Arc) { + 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 = 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); +} diff --git a/docs/plans/M6a/files/runbook-subagent-id.md b/docs/plans/M6a/files/runbook-subagent-id.md new file mode 100644 index 0000000..649b404 --- /dev/null +++ b/docs/plans/M6a/files/runbook-subagent-id.md @@ -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--` 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' '' | 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--/`.