Add the tool port, registry, dispatch and the fake tools

Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
2026-09-18 18:12:45 -07:00
parent ec5d887daa
commit fd7ade9689
5 changed files with 459 additions and 1 deletions
+1
View File
@@ -5,3 +5,4 @@ pub mod http;
pub mod llama;
pub mod selftest;
pub mod sse;
pub mod tools;
+2 -1
View File
@@ -26,7 +26,8 @@ pub enum ChatMessage {
},
}
#[derive(Debug, Clone, PartialEq)]
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ToolSchema {
pub name: String,
pub description: String,
+286
View File
@@ -0,0 +1,286 @@
//! The tool port, registry, dispatch and the fake tools that stand in for `brokerd` until M3.
use std::sync::Mutex;
use crate::llama::ToolSchema;
/// The seam every tool call goes through. `loopd` answers `find_tool` and `call_tool` itself;
/// anything else, including grants, is passed to the port (`brokerd`).
pub trait ToolPort: Send + Sync {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse;
}
#[derive(Debug, Clone, PartialEq)]
pub struct Entry {
pub schema: ToolSchema,
pub core: bool,
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Registry {
entries: Vec<Entry>,
}
impl Registry {
pub fn new(entries: Vec<Entry>) -> Registry {
Registry { entries }
}
/// The tool set for the M2b epoch: `clock` (core) and `echo` (not core).
pub fn m2b() -> Registry {
Registry::new(vec![
Entry {
schema: clock_schema(),
core: true,
},
Entry {
schema: echo_schema(),
core: false,
},
])
}
/// The fixed tools array: core entries, then `find_tool`, then `call_tool`.
pub fn core_schemas(&self) -> Vec<ToolSchema> {
let mut schemas: Vec<ToolSchema> = self
.entries
.iter()
.filter(|e| e.core)
.map(|e| e.schema.clone())
.collect();
schemas.push(find_tool_schema());
schemas.push(call_tool_schema());
schemas
}
pub fn get(&self, name: &str) -> Option<&Entry> {
self.entries.iter().find(|e| e.schema.name == name)
}
/// Case-insensitive substring on name or description; an empty query matches nothing.
pub fn find(&self, query: &str) -> Vec<&ToolSchema> {
if query.is_empty() {
return Vec::new();
}
let lower = query.to_lowercase();
self.entries
.iter()
.filter(|e| {
e.schema.name.to_lowercase().contains(&lower)
|| e.schema.description.to_lowercase().contains(&lower)
})
.map(|e| &e.schema)
.collect()
}
}
pub const FIND_TOOL: &str = "find_tool";
pub const CALL_TOOL: &str = "call_tool";
pub fn clock_schema() -> ToolSchema {
ToolSchema {
name: "clock".to_string(),
description: "The current date and time, as RFC 3339 in UTC.".to_string(),
parameters: serde_json::json!({ "type": "object", "properties": {} }),
}
}
pub fn echo_schema() -> ToolSchema {
ToolSchema {
name: "echo".to_string(),
description: "Returns its text argument unchanged.".to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "The text to return unchanged."
}
},
"required": ["text"]
}),
}
}
fn find_tool_schema() -> ToolSchema {
ToolSchema {
name: "find_tool".to_string(),
description:
"Search for more tools by keyword. Returns the schemas of the tools that match."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The keyword to search for."
}
},
"required": ["query"]
}),
}
}
fn call_tool_schema() -> ToolSchema {
ToolSchema {
name: "call_tool".to_string(),
description: "Call a tool that find_tool returned. Pass its name and an arguments object that fits its schema."
.to_string(),
parameters: serde_json::json!({
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "The name of the tool to call."
},
"arguments": {
"type": "object",
"description": "The arguments object that fits the tool's schema."
}
},
"required": ["name", "arguments"]
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Dispatch {
Local(String),
Port { tool: String, arguments: String },
}
/// Decides whether `loopd` handles a tool request itself or sends it to the port.
pub fn dispatch(registry: &Registry, name: &str, arguments: &str) -> Dispatch {
if name == FIND_TOOL {
return dispatch_find_tool(registry, arguments);
}
if name == CALL_TOOL {
return dispatch_call_tool(registry, arguments);
}
Dispatch::Port {
tool: name.to_string(),
arguments: arguments.to_string(),
}
}
fn dispatch_find_tool(registry: &Registry, arguments: &str) -> Dispatch {
let query = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(serde_json::Value::Object(obj)) => obj
.get("query")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
_ => return Dispatch::Local("No tool matches.".to_string()),
};
let matches = registry.find(&query);
if matches.is_empty() {
return Dispatch::Local("No tool matches.".to_string());
}
let mut text = format!("{} tool(s) match:\n", matches.len());
for schema in matches {
let json = serde_json::to_string(schema).unwrap_or_else(|_| "{}".to_string());
text.push_str(&json);
text.push('\n');
}
text.push_str("Call it with call_tool.");
Dispatch::Local(text)
}
fn dispatch_call_tool(registry: &Registry, arguments: &str) -> Dispatch {
let obj = match serde_json::from_str::<serde_json::Value>(arguments) {
Ok(serde_json::Value::Object(obj)) => obj,
_ => return Dispatch::Local("call_tool needs a JSON object.".to_string()),
};
let name = obj.get("name").and_then(|v| v.as_str()).unwrap_or("");
let args_value = obj.get("arguments").unwrap_or(&serde_json::Value::Null);
match registry.get(name) {
Some(entry) if entry.core => {
Dispatch::Local(format!("{name} is a core tool; call it directly."))
}
Some(_) => {
let args_text =
serde_json::to_string(args_value).unwrap_or_else(|_| "null".to_string());
Dispatch::Port {
tool: name.to_string(),
arguments: args_text,
}
}
None => Dispatch::Local(format!("No tool named \"{name}\"")),
}
}
/// 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) {
if text.len() <= cap {
return (text.to_string(), false);
}
let boundary = text.floor_char_boundary(cap);
let mut result = text[..boundary].to_string();
result.push_str("\n[truncated]");
(result, true)
}
pub struct FakeTools {
calls: Mutex<Vec<proto::ToolRequest>>,
}
impl Default for FakeTools {
fn default() -> Self {
FakeTools::new()
}
}
impl FakeTools {
pub fn new() -> Self {
FakeTools {
calls: Mutex::new(Vec::new()),
}
}
pub fn calls(&self) -> Vec<proto::ToolRequest> {
self.calls.lock().unwrap_or_else(|p| p.into_inner()).clone()
}
}
impl ToolPort for FakeTools {
fn call(&self, request: &proto::ToolRequest) -> proto::ToolResponse {
self.calls
.lock()
.unwrap_or_else(|p| p.into_inner())
.push(request.clone());
match request.tool.as_str() {
"clock" => proto::ToolResponse::Result {
content: proto::Timestamp::now().to_rfc3339(),
class: proto::DataClass::Public,
untrusted: false,
truncated: false,
},
"echo" => match serde_json::from_str::<serde_json::Value>(&request.arguments) {
Ok(serde_json::Value::Object(obj)) => {
match obj.get("text").and_then(|v| v.as_str()) {
Some(text) => proto::ToolResponse::Result {
content: text.to_string(),
class: proto::DataClass::Public,
untrusted: false,
truncated: false,
},
None => proto::ToolResponse::Failed {
message: "missing text argument".to_string(),
},
}
}
_ => proto::ToolResponse::Failed {
message: "arguments must be a JSON object".to_string(),
},
},
_ => proto::ToolResponse::Denied {
reason: proto::DenyReason::NoGrant,
},
}
}
}
+169
View File
@@ -0,0 +1,169 @@
//! Tests for the registry, dispatch, the result cap and the fake tools. Do not edit.
mod support;
use loopd::tools::{Dispatch, FakeTools, Registry, ToolPort, cap_result, dispatch};
use proto::{CallId, SessionId, ToolRequest, ToolResponse};
fn req(tool: &str, arguments: &str) -> ToolRequest {
ToolRequest {
session: SessionId::new("s").unwrap(),
call: CallId(1),
tool: tool.to_string(),
arguments: arguments.to_string(),
}
}
#[test]
fn the_core_schemas_are_the_fixed_tools_array() {
let names: Vec<String> = Registry::m2b()
.core_schemas()
.into_iter()
.map(|s| s.name)
.collect();
assert_eq!(names, ["clock", "find_tool", "call_tool"]);
let find = &Registry::m2b().core_schemas()[1];
assert_eq!(find.parameters["required"], serde_json::json!(["query"]));
let call = &Registry::m2b().core_schemas()[2];
assert_eq!(
call.parameters["required"],
serde_json::json!(["name", "arguments"])
);
}
#[test]
fn find_matches_name_or_description_case_insensitively() {
let r = Registry::m2b();
let names = |q: &str| r.find(q).iter().map(|s| s.name.clone()).collect::<Vec<_>>();
assert_eq!(names("echo"), ["echo"]);
assert_eq!(names("ECHO"), ["echo"]);
assert_eq!(names("unchanged"), ["echo"], "matches the description too");
assert_eq!(names("time"), ["clock"]);
assert!(names("weather").is_empty());
assert!(
names("").is_empty(),
"an empty query matches nothing, not everything"
);
assert!(names(" ").is_empty());
}
#[test]
fn find_tool_is_answered_locally() {
let r = Registry::m2b();
match dispatch(&r, "find_tool", r#"{"query":"echo"}"#) {
Dispatch::Local(text) => {
assert!(text.starts_with("1 tool(s) match:\n"), "{text}");
assert!(text.contains("\"name\":\"echo\""), "{text}");
assert!(
text.contains("\"parameters\""),
"the schema is included: {text}"
);
assert!(text.ends_with("Call it with call_tool."), "{text}");
}
other => panic!("{other:?}"),
}
assert_eq!(
dispatch(&r, "find_tool", r#"{"query":"weather"}"#),
Dispatch::Local("No tool matches.".to_string())
);
assert!(
matches!(dispatch(&r, "find_tool", "not json"), Dispatch::Local(t) if t == "No tool matches.")
);
}
#[test]
fn call_tool_unwraps_a_known_non_core_tool_and_nothing_else() {
let r = Registry::m2b();
let args = r#"{"name":"echo","arguments":{"text": "box"}}"#;
match dispatch(&r, "call_tool", args) {
Dispatch::Port { tool, arguments } => {
assert_eq!(tool, "echo");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&arguments).unwrap(),
serde_json::json!({"text": "box"})
);
}
other => panic!("{other:?}"),
}
let cases = [
(
r#"{"name":"weather","arguments":{}}"#,
"No tool named \"weather\"",
),
(r#"{"name":"clock","arguments":{}}"#, "core tool"),
(r#"{"arguments":{}}"#, "No tool named \"\""),
("not json", "needs a JSON object"),
];
for (args, want) in cases {
match dispatch(&r, "call_tool", args) {
Dispatch::Local(text) => assert!(text.contains(want), "{args}: {text}"),
other => panic!("{args}: {other:?}, must never reach the port"),
}
}
}
#[test]
fn any_other_tool_goes_to_the_port_as_it_is() {
let r = Registry::m2b();
let want = Dispatch::Port {
tool: "clock".to_string(),
arguments: "{}".to_string(),
};
assert_eq!(dispatch(&r, "clock", "{}"), want);
// Even one the registry does not know: the port (brokerd) decides, not loopd.
let want = Dispatch::Port {
tool: "read_file".to_string(),
arguments: r#"{"path":"/x"}"#.to_string(),
};
assert_eq!(dispatch(&r, "read_file", r#"{"path":"/x"}"#), want);
}
#[test]
fn results_are_cut_on_a_character_boundary_and_marked() {
assert_eq!(cap_result("short", 100), ("short".to_string(), false));
assert_eq!(cap_result("exactly", 7), ("exactly".to_string(), false));
let (text, truncated) = cap_result("abcdefghij", 4);
assert_eq!(text, "abcd\n[truncated]");
assert!(truncated);
// 3 ASCII bytes, then 2-byte characters: byte 4 is inside a character.
let (text, truncated) = cap_result("abc\u{e9}\u{e9}\u{e9}", 4);
assert_eq!(text, "abc\n[truncated]");
assert!(truncated);
assert_eq!(cap_result("", 0), (String::new(), false));
assert_eq!(cap_result("x", 0), ("\n[truncated]".to_string(), true));
}
#[test]
fn fake_tools_answer_clock_and_echo_and_record_calls() {
let fake = FakeTools::new();
match fake.call(&req("clock", "{}")) {
ToolResponse::Result {
content,
class,
untrusted,
truncated,
} => {
assert!(
proto::Timestamp::parse(&content).is_ok(),
"an RFC 3339 time: {content}"
);
assert_eq!(class, proto::DataClass::Public);
assert!(!untrusted && !truncated);
}
other => panic!("{other:?}"),
}
match fake.call(&req("echo", r#"{"text":"box"}"#)) {
ToolResponse::Result { content, .. } => assert_eq!(content, "box"),
other => panic!("{other:?}"),
}
assert!(matches!(
fake.call(&req("echo", r#"{"tex":"box"}"#)),
ToolResponse::Failed { .. }
));
assert!(matches!(
fake.call(&req("weather", "{}")),
ToolResponse::Denied { .. }
));
assert_eq!(fake.calls().len(), 4);
assert_eq!(fake.calls()[1].tool, "echo");
}
+1
View File
@@ -32,6 +32,7 @@ reviewer adds findings under "Reviews" once per milestone.
| M2a/15-http-streaming | 2026-09-18 | done | 1 | pass | none | Fixed `read_chunked` so `Body::read` in the Chunked phase returns as soon as it has copied at least one byte of chunk data, even when the caller's buffer is not full and the chunk's trailing CRLF has not arrived; the CRLF is consumed at the start of the next call. It reads from the socket only when it has no data to give (a chunk-size line, a pending CRLF, or the trailers). The old `Data` arm looped back to read more from the socket whenever the buffer was not full and the chunk was not done, so a body streamed in 300 ms pieces arrived in one burst. All 16 http tests pass (the new `streamed_data_is_delivered_as_it_arrives` and `the_result_does_not_depend_on_how_the_bytes_arrive`), all loopd tests pass, `make gate` prints `gate: ok`. `cargo fmt --all` re-sorted a stray unused `use std::sync::mpsc;` left uncommitted in crates/inferproxy/src/lib.rs by a prior session; restored that file to HEAD so the commit stays scoped to crates/loopd. | Ornith-1.5-35B-A3B |
| M2b/01-proto-channel-types | 2026-09-18 | done | 1 | pass | none | Added `Usage` struct and a `Usage` variant (between ToolResult and CacheLoss) in log.rs, and `Turn`, `TurnEvent`, `TurnDone` plus six `ErrorCode` variants (SessionFull..Inference) and three `Message` variants (after Error) in wire.rs; re-exported Usage, Turn, TurnEvent, TurnDone from lib.rs. All four new types carry `deny_unknown_fields`; field order matches the byte-exact fixtures (attempt/after_ms/error, name/class/truncated). 55 proto tests pass (turn_wire 5, strict 5, wire 9, ids 12, frame 13, grant 4, hash 4, records 3) and `make gate` prints `gate: ok`; the old fixtures stay byte-identical. One duplicate block of the three wire types left by an interrupted edit had to be removed mid-task. | Ornith-1.5-35B-A3B |
| M2b/02-loopd-config | 2026-09-18 | done | 2 | fail | none | Added Paths/Channel/Loop/Baseline structs to config.rs with `#[serde(deny_unknown_fields, default)]` and Debug/Clone/PartialEq/Eq/Deserialize (Default derived for Channel, hand-written for the other three: home is $BOXMAKER_HOME else /var/lib/boxmaker, loop is 8/true/16384, baseline system is system.md); Config gained the four `#[serde(default)]` fields and `channel_socket()` fills the default `<home>/run/loop/loop.sock` when the socket is empty. `load` joins `baseline.system` to the config file's directory via `parent.join` (which replaces an already-absolute path); `parse` leaves it. 9 config tests pass, `deny_unknown_fields` count is 10. Two clippy fixes on the first (failing) gate run: the nested if in `load` collapsed by relying on Path::join replacing absolute paths instead of a 1.98 let-chain, and `Path::is_empty` (stable 1.98) replaced with `as_os_str().is_empty()`. | ? |
| M2b/03-loopd-tools | 2026-09-18 | done | 1 | pass | none | Added `pub mod tools;` to lib.rs and `serde::Serialize`/`serde::Deserialize`/`deny_unknown_fields` to ToolSchema; wrote crates/loopd/src/tools.rs with ToolPort, Entry, Registry (m2b/config), core_schemas/get/find, FIND_TOOL/CALL_TOOL constants, clock_schema/echo_schema, Dispatch with dispatch (find_tool/call_tool/local dispatch rows), cap_result via floor_char_boundary, and FakeTools recording calls and answering clock/echo/denying others with `unwrap_or_else(|p| p.into_inner())` on Mutex::lock. 7 tools tests pass, `make gate` prints `gate: ok`, no `unwrap()` in tools.rs. | ? |
## Reviews