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