# 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 */ } // Debug, Clone, PartialEq, Default impl Registry { pub fn new(entries: Vec) -> Registry; pub fn m2b() -> Registry; // clock (core), echo (not core) pub fn core_schemas(&self) -> Vec; // 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> of calls */ } // Default impl FakeTools { pub fn new() -> Self; pub fn calls(&self) -> Vec; } 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`: `" 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 ""` | | `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`.