diff --git a/crates/loopd/src/subagent.rs b/crates/loopd/src/subagent.rs index d37bc07..9cd2ae8 100644 --- a/crates/loopd/src/subagent.rs +++ b/crates/loopd/src/subagent.rs @@ -20,3 +20,60 @@ pub fn schema() -> crate::llama::ToolSchema { }), } } + +/// `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, + ) +} diff --git a/crates/loopd/src/tools.rs b/crates/loopd/src/tools.rs index ecc7f17..f69ccfc 100644 --- a/crates/loopd/src/tools.rs +++ b/crates/loopd/src/tools.rs @@ -288,6 +288,7 @@ fn call_tool_schema() -> ToolSchema { pub enum Dispatch { Local(String), Port { tool: String, arguments: String }, + Subagent { task: String }, } /// Decides whether `loopd` handles a tool request itself or sends it to the port. @@ -301,6 +302,9 @@ pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch { 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(), @@ -348,6 +352,9 @@ fn dispatch_call_tool(registry: &Registry, arguments: &str) -> Dispatch { Some(_) => { 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, diff --git a/crates/loopd/src/turn.rs b/crates/loopd/src/turn.rs index 8770de7..3b71b62 100644 --- a/crates/loopd/src/turn.rs +++ b/crates/loopd/src/turn.rs @@ -281,6 +281,9 @@ fn run_call( ) -> (String, DataClass, bool) { match dispatch(rt.registry, &call.name, &call.arguments) { 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 } => { let request = ToolRequest { session: session.id().clone(), diff --git a/crates/loopd/tests/subagent_dispatch.rs b/crates/loopd/tests/subagent_dispatch.rs new file mode 100644 index 0000000..4d2c314 --- /dev/null +++ b/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/implementer-log.md b/docs/implementer-log.md index afe1ec1..b32a8be 100644 --- a/docs/implementer-log.md +++ b/docs/implementer-log.md @@ -6,6 +6,7 @@ reviewer adds findings under "Reviews" once per milestone. | Task | Date | Status | Gate runs | First gate | Deviations | Notes | Model | |---|---|---|---|---|---|---|---| +| M6a/02-loopd-subagent-dispatch | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_dispatch.rs` from `docs/plans/M6a/files/`. Step 2 (`cargo build --tests --keep-going -p loopd`) failed as expected: E0599 no `Subagent` variant and E0425 no `child_id`. Added `Dispatch::Subagent { task }` in tools.rs D1, routed `subagent` in `dispatch` and `dispatch_call_tool` (D2/D3) via `crate::subagent::dispatch_args`, added the `Subagent` arm in `run_call` (turn.rs T1) calling the stub `run`, and appended `child_id`, `parse_task`, `dispatch_args` and the stub `run` to subagent.rs. `parse_task` walks every key (unknown field), requires `task`, demands a string task and a JSON object arguments, and maps errors to `denial_message(InvalidArguments, Some(&detail))`. `run` stays a compiling stub, no spawn, no `Runtime.ctx`. `cargo check -p loopd` clean, no warning; `wc -l` prints 76 for subagent.rs, 477 for tools.rs, 332 for turn.rs, all under 500. All suites pass: `subagent_dispatch` 3, `subagent_registry` 2, `tools` 11. `make gate` printed `gate: ok` with 1580 tests (1577 before, plus 3). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M6a/01-loopd-subagent-registry | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/subagent_registry.rs` from `docs/plans/M6a/files/`. Created `crates/loopd/src/subagent.rs` with `SUBAGENT` and `schema()` (a non-core `ToolSchema`, no `parse_task`/`dispatch_args`/`run`). Added `pub mod subagent;` after `pub mod sse;` in lib.rs (rustfmt order). Edited `m5c()` in tools.rs to push the non-core `subagent` entry after the `ask_memory` insert at index 3, so `ask_memory` stays at 3 and `subagent` is last among the non-core names; `core_schemas()` still returns only the six core names and `find("subagent")`/`find("isolated")` both hit it. `cargo check -p loopd` clean, no warning; `wc -l` prints 22 for subagent.rs and 470 for tools.rs, both under 500. All suites pass: `subagent_registry` 2, `ask_memory_tool`, `tools` 11, `baseline` 6. `make gate` printed `gate: ok` with 1577 tests (1575 before, plus 2). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/11-loopd-heartbeat-failed-last | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/loopd/tests/scheduler_heartbeat_failed.rs` from `docs/plans/M5d2/files/`. Applied H1 in `crates/loopd/src/scheduler.rs` inside `heartbeat`: after the quoted block (found exactly once) the bookkeeping is now unconditional — `let _ = compact::finish_turn(...)` instead of binding `outcome`, then `drop(held)`, then `self.last_heartbeat = Some(now)` and `work.push(TickWork::Heartbeat)` with no `if outcome.is_ok()`. The unreadable-prompt and busy `return` paths stay untouched and never reach H1; cron `Finished`/`Failed` and the job arm are unchanged. `cargo test -p loopd --test scheduler_heartbeat_failed` failed first (`left: []`, `right: [Heartbeat]`), then passed with `scheduler_heartbeat` (6). `cargo check -p loopd` clean, no warning. `make gate` printed `gate: ok` with 1575 tests (1574 before, plus 1). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b | | M5d2/10-memoryd-tiers | 2026-10-03 | done | 2 | fail | none | Copied the given tests `crates/memoryd/tests/tiers.rs` and `crates/bxctl/tests/cli_memory_tiers.rs` and the starter `config/jobs/tier.toml.example` from `docs/plans/M5d2/files/`. Added `MemoryCommand::Tiers` after `Sync` in `bxcore/src/memory.rs` with the doc comment from M1. Created `crates/memoryd/src/tiers.rs` with `list`: identity entries first (memory.md then user.md via `store.entries`, each `hits 0 last -`), then notes from `notes::parse`, each joined to `recalls/` on `(noted, session, record)` where `hits` counts the lines holding a matching hit and `last` is the max `time` among them or `-`; moved/dropped notes are still listed as `note` with class `private` and `trusted`, and untrusted notes are marked. `recalls/` is read via `store.path("recalls")`: a missing directory yields an empty tally (all zeros), a file that exists but cannot be read is an error, and a line that does not parse is skipped. `Logged` and `Hit` deserialize without `deny_unknown_fields` so `epoch` and `query` on a line are ignored. Used a small `Tally { n: u64, last: String }` so the map is `HashMap<(String, i64), Tally>` and clippy's `type_complexity` stays off. Added `pub mod tiers;` after `pub mod socket;` in lib.rs, the `Tiers` arm in `serve/admin.rs`, `["tiers"]` in bxctl/src/memory.rs, `| tiers` in the USAGE line, and the edited USAGE pin in cli_memory_sync.rs (left cli_memory.rs untouched). First `make gate` failed on a third leading space I had put on the USAGE memory line, which the `usage_explains_the_speaker_flag` indentation check caught; restored it to two spaces. `cargo check` clean, no warning; `wc -l` prints 139 for tiers.rs. All suites pass: `tiers` 4, `cli_memory_tiers` 2 (plus `cli_memory` 4 and `cli_memory_sync` 4). `make gate` printed `gate: ok` with 1574 tests (1568 before, plus 6). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |