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

10 KiB

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.

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 as 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)

pub enum Dispatch {
    Local(String),
    Port { tool: String, arguments: String },
}

become

pub enum Dispatch {
    Local(String),
    Port { tool: String, arguments: String },
    Subagent { task: String },
}

D2. In dispatch, the lines (exactly once)

    if name == CLOCK {
        return Dispatch::Local(bxcore::Timestamp::now().to_rfc3339());
    }
    Dispatch::Port {
        tool: name.to_string(),
        arguments: arguments.to_string(),
    }

become

    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)

            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

            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)

        crate::tools::Dispatch::Local(text) => (text, DataClass::Public, false),
        crate::tools::Dispatch::Port { tool, arguments } => {

become

        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).

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

    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.