# 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`.