docs: M6a implementation plan
This commit is contained in:
@@ -0,0 +1,184 @@
|
|||||||
|
# M6a task 01: `subagent` in the M5c registry, not core
|
||||||
|
|
||||||
|
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
|
||||||
|
is empty; otherwise stop. Never switch branch.
|
||||||
|
**Commit subject:** `loopd: subagent in the M5c registry, not core`
|
||||||
|
**Log row:** the Task cell is `M6a/01-loopd-subagent-registry`.
|
||||||
|
|
||||||
|
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
||||||
|
Do not Read `tests/support/`, `turn.rs`, `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_registry.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`subagent` is in `Registry::m5c()`, not core. `find_tool` finds it. A new session's epoch
|
||||||
|
`tools` array stays `clock`, `recall`, `memory`, `ask_memory`, `find_tool`, `call_tool`
|
||||||
|
(spec 3.2). `loopd` does not answer the call yet (task 02).
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/loopd/src/subagent.rs` | new: `SUBAGENT`, `schema()` |
|
||||||
|
| `crates/loopd/src/lib.rs` | `pub mod subagent;` |
|
||||||
|
| `crates/loopd/src/tools.rs` | `m5c()` pushes the non-core entry |
|
||||||
|
| `crates/loopd/tests/subagent_registry.rs` | new, given |
|
||||||
|
| everything else | **no change**: not `dispatch`, not `turn.rs` |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
|
||||||
|
`crates/loopd/tests/subagent_registry.rs`.
|
||||||
|
- Create: `crates/loopd/src/subagent.rs` (the module below).
|
||||||
|
- Modify: `crates/loopd/src/lib.rs`, `crates/loopd/src/tools.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
|
||||||
|
|
||||||
|
- **Not core.** `core_schemas()` already filters `e.core`. A non-core `Entry` is found by
|
||||||
|
`find` and by `get`, and is absent from `Baseline.tools`.
|
||||||
|
- **`m5c()` only.** Do not add the entry to `m2b`, `m3a`, or `m5a`. `loopd serve` uses `m5c`.
|
||||||
|
- **`push`, not `insert`.** `ask_memory` stays at index 3. `subagent` is last among the
|
||||||
|
non-core names, after `http_fetch`.
|
||||||
|
- rustfmt `mod` order: `subagent` after `sse` / before `summarise` as rustfmt decides.
|
||||||
|
- Same-crate modules may import each other. This task's module imports nothing from
|
||||||
|
`turn` or `tools`.
|
||||||
|
- The items the edits use. This table is complete.
|
||||||
|
|
||||||
|
| Item | Signature |
|
||||||
|
|---|---|
|
||||||
|
| `ToolSchema` | `{ name: String, description: String, parameters: serde_json::Value }` in `crate::llama` |
|
||||||
|
| `Entry` | `{ schema: ToolSchema, core: bool }` |
|
||||||
|
| `Registry::m5c` | `m5c() -> Registry` |
|
||||||
|
| `Registry::get` | `get(&self, name: &str) -> Option<&Entry>` |
|
||||||
|
| `Registry::find` | `find(&self, query: &str) -> Vec<&ToolSchema>` |
|
||||||
|
| `Registry::core_schemas` | `core_schemas(&self) -> Vec<ToolSchema>` |
|
||||||
|
|
||||||
|
## The edits, exactly
|
||||||
|
|
||||||
|
### `crates/loopd/src/subagent.rs`
|
||||||
|
|
||||||
|
Write this module. Do not add `parse_task`, `dispatch_args`, or `run` (task 02 / 03).
|
||||||
|
|
||||||
|
```rust
|
||||||
|
//! 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"]
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `crates/loopd/src/lib.rs`
|
||||||
|
|
||||||
|
**L1.** After `pub mod sse;` add `pub mod subagent;`. rustfmt decides the final order.
|
||||||
|
|
||||||
|
### `crates/loopd/src/tools.rs`
|
||||||
|
|
||||||
|
**R1.** The lines (exactly once, inside `m5c`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
entries.insert(
|
||||||
|
3,
|
||||||
|
Entry {
|
||||||
|
schema: crate::memory::ask_memory_schema(),
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Registry::new(entries)
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
entries.insert(
|
||||||
|
3,
|
||||||
|
Entry {
|
||||||
|
schema: crate::memory::ask_memory_schema(),
|
||||||
|
core: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
entries.push(Entry {
|
||||||
|
schema: crate::subagent::schema(),
|
||||||
|
core: false,
|
||||||
|
});
|
||||||
|
Registry::new(entries)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given test.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/loopd/tests/subagent_registry.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, with a shell timeout of
|
||||||
|
600000 ms: test target `subagent_registry` does not compile (`E0432` unresolved
|
||||||
|
`loopd::subagent`). Every other target compiles.
|
||||||
|
- [ ] **3. The edits.** Write `subagent.rs`, L1, R1. `cargo check -p loopd` prints no error
|
||||||
|
and no warning. `wc -l crates/loopd/src/subagent.rs` and `wc -l crates/loopd/src/tools.rs`
|
||||||
|
each ≤ 500.
|
||||||
|
- [ ] **4. The tests.** `cargo test -p loopd --test subagent_registry --test ask_memory_tool
|
||||||
|
--test tools --test baseline`: every test passes, 2 in `subagent_registry`.
|
||||||
|
- [ ] **5. Check.** `git diff --stat` lists exactly `crates/loopd/src/subagent.rs`,
|
||||||
|
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`. `git status --short` also shows
|
||||||
|
the new test as untracked.
|
||||||
|
- [ ] **6. Run the gate.** `cargo fmt --all`, then `make gate`, with a shell timeout of
|
||||||
|
600000 ms. Expected last line: `gate: ok`. That is done counting: **1577** (1575 before,
|
||||||
|
plus the 2 of `subagent_registry.rs`). After `gate: ok`, do not add up `test result:`
|
||||||
|
lines and do not run `cargo test --workspace`.
|
||||||
|
- [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md`; its Task cell is
|
||||||
|
`M6a/01-loopd-subagent-registry`. Read only the first 12 lines of that file; insert the
|
||||||
|
row under the header. Stage with
|
||||||
|
`git add crates/loopd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
|
||||||
|
says. `Cargo.lock` does not change in this task; the `git add` is harmless.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `make gate` prints `gate: ok` with 1577 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/subagent.rs`,
|
||||||
|
`crates/loopd/src/lib.rs`, `crates/loopd/src/tools.rs`,
|
||||||
|
`crates/loopd/tests/subagent_registry.rs` and `docs/implementer-log.md`.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- The branch check at the top fails.
|
||||||
|
- A "before" block is not in its file exactly as shown, or is there more than once.
|
||||||
|
- Step 2 shows a different result: the tests compile, or a different crate fails.
|
||||||
|
- `cargo check` in step 3 prints an error or a warning. Quote it, and stop.
|
||||||
|
- A test fails in step 4. Do not change the test: stop, and give the failing tests' names
|
||||||
|
and their messages.
|
||||||
|
- The fix seems to need a change anywhere else. It does not; stop and say why you think so.
|
||||||
|
- A test outside `subagent_registry.rs` fails in the gate: check once whether it fails
|
||||||
|
without your change (`git stash -u`, run it, `git stash pop`), then stop and report both
|
||||||
|
results.
|
||||||
|
|
||||||
|
## What the given tests pin
|
||||||
|
|
||||||
|
`subagent` is in `m5c()`, `core` is false, `schema()` matches the entry, `find("subagent")`
|
||||||
|
and `find("isolated")` both hit it, and a baseline assembled from `m5c()` still lists only
|
||||||
|
the six core names in `tools`.
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
# 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`.
|
||||||
@@ -0,0 +1,474 @@
|
|||||||
|
# M6a task 03: spawn a child on the background slot
|
||||||
|
|
||||||
|
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
|
||||||
|
is empty; otherwise stop. Never switch branch.
|
||||||
|
**Commit subject:** `loopd: subagent spawn on the background slot`
|
||||||
|
**Log row:** the Task cell is `M6a/03-loopd-subagent-spawn`.
|
||||||
|
|
||||||
|
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
||||||
|
Do not Read `tests/support/` except the one `Runtime` literal this task names. Do not Read
|
||||||
|
`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_spawn.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A `subagent` call holds a child `sub-<parent>-<n>` (`n` is the `CallId` already consumed
|
||||||
|
for this call), runs one turn on `slots.background` with a fresh baseline and the M5d time
|
||||||
|
line, and returns a `ToolResult` `{id} completed\n{summary}` or `{id} failed` (spec 3).
|
||||||
|
The parent stays in this tool call. Nested `subagent` is the same path. A parent id that
|
||||||
|
cannot fit the child is invalid-arguments naming `subagent-id`.
|
||||||
|
|
||||||
|
`Runtime` gains `ctx: Option<&Context>` so spawn can `hold`. Tests that do not spawn leave
|
||||||
|
it `None`. Every existing `Runtime {` literal must name the new field or the crate does
|
||||||
|
not compile.
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/loopd/src/turn.rs` | `Runtime.ctx` |
|
||||||
|
| `crates/loopd/src/channel.rs` | live `Runtime` passes `Some(&*ctx)` |
|
||||||
|
| `crates/loopd/src/scheduler.rs` | both `Runtime` literals pass `Some(ctx)` |
|
||||||
|
| `crates/loopd/src/subagent.rs` | replace the stub `run` |
|
||||||
|
| `docs/runbook.md` | append `subagent-id` |
|
||||||
|
| nine test `Runtime {` literals | `ctx: None` |
|
||||||
|
| `crates/loopd/tests/subagent_spawn.rs` | new, given |
|
||||||
|
| everything else | **no change**: not the scheduler skip (task 04), not `brokerd` (task 05) |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
|
||||||
|
`crates/loopd/tests/subagent_spawn.rs`.
|
||||||
|
- Append, from `docs/plans/M6a/files/runbook-subagent-id.md`, to `docs/runbook.md`.
|
||||||
|
- Modify: `crates/loopd/src/turn.rs`, `crates/loopd/src/channel.rs`,
|
||||||
|
`crates/loopd/src/scheduler.rs`, `crates/loopd/src/subagent.rs`,
|
||||||
|
`crates/loopd/tests/support/turn.rs`, `crates/loopd/tests/session_slot.rs`,
|
||||||
|
`crates/loopd/tests/end_to_end.rs`, `crates/loopd/tests/turn_broker.rs`,
|
||||||
|
`crates/loopd/tests/tool_summary_call.rs`, `crates/loopd/tests/compact_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
|
||||||
|
|
||||||
|
- **`CallId` is already consumed.** `run_turn` calls `session.next_call()` then `run_call`.
|
||||||
|
The child's `n` is that `call_id`, not `next_call` again. First call on `mm-thread` is
|
||||||
|
`sub-mm-thread-1`. A nested call from that child is `sub-sub-mm-thread-1-1`.
|
||||||
|
- **Overflow.** `SessionId` max 64. Build `raw = format!("sub-{}-{}", parent.id().as_str(),
|
||||||
|
call_id.0)` first. `SessionId::new` `Err` →
|
||||||
|
`denial_message(InvalidArguments, Some(&format!("child session id `{raw}` would not fit; {RUNBOOK}")))`
|
||||||
|
with `RUNBOOK = "see docs/runbook.md#subagent-id"`. Do not `hold`.
|
||||||
|
- **`rt.ctx`.** `None` (unit tests that never set it) → `{id} failed`. `hold` `Busy` →
|
||||||
|
`{id} failed`. Memory `block` / `assemble_with` errors → drop the hold, `{id} failed`.
|
||||||
|
- **Baseline.** `ctx.memory.block()` then `Baseline::assemble_with(rt.cfg, rt.registry,
|
||||||
|
&block)`, not `assemble`. Speaker `None`.
|
||||||
|
- **User text.** Cap `task` with `cap_result(task, rt.cfg.r#loop.tool_result_cap)`. Then
|
||||||
|
`format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339())` — same shape as
|
||||||
|
heartbeat, current time (the given tests only check the prefix `It is ` and that the
|
||||||
|
task string is in the child user message).
|
||||||
|
- **Session.** `Session::create(..., slots.background)`. `Exists` → `Session::open`. Other
|
||||||
|
errors → drop hold, `{id} failed`.
|
||||||
|
- **The turn.** `run_turn(&mut session, rt, &text, None, on_event)` then
|
||||||
|
`compact::finish_turn(..., &*ctx.memory, ..., on_event, outcome)`. Then
|
||||||
|
`ctx.memory.updated(session.id(), session.turns())` (ignore the result). Read
|
||||||
|
`session.untrusted()` **before** `drop(held)`.
|
||||||
|
- **Result.** `Ok(done)` →
|
||||||
|
`format!("{} completed\n{summary}", child.as_str())` where
|
||||||
|
`summary = compact::cap_chars(neutralise(&done.content).as_ref(), rt.cfg.compact.summary_chars)`.
|
||||||
|
`Err(_)` (including `TurnLimit` with pending tool_calls) → `format!("{} failed", ...)`.
|
||||||
|
Class `Public`. The untrusted flag is the child's, after the turn.
|
||||||
|
- **Reuse the same `Runtime`.** The child turn uses `rt`, so it can spawn a grandchild
|
||||||
|
(`rt.ctx` is still the live context). Nested is the same wait path; no new queue.
|
||||||
|
- **`Some(&*ctx)` vs `Some(ctx)`.** `handle` has `Arc<Context>`; deref to `&Context`.
|
||||||
|
The scheduler already holds `&Context`.
|
||||||
|
- **Every `Runtime {` in `crates/loopd`.** Adding a field without filling a literal is
|
||||||
|
`E0063`. Production: `Some`. Tests that do not spawn: `None`. The given spawn test
|
||||||
|
already writes `ctx: Some(ctx)`.
|
||||||
|
- `cap_chars` is `pub(crate)` in `compact.rs`. `neutralise` returns `Cow<'_, str>`.
|
||||||
|
- rustfmt may wrap `Session::create`. That is fine.
|
||||||
|
- The items the edits use. This table is complete.
|
||||||
|
|
||||||
|
| Item | Signature |
|
||||||
|
|---|---|
|
||||||
|
| `Context::hold` | `hold(&self, id: SessionId) -> Result<Held<'_>, Busy>` |
|
||||||
|
| `MemorySource::block` | `block(&self) -> Result<String, String>` |
|
||||||
|
| `MemorySource::updated` | `updated(&self, session: &SessionId, turn: u64) -> Result<(), String>` |
|
||||||
|
| `Baseline::assemble_with` | `assemble_with(cfg, registry, memory: &str) -> Result<Baseline, _>` |
|
||||||
|
| `Session::create` | `create(home, id, baseline, slot: u32) -> Result<Session, SessionError>` |
|
||||||
|
| `Session::open` | `open(home, id) -> Result<Session, SessionError>` |
|
||||||
|
| `SessionError::Exists` | `_` |
|
||||||
|
| `run_turn` | `run_turn(&mut Session, &Runtime, &str, Option<&str>, &mut dyn FnMut(&TurnEvent))` |
|
||||||
|
| `compact::finish_turn` | `finish_turn(&mut Session, &Runtime, &dyn MemorySource, &str, Option<&str>, &mut dyn FnMut(&TurnEvent), Result<TurnOutcome, TurnError>)` |
|
||||||
|
| `compact::cap_chars` | `cap_chars(s: &str, n: usize) -> String` |
|
||||||
|
| `cap_result` | `cap_result(text: &str, cap: usize) -> (String, bool)` |
|
||||||
|
| `denial_message` | as task 02 |
|
||||||
|
| `Timestamp::now` | `now() -> Timestamp`; `to_rfc3339() -> String` |
|
||||||
|
| `Session::untrusted` | `untrusted(&self) -> bool` |
|
||||||
|
| `Session::turns` | `turns(&self) -> u64` |
|
||||||
|
| `Session::id` | `id(&self) -> &SessionId` |
|
||||||
|
|
||||||
|
## The edits, exactly
|
||||||
|
|
||||||
|
Each quoted "before" block occurs exactly once after task 02.
|
||||||
|
|
||||||
|
### `crates/loopd/src/turn.rs`
|
||||||
|
|
||||||
|
**R1.** The lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Runtime<'a> {
|
||||||
|
pub cfg: &'a Config,
|
||||||
|
pub client: &'a Client,
|
||||||
|
pub port: &'a dyn ToolPort,
|
||||||
|
pub registry: &'a Registry,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Runtime<'a> {
|
||||||
|
pub cfg: &'a Config,
|
||||||
|
pub client: &'a Client,
|
||||||
|
pub port: &'a dyn ToolPort,
|
||||||
|
pub registry: &'a Registry,
|
||||||
|
/// Present on a live `loopd` turn so `subagent` can hold the child. Tests that do not spawn
|
||||||
|
/// leave this `None`.
|
||||||
|
pub ctx: Option<&'a crate::channel::Context>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Production `Runtime` literals: `ctx: Some(...)`
|
||||||
|
|
||||||
|
**C1.** In `crates/loopd/src/channel.rs`, the lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let runtime = Runtime {
|
||||||
|
cfg: &ctx.cfg,
|
||||||
|
client: &ctx.client,
|
||||||
|
port: &*ctx.port,
|
||||||
|
registry: &ctx.registry,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut session = if turn.resume {
|
||||||
|
```
|
||||||
|
|
||||||
|
become the same struct with `ctx: Some(&*ctx),` after `registry`.
|
||||||
|
|
||||||
|
**S1.** In `crates/loopd/src/scheduler.rs`, the cron literal (the one followed by
|
||||||
|
`job.prompt`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let runtime = Runtime {
|
||||||
|
cfg: &ctx.cfg,
|
||||||
|
client: &ctx.client,
|
||||||
|
port: &*ctx.port,
|
||||||
|
registry: &ctx.registry,
|
||||||
|
};
|
||||||
|
let text = format!("It is {}.\n\n{}", now.to_rfc3339(), job.prompt);
|
||||||
|
```
|
||||||
|
|
||||||
|
gains `ctx: Some(ctx),` after `registry`.
|
||||||
|
|
||||||
|
**S2.** In the same file, the heartbeat literal (the one followed by `{file}`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let runtime = Runtime {
|
||||||
|
cfg: &ctx.cfg,
|
||||||
|
client: &ctx.client,
|
||||||
|
port: &*ctx.port,
|
||||||
|
registry: &ctx.registry,
|
||||||
|
};
|
||||||
|
|
||||||
|
let text = format!("It is {}.\n\n{file}", now.to_rfc3339());
|
||||||
|
```
|
||||||
|
|
||||||
|
gains `ctx: Some(ctx),` after `registry`.
|
||||||
|
|
||||||
|
### Test `Runtime` literals: `ctx: None`
|
||||||
|
|
||||||
|
Add `ctx: None,` after `registry` in each of these, using the unique surrounding lines.
|
||||||
|
|
||||||
|
**U1.** `crates/loopd/tests/support/turn.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub fn runtime(&self) -> Runtime<'_> {
|
||||||
|
Runtime {
|
||||||
|
cfg: &self.cfg,
|
||||||
|
client: &self.client,
|
||||||
|
port: &self.port,
|
||||||
|
registry: &self.registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**U2.** `crates/loopd/tests/compact_turn.rs` helper (not `pub`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn runtime(&self) -> Runtime<'_> {
|
||||||
|
Runtime {
|
||||||
|
cfg: &self.cfg,
|
||||||
|
client: &self.client,
|
||||||
|
port: &self.port,
|
||||||
|
registry: &self.registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**U3.** `crates/loopd/tests/session_slot.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
) -> Runtime<'a> {
|
||||||
|
Runtime {
|
||||||
|
cfg,
|
||||||
|
client,
|
||||||
|
port,
|
||||||
|
registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**U4.** `crates/loopd/tests/end_to_end.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let runtime = Runtime {
|
||||||
|
cfg: &cfg,
|
||||||
|
client: &client,
|
||||||
|
port: &port,
|
||||||
|
registry: ®istry,
|
||||||
|
};
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let outcome = run_turn(
|
||||||
|
&mut session,
|
||||||
|
&runtime,
|
||||||
|
"what is this host called?",
|
||||||
|
```
|
||||||
|
|
||||||
|
**U5.** `crates/loopd/tests/turn_broker.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let runtime = loopd::turn::Runtime {
|
||||||
|
cfg: &s.cfg,
|
||||||
|
client: &s.client,
|
||||||
|
port: &ReturnsPendingPort,
|
||||||
|
registry: &s.registry,
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**U6.** `crates/loopd/tests/tool_summary_call.rs`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let rt = Runtime {
|
||||||
|
cfg: &cfg,
|
||||||
|
client: &client,
|
||||||
|
port: &port,
|
||||||
|
registry: ®istry,
|
||||||
|
};
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let outcome = run_turn(&mut s, &rt, "hosts?", None, &mut |e| events.push(e.clone()));
|
||||||
|
```
|
||||||
|
|
||||||
|
**U7.** `crates/loopd/tests/compact_turn.rs` second literal
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let rt = Runtime {
|
||||||
|
cfg: &cfg,
|
||||||
|
client: &client,
|
||||||
|
port: &port,
|
||||||
|
registry: ®istry,
|
||||||
|
};
|
||||||
|
let mut events = Vec::new();
|
||||||
|
let outcome = run_turn(&mut s, &rt, "hi", None, &mut |e| events.push(e.clone()));
|
||||||
|
```
|
||||||
|
|
||||||
|
There are ten `Runtime {` literals in `crates/loopd` after task 02 (three production,
|
||||||
|
seven test). After this task every one names `ctx`. If `cargo check -p loopd` reports
|
||||||
|
`E0063 missing field ctx`, the file it names is one of the rows above; fill it, do not
|
||||||
|
search the tree.
|
||||||
|
|
||||||
|
### `crates/loopd/src/subagent.rs`
|
||||||
|
|
||||||
|
Replace the stub `run` (the whole function task 02 added, from `/// Spawn is task 03`
|
||||||
|
through its closing `}`) with the body below. Add `pub const RUNBOOK` next to `SUBAGENT`.
|
||||||
|
Add the `use` lines the body needs (`Baseline`, `Busy`, `compact`, `Session` /
|
||||||
|
`SessionError`, `Dispatch` is already used, `cap_result`, `denial_message`, `Runtime`,
|
||||||
|
`run_turn`, `neutralise`, `CallId`, `DataClass`, `DenyReason`, `SessionId`, `Timestamp`,
|
||||||
|
`TurnEvent`). rustfmt decides `use` order.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub const RUNBOOK: &str = "see docs/runbook.md#subagent-id";
|
||||||
|
|
||||||
|
/// Spawn the child, run one turn on the background slot, and return the parent's tool text.
|
||||||
|
pub fn run(
|
||||||
|
rt: &Runtime<'_>,
|
||||||
|
parent: &Session,
|
||||||
|
task: &str,
|
||||||
|
call_id: CallId,
|
||||||
|
on_event: &mut dyn FnMut(&TurnEvent),
|
||||||
|
) -> (String, DataClass, bool) {
|
||||||
|
let raw = format!("sub-{}-{}", parent.id().as_str(), call_id.0);
|
||||||
|
let child = match SessionId::new(&raw) {
|
||||||
|
Ok(id) => id,
|
||||||
|
Err(_) => {
|
||||||
|
return (
|
||||||
|
denial_message(
|
||||||
|
DenyReason::InvalidArguments,
|
||||||
|
Some(&format!(
|
||||||
|
"child session id `{raw}` would not fit; {RUNBOOK}"
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
DataClass::Public,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(ctx) = rt.ctx else {
|
||||||
|
return failed(&child);
|
||||||
|
};
|
||||||
|
|
||||||
|
let held = match ctx.hold(child.clone()) {
|
||||||
|
Ok(held) => held,
|
||||||
|
Err(Busy) => return failed(&child),
|
||||||
|
};
|
||||||
|
|
||||||
|
let block = match ctx.memory.block() {
|
||||||
|
Ok(block) => block,
|
||||||
|
Err(_) => {
|
||||||
|
drop(held);
|
||||||
|
return failed(&child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let baseline = match Baseline::assemble_with(rt.cfg, rt.registry, &block) {
|
||||||
|
Ok(baseline) => baseline,
|
||||||
|
Err(_) => {
|
||||||
|
drop(held);
|
||||||
|
return failed(&child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (task, _) = cap_result(task, rt.cfg.r#loop.tool_result_cap);
|
||||||
|
let text = format!("It is {}.\n\n{task}", Timestamp::now().to_rfc3339());
|
||||||
|
|
||||||
|
let mut session = match Session::create(
|
||||||
|
&rt.cfg.paths.home,
|
||||||
|
child.clone(),
|
||||||
|
baseline,
|
||||||
|
rt.cfg.slots.background,
|
||||||
|
) {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(SessionError::Exists(_)) => match Session::open(&rt.cfg.paths.home, child.clone()) {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(_) => {
|
||||||
|
drop(held);
|
||||||
|
return failed(&child);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(_) => {
|
||||||
|
drop(held);
|
||||||
|
return failed(&child);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let outcome = run_turn(&mut session, rt, &text, None, on_event);
|
||||||
|
let outcome = compact::finish_turn(
|
||||||
|
&mut session,
|
||||||
|
rt,
|
||||||
|
&*ctx.memory,
|
||||||
|
&text,
|
||||||
|
None,
|
||||||
|
on_event,
|
||||||
|
outcome,
|
||||||
|
);
|
||||||
|
let _ = ctx.memory.updated(session.id(), session.turns());
|
||||||
|
let untrusted = session.untrusted();
|
||||||
|
drop(held);
|
||||||
|
|
||||||
|
match outcome {
|
||||||
|
Ok(done) => {
|
||||||
|
let summary = compact::cap_chars(
|
||||||
|
neutralise(&done.content).as_ref(),
|
||||||
|
rt.cfg.compact.summary_chars,
|
||||||
|
);
|
||||||
|
(
|
||||||
|
format!("{} completed\n{summary}", child.as_str()),
|
||||||
|
DataClass::Public,
|
||||||
|
untrusted,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Err(_) => (
|
||||||
|
format!("{} failed", child.as_str()),
|
||||||
|
DataClass::Public,
|
||||||
|
untrusted,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failed(id: &SessionId) -> (String, DataClass, bool) {
|
||||||
|
(format!("{} failed", id.as_str()), DataClass::Public, false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill `run` in this order, `cargo check -p loopd` after each (T25): overflow / `rt.ctx` /
|
||||||
|
`hold`; baseline and create; `run_turn` / `finish_turn` / the `ToolResult` text.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given test.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/loopd/tests/subagent_spawn.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2. See it fail.** `cargo build --tests --keep-going -p loopd`, timeout 600000 ms:
|
||||||
|
test target `subagent_spawn` does not compile (`E0560` / `E0063`: `Runtime` has no field
|
||||||
|
`ctx`). Every other target compiles.
|
||||||
|
- [ ] **3. The edits.** R1, then C1, S1, S2, U1–U7 (`cargo check -p loopd` after the struct
|
||||||
|
field will fail on each still-missing literal; fill them, do not grep). Then replace
|
||||||
|
`run`. Then the runbook:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
{ echo; cat docs/plans/M6a/files/runbook-subagent-id.md; } >> docs/runbook.md
|
||||||
|
```
|
||||||
|
|
||||||
|
`grep -c '^## subagent-id$' docs/runbook.md` prints `1`. `cargo check -p loopd` prints
|
||||||
|
no error and no warning. `wc -l` of `subagent.rs`, `turn.rs`, `channel.rs`,
|
||||||
|
`scheduler.rs` each ≤ 500.
|
||||||
|
- [ ] **4. The tests.** `cargo test -p loopd --test subagent_spawn --test subagent_dispatch
|
||||||
|
--test subagent_registry --test session_slot --test channel --test compact_turn --test
|
||||||
|
tool_summary_call`: every test passes, 5 in `subagent_spawn`.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||||
|
line `gate: ok`. Test count **1585** (1580 before, plus 5). After `gate: ok`, do not add
|
||||||
|
up `test result:` lines.
|
||||||
|
- [ ] **6. Log and commit.** Task cell `M6a/03-loopd-subagent-spawn`. Read only the first
|
||||||
|
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||||
|
`git add crates/loopd docs/runbook.md docs/implementer-log.md Cargo.lock`, then commit
|
||||||
|
as `AGENTS.md` says.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `make gate` prints `gate: ok` with 1585 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only the files in the Files section plus 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` names a `Runtime` literal this task did not
|
||||||
|
list; you add `holds_prefix` (task 04) or edit `brokerd` (task 05); a test in
|
||||||
|
`session_slot` / `compact_turn` / `tool_summary_call` fails; `wc -l` of any named
|
||||||
|
source file is over 500; a test outside the named files fails in the gate
|
||||||
|
(`git stash -u`, rerun, `git stash pop`, report both).
|
||||||
|
|
||||||
|
## What the given tests pin
|
||||||
|
|
||||||
|
The child's request uses `slots.background` and a user line `It is …\n\n{task}`; the
|
||||||
|
parent's next request still extends its own prefix and carries `slots.main`; a second
|
||||||
|
spawn in the same parent is `sub-mm-thread-2`; a 59-character parent is invalid-arguments
|
||||||
|
naming `subagent-id` and does not start a child request; nested
|
||||||
|
`sub-sub-mm-thread-1-1` returns through the middle child's `ToolResult`; a child that
|
||||||
|
hits `TurnLimit` still on a tool call is `{id} failed`.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# M6a task 04: heartbeat and cron yield to a held subagent
|
||||||
|
|
||||||
|
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
|
||||||
|
is empty; otherwise stop. Never switch branch.
|
||||||
|
**Commit subject:** `loopd: scheduler yields to a held subagent`
|
||||||
|
**Log row:** the Task cell is `M6a/04-loopd-scheduler-skip`.
|
||||||
|
|
||||||
|
**First action.** Run this before any Read of crate source. Do not `ls` `crates/loopd/tests/`.
|
||||||
|
Do not Read `tests/support/`, `turn.rs`, `session.rs`, `docs/implementer-log.md` (except the
|
||||||
|
first 12 lines at the log step). The quoted blocks are the only edits.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
While any held session id starts with `sub-`, a due heartbeat does not start and a due
|
||||||
|
cron job does not start (spec 3.3). `SlotGate` is FIFO; skipping here is what stops
|
||||||
|
heartbeat and cron from jumping a child that is waiting on the background slot. Drop the
|
||||||
|
hold and the next `tick` runs them.
|
||||||
|
|
||||||
|
Leftovers still close on the first tick. Compaction already holds its session. Tests call
|
||||||
|
`tick(ctx, now)`; no sleeps (T27).
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/loopd/src/channel.rs` | `holds_prefix` |
|
||||||
|
| `crates/loopd/src/scheduler.rs` | skip heartbeat after due; skip jobs after heartbeat returns |
|
||||||
|
| `crates/loopd/tests/scheduler_subagent.rs` | new, given |
|
||||||
|
| everything else | **no change**: not `subagent.rs`, not `brokerd` |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
|
||||||
|
`crates/loopd/tests/scheduler_subagent.rs`.
|
||||||
|
- Modify: `crates/loopd/src/channel.rs`, `crates/loopd/src/scheduler.rs`,
|
||||||
|
`docs/implementer-log.md`.
|
||||||
|
- Do not touch: any other file. Do not edit `crates/loopd/tests/scheduler_heartbeat.rs`
|
||||||
|
or `scheduler_cron.rs`.
|
||||||
|
|
||||||
|
## Facts you need
|
||||||
|
|
||||||
|
- **`busy` is private.** Tests cannot see the set. `holds_prefix` locks it the same way
|
||||||
|
`hold` does (`unwrap_or_else(|p| p.into_inner())`) and returns whether any id
|
||||||
|
`as_str().starts_with(prefix)`.
|
||||||
|
- **Two skips, both required.** Heartbeat runs *before* the job loop. A skip only after
|
||||||
|
`heartbeat(...)` still lets a due heartbeat start. A skip only inside `heartbeat` still
|
||||||
|
lets a due cron job start. The given tests cover each.
|
||||||
|
- **Where in `heartbeat`.** After the `if !due { return; }` block, before
|
||||||
|
`SessionId::new("heartbeat")`. `every_s == 0` still returns earlier. Busy-skip of the
|
||||||
|
`heartbeat` session itself is unchanged.
|
||||||
|
- **Where in `tick`.** Immediately after `self.heartbeat(...)`, before `jobs_dir`.
|
||||||
|
`return work` so leftover-close on this first tick is already done.
|
||||||
|
- **Prefix `sub-`.** A hold of `heartbeat` or `job-…` does not skip. The given tests hold
|
||||||
|
`sub-mm-thread-1`.
|
||||||
|
- The items the edits use. This table is complete.
|
||||||
|
|
||||||
|
| Item | Signature |
|
||||||
|
|---|---|
|
||||||
|
| `Context::hold` | already `pub`; the given tests keep the `Held` |
|
||||||
|
| `SessionId::as_str` | `as_str(&self) -> &str` |
|
||||||
|
| `Scheduler::tick` | `tick(&mut self, ctx: &Context, now: Timestamp) -> Vec<TickWork>` |
|
||||||
|
|
||||||
|
## The edits, exactly
|
||||||
|
|
||||||
|
Each quoted "before" block occurs exactly once after task 03.
|
||||||
|
|
||||||
|
### `crates/loopd/src/channel.rs`
|
||||||
|
|
||||||
|
**H1.** After `hold`'s closing `}` and still inside `impl Context`, add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// True when any held session id starts with `prefix` (`sub-` for a live child).
|
||||||
|
pub fn holds_prefix(&self, prefix: &str) -> bool {
|
||||||
|
let busy = self.busy.lock().unwrap_or_else(|p| p.into_inner());
|
||||||
|
busy.iter().any(|id| id.as_str().starts_with(prefix))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The unique anchor is the end of `hold`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
busy.insert(id.clone());
|
||||||
|
drop(busy);
|
||||||
|
Ok(Held { ctx: self, id })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
becomes that same `hold` body, then `holds_prefix`, then the `impl` closing `}`.
|
||||||
|
|
||||||
|
### `crates/loopd/src/scheduler.rs`
|
||||||
|
|
||||||
|
**K1.** In `tick`, the lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
self.heartbeat(ctx, now, &mut work);
|
||||||
|
|
||||||
|
let jobs_dir = ctx.cfg.paths.home.join("jobs");
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
self.heartbeat(ctx, now, &mut work);
|
||||||
|
if ctx.holds_prefix("sub-") {
|
||||||
|
return work;
|
||||||
|
}
|
||||||
|
|
||||||
|
let jobs_dir = ctx.cfg.paths.home.join("jobs");
|
||||||
|
```
|
||||||
|
|
||||||
|
**K2.** In `heartbeat`, the lines
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if !due {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = match SessionId::new("heartbeat") {
|
||||||
|
```
|
||||||
|
|
||||||
|
become
|
||||||
|
|
||||||
|
```rust
|
||||||
|
if !due {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ctx.holds_prefix("sub-") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = match SessionId::new("heartbeat") {
|
||||||
|
```
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given test.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/loopd/tests/scheduler_subagent.rs crates/loopd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p loopd --test scheduler_subagent`, timeout
|
||||||
|
600000 ms. The tests compile (`hold` exists). Both fail: `work` is not empty (a
|
||||||
|
`Heartbeat` or a `Job`) and `requests_to(CHAT)` is not empty while the child is held.
|
||||||
|
- [ ] **3. The edits.** H1, K2, K1. `cargo check -p loopd` prints no error and no
|
||||||
|
warning. `wc -l crates/loopd/src/channel.rs` and `wc -l crates/loopd/src/scheduler.rs`
|
||||||
|
each ≤ 500.
|
||||||
|
- [ ] **4. The tests.** `cargo test -p loopd --test scheduler_subagent --test
|
||||||
|
scheduler_heartbeat --test scheduler_cron --test scheduler_heartbeat_failed`: every
|
||||||
|
test passes, 2 in `scheduler_subagent`, 6 in `scheduler_heartbeat`, 6 in
|
||||||
|
`scheduler_cron`, 1 in `scheduler_heartbeat_failed`.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||||
|
line `gate: ok`. Test count **1587** (1585 before, plus 2). After `gate: ok`, do not add
|
||||||
|
up `test result:` lines.
|
||||||
|
- [ ] **6. Log and commit.** Task cell `M6a/04-loopd-scheduler-skip`. 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 1587 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only `crates/loopd/src/channel.rs`,
|
||||||
|
`crates/loopd/src/scheduler.rs`, `crates/loopd/tests/scheduler_subagent.rs` and the log.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- The branch check fails; a "before" block is missing or duplicated; step 2 does not
|
||||||
|
compile, or only one of the two tests fails, or they fail for a different assertion;
|
||||||
|
`cargo check` warns; a heartbeat / cron / failed-heartbeat test fails; you skip only
|
||||||
|
one of K1/K2; a test outside the named files fails in the gate (`git stash -u`, rerun,
|
||||||
|
`git stash pop`, report both).
|
||||||
|
|
||||||
|
## What the given tests pin
|
||||||
|
|
||||||
|
A held `sub-mm-thread-1` makes a due heartbeat tick return empty work and send nothing,
|
||||||
|
then after `drop(held)` the next due tick is `Heartbeat` and one chat request; a held
|
||||||
|
`sub-*` likewise skips a due `0 7 * * *` job at 07:00, then the same timestamp after
|
||||||
|
the drop runs the job.
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
# M6a task 05: `brokerd` copies parent taint onto a new `sub-*` session
|
||||||
|
|
||||||
|
**Branch:** `m6a`. Check it: `git branch --show-current` prints `m6a` and `git status --short`
|
||||||
|
is empty; otherwise stop. Never switch branch.
|
||||||
|
**Commit subject:** `brokerd: inherit parent taint for a new subagent`
|
||||||
|
**Log row:** the Task cell is `M6a/05-brokerd-state-inherit`.
|
||||||
|
|
||||||
|
**First action.** Run this before any Read of crate source. Do not `ls`
|
||||||
|
`crates/brokerd/tests/`. Do not Read `policy.rs`, `loopd`, or `docs/implementer-log.md`
|
||||||
|
(except the first 12 lines at the log step). The quoted block is the only edit in
|
||||||
|
`read`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
`brokerd` does not see the spawn (spec 3.4). The child's later tool calls, and
|
||||||
|
`memoryd`'s session-state question, go through `StateStore::read`. A missing file for a
|
||||||
|
`sub-<parent>-<n>` id copies the parent's taint and `untrusted` flag via `raise` (so the
|
||||||
|
child file exists). Nested ids recurse through a missing middle child. A missing file
|
||||||
|
for any other id is still a fresh private trusted session and still creates nothing.
|
||||||
|
|
||||||
|
Spawn is not a grant. `loopd` does not write `{home}/state/`.
|
||||||
|
|
||||||
|
## Every place that changes
|
||||||
|
|
||||||
|
| Place | What happens there |
|
||||||
|
|---|---|
|
||||||
|
| `crates/brokerd/src/state.rs` | missing `sub-*` inherits; other missing ids unchanged |
|
||||||
|
| `crates/brokerd/tests/state_subagent.rs` | new, given |
|
||||||
|
| everything else | **no change**: not `loopd`, not `raise`'s merge rules |
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- Copy, from `docs/plans/M6a/files/` (the same path under it): the new test
|
||||||
|
`crates/brokerd/tests/state_subagent.rs`.
|
||||||
|
- Modify: `crates/brokerd/src/state.rs`, `docs/implementer-log.md`.
|
||||||
|
- Do not touch: any other file. Do not edit `crates/brokerd/tests/state.rs`.
|
||||||
|
|
||||||
|
## Facts you need
|
||||||
|
|
||||||
|
- **`parent_of_sub`.** `session.as_str().strip_prefix("sub-")`, then `rsplit_once('-')`.
|
||||||
|
The right piece must be non-empty and all ASCII digits. `SessionId::new(parent)` on the
|
||||||
|
left piece. Anything else (`chat-1`, `sub-x` with no trailing digits, an empty parent)
|
||||||
|
is `None` and `read` returns `SessionState::default()` without writing.
|
||||||
|
- **Recursion.** `sub-sub-mm-thread-1-2` → parent `sub-mm-thread-1` → parent `mm-thread`.
|
||||||
|
`read` of a missing middle child inherits from the live parent the same way. Do not
|
||||||
|
special-case depth.
|
||||||
|
- **`raise`, not a silent struct.** `inherit_or_default` calls `store.read(&parent)?`
|
||||||
|
then `store.raise(session, SessionState::default(), Label { class: inherited.taint,
|
||||||
|
untrusted: inherited.untrusted })`. `raise` already maxes class at `Private` and ORs
|
||||||
|
`untrusted`, and creates the file. A later `read` of the child hits the file.
|
||||||
|
- **Plain sessions.** `a_session_with_no_file_is_private_and_trusted` in `state.rs` still
|
||||||
|
requires that `read("chat-1")` creates no `broker/` directory. Only a `sub-*` miss
|
||||||
|
writes.
|
||||||
|
- **`Label` and `SessionState`** are already imported in `state.rs`.
|
||||||
|
- The items the edits use. This table is complete.
|
||||||
|
|
||||||
|
| Item | Signature |
|
||||||
|
|---|---|
|
||||||
|
| `StateStore::read` | `read(&self, session: &SessionId) -> Result<SessionState, StateError>` |
|
||||||
|
| `StateStore::raise` | `raise(&self, session: &SessionId, current: SessionState, label: Label) -> Result<SessionState, StateError>` |
|
||||||
|
| `SessionState::default` | private + trusted |
|
||||||
|
| `Label` | `{ class: DataClass, untrusted: bool }` |
|
||||||
|
| `SessionId::new` | `new(s: &str) -> Result<SessionId, ValueError>` |
|
||||||
|
| `SessionId::as_str` | `as_str(&self) -> &str` |
|
||||||
|
|
||||||
|
## The edits, exactly
|
||||||
|
|
||||||
|
Each quoted "before" block occurs exactly once after task 04 (the file is unchanged
|
||||||
|
since master).
|
||||||
|
|
||||||
|
### `crates/brokerd/src/state.rs`
|
||||||
|
|
||||||
|
**I1.** The line (exactly once, inside `read`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(SessionState::default()),
|
||||||
|
```
|
||||||
|
|
||||||
|
becomes
|
||||||
|
|
||||||
|
```rust
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||||
|
return inherit_or_default(self, session);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**I2.** After `impl StateStore` (after `persist`'s closing `}` and the `impl`'s closing
|
||||||
|
`}`, before `/// One line on disk`), add:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
/// A missing `sub-<parent>-<n>` file copies the parent's taint and untrusted flag (M6 spec 3.4).
|
||||||
|
fn inherit_or_default(store: &StateStore, session: &SessionId) -> Result<SessionState, StateError> {
|
||||||
|
let Some(parent) = parent_of_sub(session) else {
|
||||||
|
return Ok(SessionState::default());
|
||||||
|
};
|
||||||
|
let inherited = store.read(&parent)?;
|
||||||
|
store.raise(
|
||||||
|
session,
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: inherited.taint,
|
||||||
|
untrusted: inherited.untrusted,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent_of_sub(session: &SessionId) -> Option<SessionId> {
|
||||||
|
let rest = session.as_str().strip_prefix("sub-")?;
|
||||||
|
let (parent, n) = rest.rsplit_once('-')?;
|
||||||
|
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
SessionId::new(parent).ok()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not change `raise`. Do not change `persist`.
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
- [ ] **1. Copy the given test.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp docs/plans/M6a/files/crates/brokerd/tests/state_subagent.rs crates/brokerd/tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **2. See it fail.** `cargo test -p brokerd --test state_subagent`, timeout 600000
|
||||||
|
ms. The tests compile (`StateStore::read` exists).
|
||||||
|
`a_new_subagent_copies_the_parents_untrusted_flag` fails: the child is the default
|
||||||
|
(private, trusted) and `sub-mm-thread-3.json` does not exist.
|
||||||
|
`a_nested_subagent_copies_through_a_missing_middle_child` fails: the leaf is not
|
||||||
|
`Secret` / untrusted. `a_plain_session_still_creates_nothing_on_read` passes.
|
||||||
|
- [ ] **3. The edits.** I1, I2. `cargo check -p brokerd` prints no error and no warning.
|
||||||
|
`wc -l crates/brokerd/src/state.rs` ≤ 500.
|
||||||
|
- [ ] **4. The tests.** `cargo test -p brokerd --test state_subagent --test state`: every
|
||||||
|
test passes, 3 in `state_subagent`. `a_session_with_no_file_is_private_and_trusted`
|
||||||
|
still passes.
|
||||||
|
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`, timeout 600000 ms. Last
|
||||||
|
line `gate: ok`. Test count **1590** (1587 before, plus 3). After `gate: ok`, do not add
|
||||||
|
up `test result:` lines.
|
||||||
|
- [ ] **6. Log and commit.** Task cell `M6a/05-brokerd-state-inherit`. Read only the first
|
||||||
|
12 lines of `docs/implementer-log.md`; insert the row under the header. Stage with
|
||||||
|
`git add crates/brokerd docs/implementer-log.md Cargo.lock`, then commit as `AGENTS.md`
|
||||||
|
says.
|
||||||
|
|
||||||
|
## Done when
|
||||||
|
|
||||||
|
- `make gate` prints `gate: ok` with 1590 tests.
|
||||||
|
- `git diff HEAD~1 --stat` lists only `crates/brokerd/src/state.rs`,
|
||||||
|
`crates/brokerd/tests/state_subagent.rs` and the log.
|
||||||
|
|
||||||
|
## Stop and report if
|
||||||
|
|
||||||
|
- The branch check fails; a "before" block is missing or duplicated; step 2 does not
|
||||||
|
compile, or the plain-session test fails, or a different assertion fails; `cargo check`
|
||||||
|
warns; `state.rs` existing tests fail; you write a state file from `loopd`; a test
|
||||||
|
outside the named files fails in the gate (`git stash -u`, rerun, `git stash pop`,
|
||||||
|
report both).
|
||||||
|
|
||||||
|
## What the given tests pin
|
||||||
|
|
||||||
|
`read("sub-mm-thread-3")` of a parent raised to private+untrusted returns that pair and
|
||||||
|
creates the child file; `read("sub-sub-mm-thread-1-2")` with only `mm-thread` on disk
|
||||||
|
returns secret+untrusted; `read("chat-1")` still creates no `broker/` directory.
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
# M6a implementation plan: subagents
|
||||||
|
|
||||||
|
> For the implementer: each task file is one closed task for one fresh session. Read `AGENTS.md`,
|
||||||
|
> then the task file, and do exactly that task. The driver is `tools/run-plan.sh`.
|
||||||
|
|
||||||
|
**Goal:** a `subagent` tool starts an isolated session on `slots.background`, runs one turn
|
||||||
|
(tools allowed), and returns a `ToolResult` that the child completed (id, status, capped
|
||||||
|
summary). Nested `subagent` is allowed. Heartbeat and cron skip a tick while a `sub-*` session
|
||||||
|
is held. `brokerd` copies the parent's taint onto a new `sub-*` session on first read.
|
||||||
|
|
||||||
|
**Architecture:** `subagent` is not core. `loopd` answers it itself (`Dispatch::Subagent`).
|
||||||
|
The parent stays in the tool call. The child is a second `hold`, a fresh baseline, user text
|
||||||
|
with M5d's time line, `run_turn` then `finish_turn`, speaker `None`. Spawn is not a grant.
|
||||||
|
The child's later tool calls go to `brokerd` as today. `brokerd` infers the parent from
|
||||||
|
`sub-<parent>-<n>` so it never sees the spawn as a tool.
|
||||||
|
|
||||||
|
**Tech stack:** Rust 1.98.1, edition 2024; no new crate.
|
||||||
|
|
||||||
|
**Spec:** `docs/specs/2026-10-04-m6-subagents-consult.md`, sections 3, 5, 6, 7 (the M6a
|
||||||
|
tests), and 8 (`subagent-id`). M6b (consult) is a later plan.
|
||||||
|
|
||||||
|
## The owner's decisions (2026-10-04)
|
||||||
|
|
||||||
|
1. Nested `subagent` is allowed; the waiting session is not using the background slot.
|
||||||
|
2. Completion is a `ToolResult` in this turn (id, completed/failed, capped summary).
|
||||||
|
3. Name `subagent`. Queue: compaction in flight, then a child whose parent is held, then
|
||||||
|
cron, then heartbeat, then a tool-output summary.
|
||||||
|
|
||||||
|
## What the code already has, and what it lacks
|
||||||
|
|
||||||
|
- Heartbeat and cron are sessions on the background slot. `run_turn` sends `session.slot()`.
|
||||||
|
`Context::hold` exists. There is no `subagent` tool.
|
||||||
|
- `dispatch` sends every non-core name, including a future `subagent`, to `brokerd`.
|
||||||
|
- `Runtime` has no `Context`, so a tool call cannot hold a second session.
|
||||||
|
- `StateStore::read` of a missing file is a fresh private trusted session. A child would
|
||||||
|
launder.
|
||||||
|
|
||||||
|
## Global constraints
|
||||||
|
|
||||||
|
- Roles depend on `bxcore` only. No `unsafe`, no async runtime, no new crate.
|
||||||
|
- No file the implementer reads holds a chat-template tag whole (T33).
|
||||||
|
- Every path a given test creates is removed by a guard (T44); no test depends on timing
|
||||||
|
(T27): the scheduler is `tick(now)`, never a sleep.
|
||||||
|
- Fail-closed state `subagent-id` has a runbook entry, added by the task that adds its
|
||||||
|
pointer (T49).
|
||||||
|
- Each task gives its baseline and its gate count (T50, T53); the branch line is a check,
|
||||||
|
never `git switch` (T51).
|
||||||
|
- `Cargo.lock` is in the `git add` line (T24). Gate timeout 600000 ms (T40).
|
||||||
|
- `docs/implementer-log.md`: at log-and-commit, read only the first 12 lines (T62).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
Every task below has been written to be replayed from its text. The gate count is the passed
|
||||||
|
total of `cargo test --workspace` plus one, for the ignored `end_to_end` test the gate runs on
|
||||||
|
its own. Before task 01 it is **1575** (straylight, rustc 1.98.1 / clippy 0.1.98; M5d-2 task 11).
|
||||||
|
|
||||||
|
| # | Delivers | Crates | Gate after | Tests added | Mutations tried, caught |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 01 | `subagent` in `m5c()`, not core; `find_tool` finds it; the epoch tools array is unchanged | loopd | 1577 | 2 | 1, 1 |
|
||||||
|
| 02 | `Dispatch::Subagent`; unknown keys are invalid-arguments; `call_tool` does not send it to the port; `child_id` | loopd | 1580 | 3 | 1, 1 |
|
||||||
|
| 03 | Spawn: `Runtime.ctx`, hold, background slot, time line, `ToolResult` completed/failed, nested, overflow pointer | loopd | 1585 | 5 | 2, 2 |
|
||||||
|
| 04 | Scheduler skips heartbeat and cron while any `sub-*` id is held | loopd | 1587 | 2 | 2, 2 |
|
||||||
|
| 05 | `brokerd` copies parent taint onto a missing `sub-*` state file | brokerd | 1590 | 3 | 2, 2 |
|
||||||
|
|
||||||
|
Mutations (reference, one line each, restored after): `core: true` fails both task-01 tests; dropping the unknown-key walk fails `unknown_keys_and_a_missing_task_are_invalid_arguments`; child `slots.main` fails the slot and nested slot asserts; skipping the overflow denial fails `an_overflowing_parent_id_is_invalid_arguments`; dropping only the heartbeat `holds_prefix` skip fails the heartbeat test and leaves the cron test green; dropping only the post-heartbeat return fails the cron test and leaves the heartbeat test green; `inherit_or_default` always default fails both inherit tests and leaves the plain-session test green; a missing middle parent treated as default fails only the nested inherit test.
|
||||||
|
|
||||||
|
After task 05, the design model runs the M6a device check (spec 7) on straylight: a `bxctl`
|
||||||
|
session calls `subagent` with a short task; the child log is on slot 2; the parent's next
|
||||||
|
turn has `cache_n` near its previous total. Slot 1 is left busy.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
These are implied by the spec and pinned in the named task. A reviewer who only reads the
|
||||||
|
happy-path tests will miss them.
|
||||||
|
|
||||||
|
- `subagent` is not in the epoch `tools` array, so a new session's prefix is unchanged (task 01).
|
||||||
|
- `call_tool` must not send `subagent` to `brokerd` (task 02).
|
||||||
|
- A parent id of 59 `a`s cannot form `sub-<parent>-1`; the denial names the id and
|
||||||
|
`subagent-id` (tasks 02, 03).
|
||||||
|
- The parent's request after the child still extends its own prefix; the child used
|
||||||
|
`slots.background` (task 03).
|
||||||
|
- Nested `sub-sub-<parent>-1-1` returns through the middle child's `ToolResult` (task 03).
|
||||||
|
- A child whose last completion is still a tool call is `failed`, not a hang (task 03).
|
||||||
|
- A held `sub-*` skips both a due heartbeat and a due cron job (task 04).
|
||||||
|
- A grandchild with no middle state file still inherits the live parent's untrusted flag
|
||||||
|
(task 05).
|
||||||
|
- A plain `chat-*` read still creates no state file (task 05).
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
These are known, accepted for M6a, and not bugs to fix in a task:
|
||||||
|
|
||||||
|
- **No Mattermost delivery.** The parent learns the child finished as a `ToolResult`. The
|
||||||
|
owner who wants the child's full log opens `sessions/sub-…/`.
|
||||||
|
- **One background slot.** Nested children serialize on `SlotGate`. A parallel pool is out
|
||||||
|
of scope.
|
||||||
|
- **`brokerd` does not see the spawn.** Taint is copied on the first `StateStore::read` of a
|
||||||
|
new `sub-*` id, including `memoryd`'s session-state question. Identity `add` from that
|
||||||
|
child is already a note (M5a).
|
||||||
|
- **`consult` is M6b.**
|
||||||
|
|
||||||
|
## Before hand-over
|
||||||
|
|
||||||
|
Work through `docs/plans/CHECKLIST.md` with a reference in a scratch worktree with its own
|
||||||
|
build directory, replayed from its task text; mutations; `/tmp` compared around a gate run;
|
||||||
|
the reference moved to straylight as a bundle before the run (T18). Task files name their log
|
||||||
|
row (T47).
|
||||||
|
|
||||||
|
## Running it
|
||||||
|
|
||||||
|
The reference (`m6a-ref` at `87c3fa7`, tasks 01 to 05) is a git bundle
|
||||||
|
`~/boxmaker-refs/m6a-ref.bundle`. Copy it to straylight and delete the local worktree
|
||||||
|
(`/home/kyle/src/boxmaker-m6a-ref`) and branch before the run (T18). Then, from the
|
||||||
|
repository root, with the OpenCode TUI closed, after M5d-2 has landed on master:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git switch -c m6a master
|
||||||
|
OPENCODE_MODEL=llama.cpp/ornith-1.5-35b-a3b RESUME=1 \
|
||||||
|
SLOTS_URL='http://straylight:11434/slots?model=ornith-1.5-35b-a3b' \
|
||||||
|
tools/run-plan.sh docs/plans/M6a 01
|
||||||
|
```
|
||||||
|
|
||||||
|
After task 05, the gate count is 1590. Then the device check above.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! A `sub-*` session copies its parent's taint. Do not edit.
|
||||||
|
|
||||||
|
#[path = "support/tmp.rs"]
|
||||||
|
mod tmp;
|
||||||
|
|
||||||
|
use brokerd::policy::{Label, SessionState};
|
||||||
|
use brokerd::state::StateStore;
|
||||||
|
use bxcore::{DataClass, SessionId};
|
||||||
|
use tmp::TempDir;
|
||||||
|
|
||||||
|
fn id(text: &str) -> SessionId {
|
||||||
|
SessionId::new(text).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn store(home: &TempDir) -> StateStore {
|
||||||
|
StateStore::new(&home.path().join("broker/sessions"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_new_subagent_copies_the_parents_untrusted_flag() {
|
||||||
|
let home = TempDir::new("state-sub");
|
||||||
|
let store = store(&home);
|
||||||
|
let parent = id("mm-thread");
|
||||||
|
store
|
||||||
|
.raise(
|
||||||
|
&parent,
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: DataClass::Private,
|
||||||
|
untrusted: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let child = store.read(&id("sub-mm-thread-3")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
child,
|
||||||
|
SessionState {
|
||||||
|
taint: DataClass::Private,
|
||||||
|
untrusted: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let path = home.path().join("broker/sessions/sub-mm-thread-3.json");
|
||||||
|
assert!(path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nested_subagent_copies_through_a_missing_middle_child() {
|
||||||
|
let home = TempDir::new("state-sub-nest");
|
||||||
|
let store = store(&home);
|
||||||
|
store
|
||||||
|
.raise(
|
||||||
|
&id("mm-thread"),
|
||||||
|
SessionState::default(),
|
||||||
|
Label {
|
||||||
|
class: DataClass::Secret,
|
||||||
|
untrusted: true,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let leaf = store.read(&id("sub-sub-mm-thread-1-2")).unwrap();
|
||||||
|
assert_eq!(leaf.taint, DataClass::Secret);
|
||||||
|
assert!(leaf.untrusted);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_plain_session_still_creates_nothing_on_read() {
|
||||||
|
let home = TempDir::new("state-plain");
|
||||||
|
let store = store(&home);
|
||||||
|
let _ = store.read(&id("chat-1")).unwrap();
|
||||||
|
assert!(!home.path().join("broker").exists());
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//! Heartbeat and cron yield to a held subagent. Do not edit.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use bxcore::{SessionId, Timestamp};
|
||||||
|
use loopd::channel::Context;
|
||||||
|
use loopd::llama::Client;
|
||||||
|
use loopd::memory::NoMemory;
|
||||||
|
use loopd::scheduler::{Scheduler, TickWork};
|
||||||
|
use loopd::tools::Registry;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use support::{FakeServer, Home, Reply, ScriptedPort};
|
||||||
|
|
||||||
|
const CHAT: &str = "/v1/chat/completions";
|
||||||
|
|
||||||
|
fn sse() -> Reply {
|
||||||
|
let data1 = r#"data: {"choices":[{"finish_reason":null,"index":0,"delta":{"content":"noted"}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
|
||||||
|
let data2 = r#"data: {"choices":[{"finish_reason":"stop","index":0,"delta":{}}],"id":"h","timings":{"cache_n":0,"prompt_n":10,"predicted_n":1}}"#;
|
||||||
|
let body = format!("{data1}\n\n{data2}\n\ndata: [DONE]\n\n");
|
||||||
|
Reply::raw(format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ts(s: &str) -> Timestamp {
|
||||||
|
Timestamp::parse(s).unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_held_subagent_skips_a_due_heartbeat() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("heartbeat.md", "Look around.\n");
|
||||||
|
home.write("compact.md", "Summarise.\n");
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.heartbeat.every_s = 60;
|
||||||
|
cfg.heartbeat.prompt = home.dir.join("heartbeat.md");
|
||||||
|
server.route(CHAT, vec![sse()]);
|
||||||
|
let ctx = Arc::new(Context::new(
|
||||||
|
cfg.clone(),
|
||||||
|
Client::new(cfg),
|
||||||
|
Box::new(ScriptedPort::new(vec![])),
|
||||||
|
Registry::m2b(),
|
||||||
|
Box::new(NoMemory),
|
||||||
|
));
|
||||||
|
let held = ctx
|
||||||
|
.hold(SessionId::new("sub-mm-thread-1").unwrap())
|
||||||
|
.expect("hold");
|
||||||
|
let mut sched = Scheduler::new(ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T07:01:00.000Z"));
|
||||||
|
assert!(work.is_empty(), "{work:?}");
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
drop(held);
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T07:02:00.000Z"));
|
||||||
|
assert_eq!(work, vec![TickWork::Heartbeat]);
|
||||||
|
assert_eq!(server.requests_to(CHAT).len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_held_subagent_skips_a_due_cron_job() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("compact.md", "Summarise.\n");
|
||||||
|
home.write(
|
||||||
|
"jobs/mail.toml",
|
||||||
|
"schedule = \"0 7 * * *\"\nprompt = \"Check mail.\"\n",
|
||||||
|
);
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let cfg = home.config(&server.socket);
|
||||||
|
server.route(CHAT, vec![sse()]);
|
||||||
|
let ctx = Arc::new(Context::new(
|
||||||
|
cfg.clone(),
|
||||||
|
Client::new(cfg),
|
||||||
|
Box::new(ScriptedPort::new(vec![])),
|
||||||
|
Registry::m2b(),
|
||||||
|
Box::new(NoMemory),
|
||||||
|
));
|
||||||
|
let held = ctx
|
||||||
|
.hold(SessionId::new("sub-mm-thread-1").unwrap())
|
||||||
|
.expect("hold");
|
||||||
|
let mut sched = Scheduler::new(ts("2026-10-03T06:00:00.000Z"));
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
assert!(work.is_empty(), "{work:?}");
|
||||||
|
assert!(server.requests_to(CHAT).is_empty());
|
||||||
|
drop(held);
|
||||||
|
let work = sched.tick(&ctx, ts("2026-10-03T07:00:00.000Z"));
|
||||||
|
assert_eq!(work.len(), 1);
|
||||||
|
assert!(matches!(work[0], TickWork::Job { .. }), "{work:?}");
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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<String> {
|
||||||
|
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"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
//! A `subagent` turn on the background slot. Do not edit.
|
||||||
|
|
||||||
|
mod support;
|
||||||
|
|
||||||
|
use bxcore::{LogRecord, SessionId};
|
||||||
|
use loopd::baseline::Baseline;
|
||||||
|
use loopd::channel::Context;
|
||||||
|
use loopd::llama::Client;
|
||||||
|
use loopd::memory::NoMemory;
|
||||||
|
use loopd::session::Session;
|
||||||
|
use loopd::tools::Registry;
|
||||||
|
use loopd::turn::{Runtime, run_turn};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use support::{FakeServer, Home, Reply, ScriptedPort};
|
||||||
|
|
||||||
|
const CHAT: &str = "/v1/chat/completions";
|
||||||
|
|
||||||
|
fn sse_chunks(chunks: &[serde_json::Value]) -> Reply {
|
||||||
|
let mut body = String::new();
|
||||||
|
for chunk in chunks {
|
||||||
|
body.push_str("data: ");
|
||||||
|
body.push_str(&chunk.to_string());
|
||||||
|
body.push_str("\n\n");
|
||||||
|
}
|
||||||
|
body.push_str("data: [DONE]\n\n");
|
||||||
|
Reply::raw(format!(
|
||||||
|
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||||
|
body.len()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn timings() -> serde_json::Value {
|
||||||
|
serde_json::json!({"cache_n": 0, "prompt_n": 10, "predicted_n": 1})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_text(text: &str) -> Reply {
|
||||||
|
sse_chunks(&[
|
||||||
|
serde_json::json!({
|
||||||
|
"choices": [{"finish_reason": null, "index": 0, "delta": {"content": text}}],
|
||||||
|
"id": "t",
|
||||||
|
"timings": timings()
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"choices": [{"finish_reason": "stop", "index": 0, "delta": {}}],
|
||||||
|
"id": "t",
|
||||||
|
"timings": timings()
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_call_tool(tool: &str, arguments: serde_json::Value) -> Reply {
|
||||||
|
let args = serde_json::json!({"name": tool, "arguments": arguments}).to_string();
|
||||||
|
sse_named("call_tool", &args)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sse_named(name: &str, arguments: &str) -> Reply {
|
||||||
|
sse_chunks(&[
|
||||||
|
serde_json::json!({
|
||||||
|
"choices": [{
|
||||||
|
"finish_reason": null,
|
||||||
|
"index": 0,
|
||||||
|
"delta": {
|
||||||
|
"tool_calls": [{
|
||||||
|
"index": 0,
|
||||||
|
"id": "c1",
|
||||||
|
"type": "function",
|
||||||
|
"function": {"name": name, "arguments": arguments}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}],
|
||||||
|
"id": "t",
|
||||||
|
"timings": timings()
|
||||||
|
}),
|
||||||
|
serde_json::json!({
|
||||||
|
"choices": [{"finish_reason": "tool_calls", "index": 0, "delta": {}}],
|
||||||
|
"id": "t",
|
||||||
|
"timings": timings()
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
fn world(replies: Vec<Reply>) -> (Home, FakeServer, Arc<Context>) {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("compact.md", "Summarise.\n");
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let cfg = home.config(&server.socket);
|
||||||
|
server.route(CHAT, replies);
|
||||||
|
let ctx = Arc::new(Context::new(
|
||||||
|
cfg.clone(),
|
||||||
|
Client::new(cfg),
|
||||||
|
Box::new(ScriptedPort::new(vec![])),
|
||||||
|
Registry::m5c(),
|
||||||
|
Box::new(NoMemory),
|
||||||
|
));
|
||||||
|
(home, server, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runtime(ctx: &Context) -> Runtime<'_> {
|
||||||
|
Runtime {
|
||||||
|
cfg: &ctx.cfg,
|
||||||
|
client: &ctx.client,
|
||||||
|
port: &*ctx.port,
|
||||||
|
registry: &ctx.registry,
|
||||||
|
ctx: Some(ctx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parent(ctx: &Context, id: &str) -> Session {
|
||||||
|
let baseline = Baseline::assemble(&ctx.cfg, &ctx.registry).unwrap();
|
||||||
|
Session::create(
|
||||||
|
&ctx.cfg.paths.home,
|
||||||
|
SessionId::new(id).unwrap(),
|
||||||
|
baseline,
|
||||||
|
ctx.cfg.slots.main,
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn last_tool_text(session: &Session) -> String {
|
||||||
|
for record in session.records().iter().rev() {
|
||||||
|
if let LogRecord::ToolResult { content, .. } = record {
|
||||||
|
return content.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
panic!("no tool result");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_child_runs_on_the_background_slot_and_the_parent_prefix_still_extends() {
|
||||||
|
let (home, server, ctx) = world(vec![
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "look around"})),
|
||||||
|
sse_text("the drawer is empty"),
|
||||||
|
sse_text("ok"),
|
||||||
|
]);
|
||||||
|
let mut session = parent(&ctx, "mm-thread");
|
||||||
|
let rt = runtime(&ctx);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
run_turn(&mut session, &rt, "search", None, &mut |e| {
|
||||||
|
events.push(e.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sent = server.requests_to(CHAT);
|
||||||
|
assert_eq!(sent.len(), 3);
|
||||||
|
assert_eq!(sent[0].json()["id_slot"], ctx.cfg.slots.main);
|
||||||
|
assert_eq!(sent[1].json()["id_slot"], ctx.cfg.slots.background);
|
||||||
|
assert_eq!(sent[2].json()["id_slot"], ctx.cfg.slots.main);
|
||||||
|
|
||||||
|
let first = sent[0].json()["messages"].as_array().unwrap().clone();
|
||||||
|
let third = sent[2].json()["messages"].as_array().unwrap().clone();
|
||||||
|
assert!(third.len() > first.len());
|
||||||
|
assert_eq!(&third[..first.len()], &first[..]);
|
||||||
|
let tool = third[first.len() + 1].clone();
|
||||||
|
assert_eq!(tool["role"], "tool");
|
||||||
|
assert!(
|
||||||
|
tool["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("sub-mm-thread-1 completed\n"),
|
||||||
|
"{tool}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
tool["content"]
|
||||||
|
.as_str()
|
||||||
|
.unwrap()
|
||||||
|
.contains("the drawer is empty"),
|
||||||
|
"{tool}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let child_body = sent[1].json();
|
||||||
|
let child_msgs = child_body["messages"].as_array().unwrap();
|
||||||
|
let last = child_msgs.last().unwrap();
|
||||||
|
assert_eq!(last["role"], "user");
|
||||||
|
let content = last["content"].as_str().unwrap();
|
||||||
|
assert!(content.starts_with("It is "), "{content}");
|
||||||
|
assert!(content.contains("look around"), "{content}");
|
||||||
|
|
||||||
|
let child = Session::open(&home.dir, SessionId::new("sub-mm-thread-1").unwrap()).unwrap();
|
||||||
|
assert_eq!(child.slot(), ctx.cfg.slots.background);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_second_spawn_in_the_same_parent_uses_the_next_call_id() {
|
||||||
|
let (_home, _server, ctx) = world(vec![
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "one"})),
|
||||||
|
sse_text("first"),
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "two"})),
|
||||||
|
sse_text("second"),
|
||||||
|
sse_text("ok"),
|
||||||
|
]);
|
||||||
|
let mut session = parent(&ctx, "mm-thread");
|
||||||
|
let rt = runtime(&ctx);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
run_turn(&mut session, &rt, "both", None, &mut |e| {
|
||||||
|
events.push(e.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let texts: Vec<String> = session
|
||||||
|
.records()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|r| match r {
|
||||||
|
LogRecord::ToolResult { content, .. } => Some(content.clone()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert!(
|
||||||
|
texts[0].starts_with("sub-mm-thread-1 completed\n"),
|
||||||
|
"{texts:?}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
texts[1].starts_with("sub-mm-thread-2 completed\n"),
|
||||||
|
"{texts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_overflowing_parent_id_is_invalid_arguments() {
|
||||||
|
let parent_id = "a".repeat(59);
|
||||||
|
let (_home, server, ctx) = world(vec![
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "x"})),
|
||||||
|
sse_text("ok"),
|
||||||
|
]);
|
||||||
|
let mut session = parent(&ctx, &parent_id);
|
||||||
|
let rt = runtime(&ctx);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
run_turn(&mut session, &rt, "go", None, &mut |e| {
|
||||||
|
events.push(e.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let text = last_tool_text(&session);
|
||||||
|
assert!(
|
||||||
|
text.contains("would not fit") && text.contains("subagent-id"),
|
||||||
|
"{text}"
|
||||||
|
);
|
||||||
|
assert_eq!(server.requests_to(CHAT).len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_nested_child_returns_through_the_middle_tool_result() {
|
||||||
|
let (home, server, ctx) = world(vec![
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "outer"})),
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "inner"})),
|
||||||
|
sse_text("leaf"),
|
||||||
|
sse_text("middle"),
|
||||||
|
sse_text("ok"),
|
||||||
|
]);
|
||||||
|
let mut session = parent(&ctx, "mm-thread");
|
||||||
|
let rt = runtime(&ctx);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
run_turn(&mut session, &rt, "nest", None, &mut |e| {
|
||||||
|
events.push(e.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let middle = Session::open(&home.dir, SessionId::new("sub-mm-thread-1").unwrap()).unwrap();
|
||||||
|
let leaf = Session::open(&home.dir, SessionId::new("sub-sub-mm-thread-1-1").unwrap()).unwrap();
|
||||||
|
assert_eq!(leaf.slot(), ctx.cfg.slots.background);
|
||||||
|
let middle_text = last_tool_text(&middle);
|
||||||
|
assert!(
|
||||||
|
middle_text.starts_with("sub-sub-mm-thread-1-1 completed\n"),
|
||||||
|
"{middle_text}"
|
||||||
|
);
|
||||||
|
assert!(middle_text.contains("leaf"), "{middle_text}");
|
||||||
|
let parent_text = last_tool_text(&session);
|
||||||
|
assert!(
|
||||||
|
parent_text.starts_with("sub-mm-thread-1 completed\n"),
|
||||||
|
"{parent_text}"
|
||||||
|
);
|
||||||
|
assert!(parent_text.contains("middle"), "{parent_text}");
|
||||||
|
|
||||||
|
let sent = server.requests_to(CHAT);
|
||||||
|
assert_eq!(sent[0].json()["id_slot"], 0);
|
||||||
|
assert_eq!(sent[1].json()["id_slot"], 1);
|
||||||
|
assert_eq!(sent[2].json()["id_slot"], 1);
|
||||||
|
let parent_first = sent[0].json()["messages"].as_array().unwrap().clone();
|
||||||
|
let parent_last = sent[4].json()["messages"].as_array().unwrap().clone();
|
||||||
|
assert_eq!(&parent_last[..parent_first.len()], &parent_first[..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_child_that_ends_on_a_tool_call_is_failed() {
|
||||||
|
let home = Home::new();
|
||||||
|
home.write("compact.md", "Summarise.\n");
|
||||||
|
let server = FakeServer::start();
|
||||||
|
let mut cfg = home.config(&server.socket);
|
||||||
|
cfg.r#loop.tool_iterations = 1;
|
||||||
|
server.route(
|
||||||
|
CHAT,
|
||||||
|
vec![
|
||||||
|
sse_call_tool("subagent", serde_json::json!({"task": "loop"})),
|
||||||
|
sse_named("clock", "{}"),
|
||||||
|
sse_named("clock", "{}"),
|
||||||
|
sse_text("ok"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let ctx = Arc::new(Context::new(
|
||||||
|
cfg.clone(),
|
||||||
|
Client::new(cfg),
|
||||||
|
Box::new(ScriptedPort::new(vec![])),
|
||||||
|
Registry::m5c(),
|
||||||
|
Box::new(NoMemory),
|
||||||
|
));
|
||||||
|
let mut session = parent(&ctx, "mm-thread");
|
||||||
|
let rt = runtime(&ctx);
|
||||||
|
let mut events = Vec::new();
|
||||||
|
run_turn(&mut session, &rt, "go", None, &mut |e| {
|
||||||
|
events.push(e.clone())
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let text = last_tool_text(&session);
|
||||||
|
assert_eq!(text, "sub-mm-thread-1 failed");
|
||||||
|
drop(home);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
## subagent-id
|
||||||
|
|
||||||
|
**What you see.** A `subagent` call is denied as invalid-arguments. The tool result names the
|
||||||
|
child id that would not fit and this entry:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Denied: the arguments are not valid for this tool: child session id `sub-…` would not fit; see docs/runbook.md#subagent-id.
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why.** A session id is `[a-z0-9-]`, at most 64 bytes. The child is `sub-<parent>-<n>` with
|
||||||
|
`n` the parent's next call id. A parent already near that limit, or a nested chain of
|
||||||
|
`sub-` prefixes, cannot spawn another child.
|
||||||
|
|
||||||
|
**Confirm.**
|
||||||
|
|
||||||
|
```sh
|
||||||
|
printf '%s' '<the id named in the denial>' | wc -c
|
||||||
|
```
|
||||||
|
|
||||||
|
**Fix.** Do the work in the current session, or start a shorter parent id (`bxctl chat` without
|
||||||
|
a long `--session`). Nested children share one background slot; a parallel pool is out of
|
||||||
|
scope.
|
||||||
|
|
||||||
|
**Check.** A `subagent` call from a short parent (`chat-*`, `mm-*`, `heartbeat`) completes and
|
||||||
|
writes `sessions/sub-<parent>-<n>/`.
|
||||||
Reference in New Issue
Block a user