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:
@@ -5,3 +5,4 @@ pub mod http;
|
||||
pub mod llama;
|
||||
pub mod selftest;
|
||||
pub mod sse;
|
||||
pub mod tools;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
Reference in New Issue
Block a user