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>
5.4 KiB
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:
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, thencp 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, addpub mod tools;tolib.rs, and add the derives toToolSchema. Runcargo 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 toolsreports 7 passed;make gateprintsgate: ok.grep -n "unwrap()" crates/loopd/src/tools.rsprints nothing (Mutex::lockusesunwrap_or_else(|p| p.into_inner())).
Stop and report if
- A row of the
dispatchtable cannot be reconciled with a test intools.rs.