Files
boxmaker/docs/plans/M6a/02-loopd-subagent-dispatch.md
2026-10-03 19:39:35 -07:00

283 lines
10 KiB
Markdown

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