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 testcrates/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
taskisunknown field: {key}. Missingtaskistask is required. A non-stringtaskistask must be a string. A non-object or unparseable value isarguments must be a JSON object. denial_message(InvalidArguments, Some(&detail))is the Local text. It already formatsDenied: the arguments are not valid for this tool: <detail>.- Both routes.
dispatch(registry, "subagent", args)anddispatch(registry, "call_tool", {"name":"subagent","arguments":{...}})both calldispatch_args. A check in only one of those two functions fails a given test. child_id.format!("sub-{}-{}", parent.as_str(), n.0)thenSessionId::new. OnErr, return that rawString(the given test assertslen() > 64andstarts_with("sub-")). A parent of 59as plus-1is 65 bytes.CallId(pub u64). The field is.0.run_callis 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::newnew(s: &str) -> Result<SessionId, ValueError>(rejects empty orlen() > 64)SessionId::as_stras_str(&self) -> &strCallIdCallId(pub u64)denial_messagedenial_message(reason: DenyReason, detail: Option<&str>) -> StringDenyReason::InvalidArgumentsin bxcoredispatchdispatch(registry: &Registry, name: &str, arguments: &str) -> DispatchRuntime{ cfg, client, port, registry }(noctxyet; task 03)Session::idid(&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 targetsubagent_dispatchdoes not compile (E0599no variantSubagent, and/or unresolvedchild_id). Every other target compiles. -
3. The edits. D1, D2, D3, T1, then the functions in
subagent.rs(cargo check -p loopdafterparse_task, afterdispatch_args, after the stubrun).cargo check -p loopdprints no error and no warning.wc -lofsubagent.rs,tools.rs,turn.rseach ≤ 500. -
4. The tests.
cargo test -p loopd --test subagent_dispatch --test subagent_registry --test tools: every test passes, 3 insubagent_dispatchand 2 insubagent_registry. -
5. Check.
git diff --statlists exactlycrates/loopd/src/subagent.rs,crates/loopd/src/tools.rs,crates/loopd/src/turn.rs. -
6. Run the gate.
cargo fmt --all, thenmake gate, timeout 600000 ms. Last linegate: ok. Test count 1580 (1577 before, plus 3). Aftergate: ok, do not add uptest result:lines. -
7. Log and commit. Task cell
M6a/02-loopd-subagent-dispatch. Read only the first 12 lines ofdocs/implementer-log.md; insert the row under the header. Stage withgit add crates/loopd docs/implementer-log.md Cargo.lock, then commit asAGENTS.mdsays.
Done when
make gateprintsgate: okwith 1580 tests.git diff HEAD~1 --statlists onlycrates/loopd/src/subagent.rs,crates/loopd/src/tools.rs,crates/loopd/src/turn.rs,crates/loopd/tests/subagent_dispatch.rsand 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 checkwarns; a test insubagent_registryortoolsfails; you addRuntime.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.