Skip to main content

rai_sdk/
tool.rs

1//! Typed tools that the model can call during generation.
2//!
3//! A [`Tool`] pairs a name and description with an async handler. The handler's
4//! argument type supplies the tool's JSON Schema through [`JsonSchema`], so the
5//! schema advertised to the provider and the type the handler receives cannot
6//! drift apart.
7//!
8//! Incoming arguments are validated against that schema before the handler runs.
9//! Validation failures do not abort generation: they are returned to the model
10//! as a structured tool-error message describing each problem, so the model can
11//! correct itself and call the tool again.
12
13use std::{collections::HashMap, future::Future, pin::Pin, sync::Arc};
14
15use schemars::{JsonSchema, schema_for};
16use serde::{Serialize, de::DeserializeOwned};
17
18use crate::{
19    error::{Error, ProviderKind, Result, ToolArgumentIssue},
20    generation::normalize_strict_json_schema,
21    message::{Message, ToolCall, ToolDefinition},
22};
23
24type ToolFuture = Pin<Box<dyn Future<Output = Result<serde_json::Value>> + Send>>;
25type ToolHandler = dyn Fn(serde_json::Value, ToolContext) -> ToolFuture + Send + Sync;
26
27fn format_instance_path(path: impl ToString) -> String {
28    let path = path.to_string();
29    if path.is_empty() {
30        "$".to_string()
31    } else {
32        path
33    }
34}
35
36fn collect_validation_issues(
37    validator: &jsonschema::Validator,
38    value: &serde_json::Value,
39) -> Vec<ToolArgumentIssue> {
40    let mut issues: Vec<_> = validator
41        .iter_errors(value)
42        .map(|error| ToolArgumentIssue {
43            path: format_instance_path(error.instance_path()),
44            schema_path: error.schema_path().to_string(),
45            message: error.to_string(),
46        })
47        .collect();
48
49    issues.sort_by(|a, b| {
50        a.path
51            .cmp(&b.path)
52            .then_with(|| a.schema_path.cmp(&b.schema_path))
53            .then_with(|| a.message.cmp(&b.message))
54    });
55    issues.dedup();
56    issues
57}
58
59fn summarize_validation_issues(issues: &[ToolArgumentIssue]) -> Option<String> {
60    if issues.is_empty() {
61        return None;
62    }
63
64    let messages: Vec<_> = issues.iter().map(|i| i.message.clone()).collect();
65    Some(match messages.as_slice() {
66        [single] => single.clone(),
67        many => format!("{} validation errors: {}", many.len(), many.join("; ")),
68    })
69}
70
71fn tool_error_content(error: Error) -> Result<String> {
72    match error {
73        Error::ToolArguments {
74            name,
75            message,
76            issues,
77        } => serde_json::to_string(&serde_json::json!({
78            "error": {
79                "type": "tool_argument_validation",
80                "tool": name,
81                "retryable": true,
82                "message": "Tool arguments failed validation. Call the tool again with corrected arguments.",
83                "summary": message,
84                "issues": issues,
85            }
86        }))
87        .map_err(Into::into),
88        error => serde_json::to_string(&serde_json::json!({ "error": error.to_string() }))
89            .map_err(Into::into),
90    }
91}
92
93/// Metadata passed to each tool handler invocation.
94///
95/// # Example
96///
97/// ```rust
98/// use rai_sdk::{JsonSchema, Tool};
99/// use serde::Deserialize;
100///
101/// #[derive(Deserialize, JsonSchema)]
102/// #[schemars(crate = "rai_sdk::schemars")]
103/// struct EchoArgs {
104///     value: String,
105/// }
106///
107/// let _tool = Tool::new("echo")
108///     .description("Return the input together with model metadata")
109///     .handler(|args: EchoArgs, ctx: rai_sdk::ToolContext| async move {
110///         Ok(format!("{} via {}", args.value, ctx.model))
111///     })?;
112/// # Ok::<(), rai_sdk::Error>(())
113/// ```
114#[derive(Debug, Clone)]
115pub struct ToolContext {
116    /// Provider that requested the tool call.
117    pub provider: ProviderKind,
118    /// Model ID that requested the tool call, as sent on the wire.
119    pub model: String,
120    /// Zero-based index of the tool-calling round within the current request.
121    ///
122    /// Useful for detecting repeated calls and for bounding work in handlers
123    /// that may be invoked several times in one generation.
124    pub round: usize,
125    /// Name of the tool being invoked.
126    pub tool_name: String,
127    /// Provider-assigned identifier for this specific tool call.
128    pub tool_call_id: String,
129}
130
131/// A callable tool that can be registered on a client or request.
132///
133/// Use [`Tool::new`] to start building, chain `.description()` and `.handler()`,
134/// where `.handler()` is fallible and validates the schema at build time.
135///
136/// # Example
137///
138/// ```rust
139/// use rai_sdk::{JsonSchema, Tool};
140/// use serde::Deserialize;
141///
142/// #[derive(Deserialize, JsonSchema)]
143/// #[schemars(crate = "rai_sdk::schemars")]
144/// struct GetUserInfo {
145///     user_id: String,
146/// }
147///
148/// let _tool = Tool::new("get_user_info")
149///     .description("Load a user's profile")
150///     .handler(|input: GetUserInfo, _ctx: rai_sdk::ToolContext| async move {
151///         Ok(serde_json::json!({ "user_id": input.user_id, "email": "user@example.com" }))
152///     })?;
153/// # Ok::<(), rai_sdk::Error>(())
154/// ```
155#[derive(Clone)]
156pub struct Tool {
157    name: String,
158    description: Option<String>,
159    input_schema: Option<serde_json::Value>,
160    handler: Option<Arc<ToolHandler>>,
161}
162
163impl Tool {
164    /// Start a new tool definition.
165    pub fn new(name: impl Into<String>) -> Self {
166        Self {
167            name: name.into(),
168            description: None,
169            input_schema: None,
170            handler: None,
171        }
172    }
173
174    /// Describe when the model should use this tool.
175    pub fn description(mut self, description: impl Into<String>) -> Self {
176        self.description = Some(description.into());
177        self
178    }
179
180    /// Override the inferred input schema with a custom JSON Schema.
181    pub fn json_schema(mut self, input_schema: serde_json::Value) -> Self {
182        self.input_schema = Some(input_schema);
183        self
184    }
185
186    /// Attach an async handler and infer the input schema from `Args`.
187    ///
188    /// This is fallible — the schema is validated at build time and an error
189    /// is returned if it is invalid.
190    pub fn handler<Args, Output, F, Fut>(mut self, handler: F) -> Result<Self>
191    where
192        Args: DeserializeOwned + JsonSchema + Send + 'static,
193        Output: Serialize + Send + 'static,
194        F: Fn(Args, ToolContext) -> Fut + Send + Sync + 'static,
195        Fut: Future<Output = Result<Output>> + Send + 'static,
196    {
197        let tool_name = self.name.clone();
198        let schema = schema_for!(Args);
199        let mut generated_schema = serde_json::to_value(schema)?;
200        normalize_strict_json_schema(&mut generated_schema);
201
202        let mut input_schema = self.input_schema.take().unwrap_or(generated_schema);
203        normalize_strict_json_schema(&mut input_schema);
204
205        let validator = Arc::new(jsonschema::validator_for(&input_schema).map_err(|error| {
206            Error::InvalidRequest(format!(
207                "Tool '{}' has an invalid input schema: {error}",
208                self.name
209            ))
210        })?);
211
212        self.input_schema = Some(input_schema);
213
214        self.handler = Some(Arc::new(move |value, ctx| {
215            let tool_name = tool_name.clone();
216            let validator = validator.clone();
217
218            let issues = collect_validation_issues(&validator, &value);
219
220            if let Some(message) = summarize_validation_issues(&issues) {
221                return Box::pin(async move {
222                    Err(Error::ToolArguments {
223                        name: tool_name,
224                        message,
225                        issues,
226                    })
227                });
228            }
229
230            let future = match serde_json::from_value::<Args>(value) {
231                Ok(args) => handler(args, ctx),
232                Err(error) => {
233                    return Box::pin(async move {
234                        Err(Error::ToolArguments {
235                            name: tool_name,
236                            message: error.to_string(),
237                            issues: Vec::new(),
238                        })
239                    });
240                }
241            };
242
243            Box::pin(async move {
244                let output = future.await?;
245                serde_json::to_value(output).map_err(Into::into)
246            })
247        }));
248
249        Ok(self)
250    }
251
252    fn into_registered(self) -> Result<RegisteredTool> {
253        if self.name.trim().is_empty() {
254            return Err(Error::InvalidRequest(
255                "Tool name cannot be empty".to_string(),
256            ));
257        }
258
259        let input_schema = self.input_schema.ok_or_else(|| {
260            Error::InvalidRequest(format!("Tool '{}' is missing an input schema", self.name))
261        })?;
262
263        let handler = self.handler.ok_or_else(|| {
264            Error::InvalidRequest(format!("Tool '{}' is missing a handler", self.name))
265        })?;
266
267        Ok(RegisteredTool {
268            definition: ToolDefinition {
269                name: self.name,
270                description: self.description,
271                input_schema,
272            },
273            handler,
274        })
275    }
276}
277
278#[derive(Clone)]
279struct RegisteredTool {
280    definition: ToolDefinition,
281    handler: Arc<ToolHandler>,
282}
283
284/// Registry of tools that can be auto-executed during generation.
285#[derive(Clone, Default)]
286pub(crate) struct ToolRegistry {
287    tools: HashMap<String, RegisteredTool>,
288}
289
290impl ToolRegistry {
291    pub(crate) fn new() -> Self {
292        Self {
293            tools: HashMap::new(),
294        }
295    }
296
297    pub(crate) fn register(&mut self, tool: Tool) -> Result<()> {
298        let tool = tool.into_registered()?;
299        let name = tool.definition.name.clone();
300
301        if self.tools.contains_key(&name) {
302            return Err(Error::InvalidRequest(format!(
303                "Tool '{}' is already registered",
304                name
305            )));
306        }
307
308        self.tools.insert(name, tool);
309        Ok(())
310    }
311
312    pub(crate) fn extend<T>(&mut self, tools: T) -> Result<()>
313    where
314        T: IntoIterator<Item = Tool>,
315    {
316        for tool in tools {
317            self.register(tool)?;
318        }
319        Ok(())
320    }
321
322    pub(crate) fn is_empty(&self) -> bool {
323        self.tools.is_empty()
324    }
325
326    pub(crate) fn definitions(&self) -> Vec<ToolDefinition> {
327        self.tools
328            .values()
329            .map(|tool| tool.definition.clone())
330            .collect()
331    }
332
333    /// Execute a tool call and return a [`Message`] with the result.
334    ///
335    /// On handler error, the error is wrapped as a `Message::tool_error()` with
336    /// structured JSON content describing the validation failure — tool errors
337    /// are NOT hard failures, they become messages the model can retry from.
338    pub(crate) async fn execute(
339        &self,
340        tool_call: &ToolCall,
341        context: ToolContext,
342    ) -> Result<Message> {
343        let registered = self
344            .tools
345            .get(&tool_call.name)
346            .ok_or_else(|| Error::ToolNotFound {
347                name: tool_call.name.clone(),
348            })?;
349
350        match (registered.handler)(tool_call.arguments.clone(), context).await {
351            Ok(result) => Ok(Message::tool(
352                serde_json::to_string(&result)?,
353                tool_call.id.clone(),
354            )),
355            Err(error) => Ok(Message::tool_error(
356                tool_error_content(error)?,
357                tool_call.id.clone(),
358            )),
359        }
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366    use schemars::JsonSchema;
367    use serde::Deserialize;
368    use std::sync::Arc;
369
370    #[derive(Debug, Deserialize, JsonSchema)]
371    struct AddArgs {
372        a: i32,
373        b: i32,
374    }
375
376    #[derive(Debug, Deserialize, JsonSchema)]
377    #[schemars(deny_unknown_fields)]
378    struct SearchArgs {
379        #[schemars(
380            description = "Customer name or email substring to search for",
381            length(min = 1)
382        )]
383        query: String,
384        #[schemars(
385            description = "Maximum number of results to return",
386            range(min = 1, max = 10)
387        )]
388        limit: Option<usize>,
389    }
390
391    #[derive(Debug, Deserialize, JsonSchema)]
392    struct OptionalOnlyArgs {
393        // Only the generated schema is under test here; the field is never read.
394        #[allow(dead_code)]
395        #[serde(default)]
396        query: Option<String>,
397    }
398
399    #[derive(Debug, serde::Serialize)]
400    struct AddResult {
401        sum: i32,
402    }
403
404    fn test_tool_context(tool_name: &str) -> ToolContext {
405        ToolContext {
406            provider: ProviderKind::OpenAI,
407            model: "gpt-4o-mini".to_string(),
408            round: 0,
409            tool_name: tool_name.to_string(),
410            tool_call_id: "call_123".to_string(),
411        }
412    }
413
414    fn tool_error_payload(message: &Message) -> serde_json::Value {
415        serde_json::from_str(&message.content).expect("tool error content should be valid json")
416    }
417
418    #[tokio::test]
419    async fn tool_registry_executes_registered_handler() {
420        let calls = Arc::new(tokio::sync::Mutex::new(Vec::new()));
421        let mut registry = ToolRegistry::new();
422        registry
423            .register(
424                Tool::new("add")
425                    .description("Add two numbers")
426                    .handler({
427                        let calls = calls.clone();
428                        move |args: AddArgs, _ctx| {
429                            let calls = calls.clone();
430                            async move {
431                                calls.lock().await.push((args.a, args.b));
432                                Ok(AddResult {
433                                    sum: args.a + args.b,
434                                })
435                            }
436                        }
437                    })
438                    .expect("tool should build"),
439            )
440            .expect("tool should register");
441
442        let message = registry
443            .execute(
444                &ToolCall {
445                    id: "call_123".to_string(),
446                    name: "add".to_string(),
447                    arguments: serde_json::json!({ "a": 2, "b": 3 }),
448                },
449                test_tool_context("add"),
450            )
451            .await
452            .expect("tool execution should succeed");
453
454        assert_eq!(message.content, "{\"sum\":5}");
455        assert_eq!(message.tool_call_id.as_deref(), Some("call_123"));
456        assert!(!message.tool_error);
457        assert_eq!(calls.lock().await.as_slice(), &[(2, 3)]);
458    }
459
460    #[tokio::test]
461    async fn tool_registry_aggregates_schema_validation_errors_before_handler() {
462        let calls = Arc::new(tokio::sync::Mutex::new(Vec::new()));
463        let mut registry = ToolRegistry::new();
464        registry
465            .register(
466                Tool::new("search_customers")
467                    .description("Search customers by email or name")
468                    .handler({
469                        let calls = calls.clone();
470                        move |args: SearchArgs, _ctx| {
471                            let calls = calls.clone();
472                            async move {
473                                calls.lock().await.push(args.query);
474                                Ok(serde_json::json!({ "limit": args.limit }))
475                            }
476                        }
477                    })
478                    .expect("tool should build"),
479            )
480            .expect("tool should register");
481
482        let message = registry
483            .execute(
484                &ToolCall {
485                    id: "call_123".to_string(),
486                    name: "search_customers".to_string(),
487                    arguments: serde_json::json!({ "query": "", "limit": 25 }),
488                },
489                test_tool_context("search_customers"),
490            )
491            .await
492            .expect("tool execution should return a tool message");
493
494        let payload = tool_error_payload(&message);
495
496        assert!(message.tool_error);
497        assert_eq!(payload["error"]["type"], "tool_argument_validation");
498        assert_eq!(payload["error"]["tool"], "search_customers");
499        assert_eq!(payload["error"]["retryable"], true);
500        assert_eq!(payload["error"]["issues"].as_array().map(Vec::len), Some(2));
501        assert!(
502            payload["error"]["summary"]
503                .as_str()
504                .unwrap_or_default()
505                .contains("validation errors")
506        );
507        assert!(calls.lock().await.is_empty());
508    }
509
510    #[tokio::test]
511    async fn tool_registry_rejects_unknown_fields_in_arguments() {
512        let calls = Arc::new(tokio::sync::Mutex::new(Vec::new()));
513        let mut registry = ToolRegistry::new();
514        registry
515            .register(
516                Tool::new("add")
517                    .description("Add two numbers")
518                    .handler({
519                        let calls = calls.clone();
520                        move |args: AddArgs, _ctx| {
521                            let calls = calls.clone();
522                            async move {
523                                calls.lock().await.push((args.a, args.b));
524                                Ok(AddResult {
525                                    sum: args.a + args.b,
526                                })
527                            }
528                        }
529                    })
530                    .expect("tool should build"),
531            )
532            .expect("tool should register");
533
534        let message = registry
535            .execute(
536                &ToolCall {
537                    id: "call_123".to_string(),
538                    name: "add".to_string(),
539                    arguments: serde_json::json!({ "a": 2, "b": 3, "c": 4 }),
540                },
541                test_tool_context("add"),
542            )
543            .await
544            .expect("tool execution should return a tool message");
545
546        let payload = tool_error_payload(&message);
547
548        assert!(message.tool_error);
549        assert_eq!(payload["error"]["type"], "tool_argument_validation");
550        assert_eq!(payload["error"]["tool"], "add");
551        assert_eq!(payload["error"]["issues"].as_array().map(Vec::len), Some(1));
552        assert!(calls.lock().await.is_empty());
553    }
554
555    #[test]
556    fn tool_builder_rejects_invalid_custom_input_schema() {
557        let result = Tool::new("add")
558            .json_schema(serde_json::json!({ "type": "not-a-real-json-schema-type" }))
559            .handler(|_args: AddArgs, _ctx| async move { Ok(AddResult { sum: 0 }) });
560
561        let error = match result {
562            Ok(_) => panic!("invalid custom schemas should fail during registration"),
563            Err(error) => error,
564        };
565
566        assert!(matches!(error, Error::InvalidRequest(_)));
567        assert!(error.to_string().contains("invalid input schema"));
568    }
569
570    #[test]
571    fn tool_builder_adds_root_object_type_for_optional_only_args() {
572        let mut registry = ToolRegistry::new();
573        registry
574            .register(
575                Tool::new("lookup_workspace")
576                    .description("Look up workspace information")
577                    .handler(|_args: OptionalOnlyArgs, _ctx| async move {
578                        Ok(serde_json::json!({ "ok": true }))
579                    })
580                    .expect("tool should build"),
581            )
582            .expect("tool should register");
583
584        let definitions = registry.definitions();
585        let schema = &definitions[0].input_schema;
586
587        assert_eq!(
588            schema["type"],
589            serde_json::Value::String("object".to_string())
590        );
591        assert_eq!(
592            schema["additionalProperties"],
593            serde_json::Value::Bool(false)
594        );
595        assert!(schema.get("properties").is_some());
596    }
597}