Files
boxmaker/docs/plans/M2b/03-loopd-tools.md
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

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, 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.