Skip to main content

rustlavel_ai/
tool.rs

1//! Tools: describing them, registering handlers, and running the loop.
2//!
3//! A tool is a function the model may ask for by name. Every provider wants
4//! the arguments described as JSON Schema, which nobody enjoys writing by
5//! hand, so [`Schema`] builds it:
6//!
7//! ```ignore
8//! let tool = Tool::new("get_weather", "Look up today's weather for a city")
9//!     .string("city", "The city, e.g. Oslo")
10//!     .one_of("unit", "Temperature unit", &["celsius", "fahrenheit"]).optional();
11//! ```
12
13use crate::completion::{Completion, Usage};
14use crate::message::Message;
15use crate::provider::{BoxFuture, Provider};
16use crate::request::Request;
17use rustlavel_core::{Error, Json, Result};
18use std::collections::BTreeMap;
19use std::future::Future;
20use std::sync::Arc;
21
22/// A JSON Schema object, built a field at a time.
23///
24/// Only the subset every provider agrees on is modelled — an object of named,
25/// typed, described fields, some required. Anything more exotic can still be
26/// handed over with [`Schema::raw`].
27#[derive(Debug, Clone, Default, PartialEq)]
28pub struct Schema {
29    properties: Vec<(String, Json)>,
30    required: Vec<String>,
31    description: Option<String>,
32    /// A hand-written schema, used instead of the fields when present.
33    raw: Option<Json>,
34}
35
36impl Schema {
37    pub fn object() -> Schema {
38        Schema::default()
39    }
40
41    /// Use a schema written out by hand, for shapes this builder does not cover.
42    pub fn raw(schema: Json) -> Schema {
43        Schema { raw: Some(schema), ..Schema::default() }
44    }
45
46    /// Describe the object itself, which some models read as extra instruction.
47    pub fn describe(mut self, description: impl Into<String>) -> Schema {
48        self.description = Some(description.into());
49        self
50    }
51
52    pub fn string(self, name: &str, description: &str) -> Schema {
53        self.field(name, "string", description)
54    }
55
56    pub fn integer(self, name: &str, description: &str) -> Schema {
57        self.field(name, "integer", description)
58    }
59
60    pub fn number(self, name: &str, description: &str) -> Schema {
61        self.field(name, "number", description)
62    }
63
64    pub fn boolean(self, name: &str, description: &str) -> Schema {
65        self.field(name, "boolean", description)
66    }
67
68    /// A string field limited to a fixed set of values.
69    pub fn one_of(mut self, name: &str, description: &str, values: &[&str]) -> Schema {
70        let values: Vec<Json> = values.iter().map(|v| Json::from(*v)).collect();
71        self.put(
72            name,
73            Json::object([
74                ("type", Json::from("string")),
75                ("description", Json::from(description)),
76                ("enum", Json::Array(values)),
77            ]),
78        );
79        self
80    }
81
82    /// An array whose items are all of one primitive type.
83    pub fn array_of(mut self, name: &str, description: &str, item_type: &str) -> Schema {
84        self.put(
85            name,
86            Json::object([
87                ("type", Json::from("array")),
88                ("description", Json::from(description)),
89                ("items", Json::object([("type", Json::from(item_type))])),
90            ]),
91        );
92        self
93    }
94
95    /// A nested object.
96    pub fn nested(mut self, name: &str, description: &str, schema: Schema) -> Schema {
97        let mut inner = schema.to_json();
98        if let Json::Object(map) = &mut inner {
99            map.insert("description".into(), Json::from(description));
100        }
101        self.put(name, inner);
102        self
103    }
104
105    /// Make the field just added optional.
106    ///
107    /// Fields are required by default because that is what a caller means far
108    /// more often, and because an optional field a model omits is a `None` the
109    /// handler has to think about.
110    pub fn optional(mut self) -> Schema {
111        self.required.pop();
112        self
113    }
114
115    fn field(mut self, name: &str, kind: &str, description: &str) -> Schema {
116        self.put(
117            name,
118            Json::object([
119                ("type", Json::from(kind)),
120                ("description", Json::from(description)),
121            ]),
122        );
123        self
124    }
125
126    fn put(&mut self, name: &str, definition: Json) {
127        self.properties.push((name.to_string(), definition));
128        self.required.push(name.to_string());
129    }
130
131    /// The schema as the wire wants it.
132    pub fn to_json(&self) -> Json {
133        if let Some(raw) = &self.raw {
134            return raw.clone();
135        }
136
137        let mut object = BTreeMap::new();
138        object.insert("type".to_string(), Json::from("object"));
139        if let Some(description) = &self.description {
140            object.insert("description".to_string(), Json::from(description.as_str()));
141        }
142        object.insert(
143            "properties".to_string(),
144            Json::object(self.properties.iter().map(|(k, v)| (k.as_str(), v.clone()))),
145        );
146        object.insert(
147            "required".to_string(),
148            Json::Array(self.required.iter().map(|name| Json::from(name.as_str())).collect()),
149        );
150        Json::Object(object)
151    }
152
153    pub fn is_empty(&self) -> bool {
154        self.raw.is_none() && self.properties.is_empty()
155    }
156}
157
158/// A function the model may ask for.
159#[derive(Debug, Clone, PartialEq)]
160pub struct Tool {
161    pub name: String,
162    /// What it does, in prose. This is the only thing the model has to go on
163    /// when deciding whether to reach for it, so it earns its length.
164    pub description: String,
165    pub parameters: Schema,
166}
167
168impl Tool {
169    pub fn new(name: impl Into<String>, description: impl Into<String>) -> Tool {
170        Tool {
171            name: name.into(),
172            description: description.into(),
173            parameters: Schema::object(),
174        }
175    }
176
177    pub fn parameters(mut self, schema: Schema) -> Tool {
178        self.parameters = schema;
179        self
180    }
181
182    pub fn string(mut self, name: &str, description: &str) -> Tool {
183        self.parameters = self.parameters.string(name, description);
184        self
185    }
186
187    pub fn integer(mut self, name: &str, description: &str) -> Tool {
188        self.parameters = self.parameters.integer(name, description);
189        self
190    }
191
192    pub fn number(mut self, name: &str, description: &str) -> Tool {
193        self.parameters = self.parameters.number(name, description);
194        self
195    }
196
197    pub fn boolean(mut self, name: &str, description: &str) -> Tool {
198        self.parameters = self.parameters.boolean(name, description);
199        self
200    }
201
202    pub fn one_of(mut self, name: &str, description: &str, values: &[&str]) -> Tool {
203        self.parameters = self.parameters.one_of(name, description, values);
204        self
205    }
206
207    pub fn array_of(mut self, name: &str, description: &str, item_type: &str) -> Tool {
208        self.parameters = self.parameters.array_of(name, description, item_type);
209        self
210    }
211
212    /// Make the parameter just added optional.
213    pub fn optional(mut self) -> Tool {
214        self.parameters = self.parameters.optional();
215        self
216    }
217
218    pub fn schema_json(&self) -> Json {
219        self.parameters.to_json()
220    }
221}
222
223/// A tool handler: takes the arguments the model chose, answers with JSON.
224type Handler = Arc<dyn Fn(Json) -> BoxFuture<'static, Result<Json>> + Send + Sync>;
225
226/// Tools plus the code that runs them.
227///
228/// Cloning is cheap and shares the handlers, so a toolbox can be built once at
229/// boot and handed to every request.
230#[derive(Clone, Default)]
231pub struct Toolbox {
232    tools: Vec<Tool>,
233    handlers: BTreeMap<String, Handler>,
234}
235
236impl Toolbox {
237    pub fn new() -> Toolbox {
238        Toolbox::default()
239    }
240
241    /// Register a tool and the async function that answers it.
242    pub fn add<F, Fut>(mut self, tool: Tool, handler: F) -> Toolbox
243    where
244        F: Fn(Json) -> Fut + Send + Sync + 'static,
245        Fut: Future<Output = Result<Json>> + Send + 'static,
246    {
247        let name = tool.name.clone();
248        self.tools.retain(|existing| existing.name != name);
249        self.tools.push(tool);
250        self.handlers.insert(name, Arc::new(move |input| Box::pin(handler(input))));
251        self
252    }
253
254    pub fn tools(&self) -> &[Tool] {
255        &self.tools
256    }
257
258    pub fn has(&self, name: &str) -> bool {
259        self.handlers.contains_key(name)
260    }
261
262    pub fn len(&self) -> usize {
263        self.tools.len()
264    }
265
266    pub fn is_empty(&self) -> bool {
267        self.tools.is_empty()
268    }
269
270    /// Run one tool by name.
271    pub async fn call(&self, name: &str, arguments: Json) -> Result<Json> {
272        let handler = self.handlers.get(name).ok_or_else(|| {
273            Error::msg(format!(
274                "the model asked for a tool named `{name}`, which is not registered. \
275                 Registered tools: {}",
276                self.tools.iter().map(|t| t.name.as_str()).collect::<Vec<_>>().join(", ")
277            ))
278        })?;
279        handler(arguments).await
280    }
281}
282
283impl std::fmt::Debug for Toolbox {
284    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
285        f.debug_struct("Toolbox")
286            .field("tools", &self.tools.iter().map(|t| &t.name).collect::<Vec<_>>())
287            .finish()
288    }
289}
290
291/// How many model turns a tool loop may take before giving up.
292///
293/// A model that keeps asking for tools forever is a bug, not a conversation,
294/// and an uncapped loop bills for it.
295pub const DEFAULT_MAX_ROUNDS: usize = 8;
296
297/// Ask the model, run whatever tools it asks for, feed the results back, and
298/// repeat until it answers in words.
299///
300/// Usage is summed across every round: one loop is one operation as far as the
301/// caller's budget is concerned.
302pub async fn run_tools(
303    provider: &dyn Provider,
304    request: &Request,
305    toolbox: &Toolbox,
306    max_rounds: usize,
307) -> Result<Completion> {
308    let mut request = request.clone();
309    if request.tools.is_empty() {
310        request.tools = toolbox.tools().to_vec();
311    }
312
313    let mut total = Usage::default();
314
315    for round in 0..max_rounds.max(1) {
316        let mut completion = provider.complete(&request).await?;
317        total.add(completion.usage);
318
319        if !completion.wants_tools() {
320            completion.usage = total;
321            return Ok(completion);
322        }
323
324        request.messages.push(completion.to_message());
325        for call in &completion.tool_calls {
326            let message = match toolbox.call(&call.name, call.arguments.clone()).await {
327                Ok(output) => Message::tool_result(&call.id, &call.name, output),
328                // A handler that fails is reported to the model, not to the
329                // caller: the model can usually recover, and if it cannot the
330                // loop ends normally with an explanation.
331                Err(error) => Message::tool_error(&call.id, &call.name, error.to_string()),
332            };
333            request.messages.push(message);
334        }
335
336        if round + 1 == max_rounds.max(1) {
337            return Err(Error::msg(format!(
338                "the model still wanted tools after {max_rounds} rounds; \
339                 raise the limit or check that the tools answer usefully"
340            )));
341        }
342    }
343
344    unreachable!("the loop returns or errors on its last round")
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn weather_tool() -> Tool {
352        Tool::new("get_weather", "Look up today's weather")
353            .string("city", "The city to look up")
354            .one_of("unit", "Temperature unit", &["celsius", "fahrenheit"])
355            .optional()
356    }
357
358    #[test]
359    fn a_schema_is_built_without_hand_writing_json() {
360        let schema = weather_tool().schema_json();
361
362        assert_eq!(schema.get("type").unwrap().as_str(), Some("object"));
363        assert_eq!(schema.get("properties.city.type").unwrap().as_str(), Some("string"));
364        assert_eq!(
365            schema.get("properties.city.description").unwrap().as_str(),
366            Some("The city to look up")
367        );
368        assert_eq!(schema.get("properties.unit.enum.0").unwrap().as_str(), Some("celsius"));
369
370        // `city` is required; `unit` was marked optional.
371        let required = schema.get("required").unwrap().as_array().unwrap();
372        assert_eq!(required.len(), 1);
373        assert_eq!(required[0].as_str(), Some("city"));
374    }
375
376    #[test]
377    fn schemas_describe_arrays_and_nested_objects() {
378        let schema = Schema::object()
379            .describe("A search")
380            .array_of("terms", "Words to look for", "string")
381            .nested("page", "Paging", Schema::object().integer("size", "How many"))
382            .to_json();
383
384        assert_eq!(schema.get("description").unwrap().as_str(), Some("A search"));
385        assert_eq!(schema.get("properties.terms.items.type").unwrap().as_str(), Some("string"));
386        assert_eq!(schema.get("properties.page.type").unwrap().as_str(), Some("object"));
387        assert_eq!(schema.get("properties.page.description").unwrap().as_str(), Some("Paging"));
388        assert_eq!(
389            schema.get("properties.page.properties.size.type").unwrap().as_str(),
390            Some("integer")
391        );
392    }
393
394    #[test]
395    fn a_hand_written_schema_is_passed_through_untouched() {
396        let raw = Json::object([("type", "string".into())]);
397        assert_eq!(Schema::raw(raw.clone()).to_json(), raw);
398    }
399
400    #[test]
401    fn marking_optional_with_no_fields_is_harmless() {
402        assert!(Schema::object().optional().to_json().get("required").is_some());
403    }
404
405    #[tokio::test]
406    async fn a_toolbox_runs_the_handler_it_was_given() {
407        let toolbox = Toolbox::new().add(weather_tool(), |input: Json| async move {
408            let city = input.get("city").and_then(Json::as_str).unwrap_or("nowhere").to_string();
409            Ok(Json::object([("city", Json::from(city)), ("degrees", 7.into())]))
410        });
411
412        assert_eq!(toolbox.len(), 1);
413        assert!(toolbox.has("get_weather"));
414
415        let answer = toolbox
416            .call("get_weather", Json::object([("city", "Oslo".into())]))
417            .await
418            .unwrap();
419
420        assert_eq!(answer.get("city").unwrap().as_str(), Some("Oslo"));
421        assert_eq!(answer.get("degrees").unwrap().as_i64(), Some(7));
422    }
423
424    #[tokio::test]
425    async fn an_unknown_tool_names_the_ones_that_do_exist() {
426        let toolbox = Toolbox::new().add(weather_tool(), |_| async { Ok(Json::Null) });
427
428        let error = toolbox.call("get_tides", Json::Null).await.unwrap_err().to_string();
429
430        assert!(error.contains("get_tides"), "{error}");
431        assert!(error.contains("get_weather"), "{error}");
432    }
433
434    #[test]
435    fn registering_a_tool_twice_replaces_it() {
436        let toolbox = Toolbox::new()
437            .add(Tool::new("t", "first"), |_| async { Ok(Json::Null) })
438            .add(Tool::new("t", "second"), |_| async { Ok(Json::Null) });
439
440        assert_eq!(toolbox.len(), 1);
441        assert_eq!(toolbox.tools()[0].description, "second");
442        assert!(format!("{toolbox:?}").contains('t'));
443    }
444}