Files
kyleandClaude Fable 5.1 e156975649 Add M2b plan: ten tasks, tests, recordings and the first system prompt
The tasks build the agent loop on M2a's client: channel messages and
the usage record in proto, four config tables, the tool port and
registry with find_tool and call_tool, the baseline and replay, the
session store, the turn loop with its limits and the append-only
property test, the channel server, loopd serve, bxctl chat, and the
device checks including a four-turn conversation with a restart.

Checked against a private reference implementation: the gate passes
after every task in order, the new suites pass under CPU load, and the
reference passes make verify-device on straylight with no cache loss.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 17:19:02 -07:00

107 lines
5.4 KiB
Markdown

# M2b task 03: tools as `loopd` sees them
**Branch:** `m2b` (run `git switch m2b`; `git status --short` must be empty, otherwise stop)
**Commit subject:** `Add the tool port, registry, dispatch and the fake tools`
## Goal
`loopd` runs no tool itself. This task adds the seam every tool call goes through (`ToolPort`), the
registry of what exists, the two calls `loopd` answers itself (`find_tool`, `call_tool`), the
result cap, and the in-process `FakeTools` that stands in for `brokerd` until M3.
## Context
From the amended brief: the `tools` array is fixed for the epoch. Only a small core set is in it.
Everything else is found through `find_tool`, which returns schemas as a tool result, and is
called through `call_tool(name, arguments)`. `brokerd` applies grants to the target tool, not to
`call_tool`. Never prompt the model to call a tool that is not in the array.
## Files
- Copy: `crates/loopd/tests/tools.rs`
- Create: `crates/loopd/src/tools.rs`
- Modify: `crates/loopd/src/llama/mod.rs`, `crates/loopd/src/lib.rs`, `docs/implementer-log.md`
## Interfaces
First, `ToolSchema` in `llama/mod.rs` gains `serde::Serialize`, `serde::Deserialize` and
`#[serde(deny_unknown_fields)]`: it is written into a session's baseline file, so it is our format.
Then `crates/loopd/src/tools.rs`:
```rust
pub trait ToolPort: Send + Sync {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse;
}
pub struct Entry { pub schema: ToolSchema, pub core: bool } // Debug, Clone, PartialEq
pub struct Registry { /* private: Vec<Entry> */ } // Debug, Clone, PartialEq, Default
impl Registry {
pub fn new(entries: Vec<Entry>) -> Registry;
pub fn m2b() -> Registry; // clock (core), echo (not core)
pub fn core_schemas(&self) -> Vec<ToolSchema>; // core entries, then find_tool, then call_tool
pub fn get(&self, name: &str) -> Option<&Entry>;
pub fn find(&self, query: &str) -> Vec<&ToolSchema>; // case-insensitive substring on name or description; empty query matches nothing
}
pub const FIND_TOOL: &str = "find_tool";
pub const CALL_TOOL: &str = "call_tool";
pub fn clock_schema() -> ToolSchema;
pub fn echo_schema() -> ToolSchema;
pub enum Dispatch { Local(String), Port { tool: String, arguments: String } } // Debug, Clone, PartialEq, Eq
pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch;
/// Cuts to at most `cap` bytes on a character boundary and appends "\n[truncated]" if it cut.
pub fn cap_result(text: &str, cap: usize) -> (String, bool);
pub struct FakeTools { /* private: a Mutex<Vec<ToolRequest>> of calls */ } // Default
impl FakeTools { pub fn new() -> Self; pub fn calls(&self) -> Vec<proto::ToolRequest>; }
impl ToolPort for FakeTools { }
```
**The schemas.** `clock`: "The current date and time, as RFC 3339 in UTC.", an object with no
properties. `echo`: "Returns its text argument unchanged.", one required string `text`.
`find_tool`: "Search for more tools by keyword. Returns the schemas of the tools that match.", one
required string `query` with a description. `call_tool`: "Call a tool that find_tool returned.
Pass its name and an arguments object that fits its schema.", required `name` (string) and
`arguments` (object).
**`dispatch`**, one row per case:
| `name` | `arguments` | Result |
|---|---|---|
| `find_tool` | `{"query": q}` with matches | `Local`: `"<n> tool(s) match:\n"`, then one line per match, `{"name":…,"description":…,"parameters":…}` as compact JSON, then `"Call it with call_tool."` |
| `find_tool` | no match, or arguments that are not JSON with a string `query` | `Local("No tool matches.")` |
| `call_tool` | `{"name": n, "arguments": a}` where `n` is in the registry and **not** core | `Port { tool: n, arguments: a as JSON text }` |
| `call_tool` | `n` is a core tool | `Local`, saying it is a core tool and to call it directly |
| `call_tool` | `n` unknown, or `name` missing | `Local`, containing `No tool named "<n>"` |
| `call_tool` | arguments not a JSON object | `Local`, containing `needs a JSON object` |
| anything else | anything | `Port { tool: name, arguments }` unchanged, even for a name the registry does not know: the port (`brokerd`) decides, not `loopd` |
**`FakeTools`** answers `clock` with `Timestamp::now().to_rfc3339()` and `echo` with its `text`
argument, both as `Result { class: Public, untrusted: false, truncated: false }`; `echo` without
a string `text` is `Failed`; any other tool is `Denied { reason: NoGrant }`. Every request is
recorded.
## Steps
- [ ] **1. Copy.** `git switch m2b`, then
`cp docs/plans/M2b/files/crates/loopd/tests/tools.rs crates/loopd/tests/`
- [ ] **2. See the test fail.** `cargo test -p loopd --test tools`. Expected: it does not compile.
- [ ] **3. Write `tools.rs`**, add `pub mod tools;` to `lib.rs`, and add the derives to
`ToolSchema`. Run `cargo fmt --all`.
- [ ] **4. See the test pass.** `cargo test -p loopd --test tools`. Expected: `7 passed`.
- [ ] **5. Run the gate.** `make gate`. Expected last line: `gate: ok`.
- [ ] **6. Log and commit.** `git add crates/loopd docs/implementer-log.md && git commit`
## Done when
- `cargo test -p loopd --test tools` reports 7 passed; `make gate` prints `gate: ok`.
- `grep -n "unwrap()" crates/loopd/src/tools.rs` prints nothing (`Mutex::lock` uses
`unwrap_or_else(|p| p.into_inner())`).
## Stop and report if
- A row of the `dispatch` table cannot be reconciled with a test in `tools.rs`.