Skip to main content

ai_agent/
tool_helper.rs

1use crate::types::ToolDefinition;
2use serde::{Deserialize, Serialize};
3
4/// Tool annotations (MCP standard).
5#[derive(Debug, Clone, Default, Serialize, Deserialize)]
6pub struct ToolAnnotations {
7    #[serde(rename = "readOnlyHint", skip_serializing_if = "Option::is_none")]
8    pub read_only_hint: Option<bool>,
9    #[serde(rename = "destructiveHint", skip_serializing_if = "Option::is_none")]
10    pub destructive_hint: Option<bool>,
11    #[serde(rename = "idempotentHint", skip_serializing_if = "Option::is_none")]
12    pub idempotent_hint: Option<bool>,
13    #[serde(rename = "openWorldHint", skip_serializing_if = "Option::is_none")]
14    pub open_world_hint: Option<bool>,
15}
16
17/// Tool call result (MCP-compatible).
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct CallToolResult {
20    pub content: Vec<ContentBlock>,
21    #[serde(skip_serializing_if = "Option::is_none")]
22    pub is_error: Option<bool>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26#[serde(untagged)]
27pub enum ContentBlock {
28    Text {
29        #[serde(rename = "type")]
30        content_type: String,
31        text: String,
32    },
33    Image {
34        #[serde(rename = "type")]
35        content_type: String,
36        data: String,
37        mime_type: String,
38    },
39    Resource {
40        #[serde(rename = "type")]
41        content_type: String,
42        resource: ResourceContent,
43    },
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct ResourceContent {
48    pub uri: String,
49    #[serde(skip_serializing_if = "Option::is_none")]
50    pub text: Option<String>,
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub blob: Option<String>,
53}
54
55/// SDK tool definition - stores tool metadata for later conversion to ToolDefinition.
56/// The handler is not stored directly - it should be registered separately.
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct SdkToolDefinition {
59    pub name: String,
60    pub description: String,
61    pub input_schema: serde_json::Value,
62    pub annotations: Option<ToolAnnotations>,
63}
64
65/// Create a tool using JSON Schema.
66///
67/// This creates the metadata definition. The handler should be registered separately
68/// with the tool system.
69///
70/// Usage:
71/// ```ignore
72/// let tool = create_tool(
73///     "get_weather",
74///     "Get weather for a city",
75///     serde_json::json!({
76///         "type": "object",
77///         "properties": {
78///             "city": { "type": "string", "description": "City name" }
79///         },
80///         "required": ["city"]
81///     })
82/// );
83/// ```
84pub fn create_tool(
85    name: &str,
86    description: &str,
87    input_schema: serde_json::Value,
88) -> SdkToolDefinition {
89    SdkToolDefinition {
90        name: name.to_string(),
91        description: description.to_string(),
92        input_schema,
93        annotations: None,
94    }
95}
96
97/// Create a tool with annotations.
98pub fn create_tool_with_annotations(
99    name: &str,
100    description: &str,
101    input_schema: serde_json::Value,
102    annotations: ToolAnnotations,
103) -> SdkToolDefinition {
104    SdkToolDefinition {
105        name: name.to_string(),
106        description: description.to_string(),
107        input_schema,
108        annotations: Some(annotations),
109    }
110}
111
112/// Convert an SdkToolDefinition to a ToolDefinition for the engine.
113pub fn sdk_tool_to_tool_definition(sdk_tool: SdkToolDefinition) -> ToolDefinition {
114    let tool_name = sdk_tool.name.clone();
115    let tool_description = sdk_tool.description.clone();
116    let input_schema = sdk_tool.input_schema.clone();
117
118    // Extract properties and required from the JSON schema
119    let (schema_type, properties, required) = extract_schema_parts(&input_schema);
120
121    crate::types::ToolDefinition {
122        name: tool_name,
123        description: tool_description,
124        input_schema: crate::types::ToolInputSchema {
125            schema_type,
126            properties,
127            required,
128        },
129        annotations: None,
130        should_defer: None,
131        always_load: None,
132        is_mcp: None,
133        search_hint: None,
134        aliases: None,
135        user_facing_name: None,
136        interrupt_behavior: None,
137    }
138}
139
140/// Extract schema parts from a JSON schema.
141fn extract_schema_parts(
142    schema: &serde_json::Value,
143) -> (String, serde_json::Value, Option<Vec<String>>) {
144    let schema_type = schema
145        .get("type")
146        .and_then(|t| t.as_str())
147        .unwrap_or("object")
148        .to_string();
149
150    let properties = schema
151        .get("properties")
152        .cloned()
153        .unwrap_or(serde_json::json!({}));
154
155    let required = schema
156        .get("required")
157        .and_then(|r| r.as_array())
158        .map(|arr| {
159            arr.iter()
160                .filter_map(|s| s.as_str().map(String::from))
161                .collect()
162        });
163
164    (schema_type, properties, required)
165}
166
167/// Helper to create a text content block.
168pub fn text_content(text: &str) -> ContentBlock {
169    ContentBlock::Text {
170        content_type: "text".to_string(),
171        text: text.to_string(),
172    }
173}
174
175/// Helper to create an image content block.
176pub fn image_content(data: &str, mime_type: &str) -> ContentBlock {
177    ContentBlock::Image {
178        content_type: "image".to_string(),
179        data: data.to_string(),
180        mime_type: mime_type.to_string(),
181    }
182}
183
184/// Helper to create a resource content block.
185pub fn resource_content(uri: &str, text: Option<&str>, blob: Option<&str>) -> ContentBlock {
186    ContentBlock::Resource {
187        content_type: "resource".to_string(),
188        resource: ResourceContent {
189            uri: uri.to_string(),
190            text: text.map(String::from),
191            blob: blob.map(String::from),
192        },
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn test_tool_annotations_default() {
202        let annotations = ToolAnnotations::default();
203        assert!(annotations.read_only_hint.is_none());
204    }
205
206    #[test]
207    fn test_tool_annotations_with_values() {
208        let annotations = ToolAnnotations {
209            read_only_hint: Some(true),
210            destructive_hint: Some(false),
211            idempotent_hint: Some(true),
212            open_world_hint: None,
213        };
214
215        assert_eq!(annotations.read_only_hint, Some(true));
216        assert_eq!(annotations.destructive_hint, Some(false));
217    }
218
219    #[test]
220    fn test_call_tool_result_text() {
221        let result = CallToolResult {
222            content: vec![text_content("Hello world")],
223            is_error: Some(false),
224        };
225
226        assert!(!result.is_error.unwrap());
227        if let ContentBlock::Text { text, .. } = &result.content[0] {
228            assert_eq!(text, "Hello world");
229        } else {
230            panic!("Expected Text content block");
231        }
232    }
233
234    #[test]
235    fn test_create_tool() {
236        let tool = create_tool(
237            "test_tool",
238            "A test tool",
239            serde_json::json!({
240                "type": "object",
241                "properties": {
242                    "arg": { "type": "string" }
243                }
244            }),
245        );
246
247        assert_eq!(tool.name, "test_tool");
248        assert_eq!(tool.description, "A test tool");
249    }
250
251    #[test]
252    fn test_create_tool_with_annotations() {
253        let tool = create_tool_with_annotations(
254            "readonly_tool",
255            "A read-only tool",
256            serde_json::json!({
257                "type": "object",
258                "properties": {}
259            }),
260            ToolAnnotations {
261                read_only_hint: Some(true),
262                ..Default::default()
263            },
264        );
265
266        assert!(tool.annotations.is_some());
267        assert_eq!(tool.annotations.unwrap().read_only_hint, Some(true));
268    }
269
270    #[test]
271    fn test_sdk_tool_to_tool_definition() {
272        let sdk_tool = create_tool(
273            "weather",
274            "Get weather info",
275            serde_json::json!({
276                "type": "object",
277                "properties": {
278                    "city": { "type": "string", "description": "City name" }
279                },
280                "required": ["city"]
281            }),
282        );
283
284        let tool_def = sdk_tool_to_tool_definition(sdk_tool);
285        assert_eq!(tool_def.name, "weather");
286        assert_eq!(tool_def.description, "Get weather info");
287    }
288
289    #[test]
290    fn test_extract_schema_parts() {
291        let schema = serde_json::json!({
292            "type": "object",
293            "properties": {
294                "name": { "type": "string" },
295                "age": { "type": "number" }
296            },
297            "required": ["name"]
298        });
299
300        let (schema_type, properties, required) = extract_schema_parts(&schema);
301
302        assert_eq!(schema_type, "object");
303        assert!(properties.get("name").is_some());
304        assert_eq!(required, Some(vec!["name".to_string()]));
305    }
306
307    #[test]
308    fn test_text_content_helper() {
309        let content = text_content("test");
310        match content {
311            ContentBlock::Text { content_type, text } => {
312                assert_eq!(content_type, "text");
313                assert_eq!(text, "test");
314            }
315            _ => panic!("Expected Text variant"),
316        }
317    }
318
319    #[test]
320    fn test_image_content_helper() {
321        let content = image_content("base64data", "image/png");
322        match content {
323            ContentBlock::Image {
324                content_type,
325                data,
326                mime_type,
327            } => {
328                assert_eq!(content_type, "image");
329                assert_eq!(data, "base64data");
330                assert_eq!(mime_type, "image/png");
331            }
332            _ => panic!("Expected Image variant"),
333        }
334    }
335
336    #[test]
337    fn test_resource_content_helper() {
338        let content = resource_content("file://test.txt", Some("content"), None);
339        match content {
340            ContentBlock::Resource {
341                content_type,
342                resource,
343            } => {
344                assert_eq!(content_type, "resource");
345                assert_eq!(resource.uri, "file://test.txt");
346                assert_eq!(resource.text, Some("content".to_string()));
347            }
348            _ => panic!("Expected Resource variant"),
349        }
350    }
351}