diff --git a/crates/loopd/src/lib.rs b/crates/loopd/src/lib.rs index d0b9127..d9ea4da 100644 --- a/crates/loopd/src/lib.rs +++ b/crates/loopd/src/lib.rs @@ -15,6 +15,7 @@ pub mod selftest; pub mod session; pub mod socket; pub mod sse; +pub mod subagent; pub mod summarise; pub mod tools; pub mod turn; diff --git a/crates/loopd/src/subagent.rs b/crates/loopd/src/subagent.rs new file mode 100644 index 0000000..d37bc07 --- /dev/null +++ b/crates/loopd/src/subagent.rs @@ -0,0 +1,22 @@ +//! The `subagent` tool: loopd answers it itself (M6 spec, section 3). +//! It is not core; the parent finds it with `find_tool` and calls it through `call_tool`. + +pub const SUBAGENT: &str = "subagent"; + +pub fn schema() -> crate::llama::ToolSchema { + crate::llama::ToolSchema { + name: SUBAGENT.to_string(), + description: "Run a short task in an isolated child session on the background slot." + .to_string(), + parameters: serde_json::json!({ + "type": "object", + "properties": { + "task": { + "type": "string", + "description": "What the child should do." + } + }, + "required": ["task"] + }), + } +} diff --git a/crates/loopd/src/tools.rs b/crates/loopd/src/tools.rs index b6e47a7..ecc7f17 100644 --- a/crates/loopd/src/tools.rs +++ b/crates/loopd/src/tools.rs @@ -114,6 +114,10 @@ impl Registry { core: true, }, ); + entries.push(Entry { + schema: crate::subagent::schema(), + core: false, + }); Registry::new(entries) } diff --git a/crates/loopd/tests/subagent_registry.rs b/crates/loopd/tests/subagent_registry.rs new file mode 100644 index 0000000..b5ab677 --- /dev/null +++ b/crates/loopd/tests/subagent_registry.rs @@ -0,0 +1,57 @@ +//! `subagent` in the M5c registry, not core. Do not edit. + +mod support; + +use loopd::baseline::Baseline; +use loopd::subagent::{self, SUBAGENT}; +use loopd::tools::Registry; +use std::path::Path; +use support::Home; + +fn core_names(registry: &Registry) -> Vec { + registry + .core_schemas() + .into_iter() + .map(|s| s.name) + .collect() +} + +#[test] +fn subagent_is_in_m5c_and_is_not_core() { + let r = Registry::m5c(); + let entry = r.get(SUBAGENT).expect("subagent"); + assert!(!entry.core); + assert_eq!(entry.schema, subagent::schema()); + assert_eq!( + core_names(&r), + [ + "clock", + "recall", + "memory", + "ask_memory", + "find_tool", + "call_tool" + ] + ); + assert!(r.find("subagent").iter().any(|s| s.name == SUBAGENT)); + assert!(r.find("isolated").iter().any(|s| s.name == SUBAGENT)); +} + +#[test] +fn a_baseline_from_m5c_does_not_put_subagent_in_the_tools_array() { + let home = Home::new(); + let cfg = home.config(Path::new("/tmp/unused.sock")); + let baseline = Baseline::assemble(&cfg, &Registry::m5c()).unwrap(); + let names: Vec<&str> = baseline.tools.iter().map(|t| t.name.as_str()).collect(); + assert_eq!( + names, + [ + "clock", + "recall", + "memory", + "ask_memory", + "find_tool", + "call_tool" + ] + ); +} diff --git a/docs/implementer-log.md b/docs/implementer-log.md index b8d889d..afe1ec1 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/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 | | M5d2/09-memoryd-summary-index | 2026-10-03 | done | 1 | pass | none | Copied the given test `crates/memoryd/tests/tool_summary_index.rs` from `docs/plans/M5d2/files/`. Edited `index_record` in `crates/memoryd/src/index.rs`, adding the `ToolSummary` arm before `_ => {}`: `Source { kind: "summary", role: "summary", class: *class, untrusted: *untrusted, ..base.clone() }` with `time` from the record, guarded by `if !content.trim().is_empty() && *class <= self.max_class`; `DataClass` implements `Ord` so `<=` compiles. The existing `if base.class > self.max_class` guard at the top of `index_record` stays, and the rolling-label update in `index_log` still looks at `ToolResult` only (no `ToolSummary` added there). Updated the module doc "never a tool's result" to say summaries of opted-in results are indexed. `cargo check -p memoryd` clean, no error or warning. All suites pass: `tool_summary_index` 5, `recall` 6. `cargo fmt --all` changed nothing after the edits. `make gate` printed `gate: ok` with 1568 tests (1563 before, plus 5). No new dependency, so `Cargo.lock` unchanged. | ornith-1.5-35b-a3b |