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    }
131}
132
133/// Extract schema parts from a JSON schema.
134fn extract_schema_parts(
135    schema: &serde_json::Value,
136) -> (String, serde_json::Value, Option<Vec<String>>) {
137    let schema_type = schema
138        .get("type")
139        .and_then(|t| t.as_str())
140        .unwrap_or("object")
141        .to_string();
142
143    let properties = schema
144        .get("properties")
145        .cloned()
146        .unwrap_or(serde_json::json!({}));
147
148    let required = schema
149        .get("required")
150        .and_then(|r| r.as_array())
151        .map(|arr| {
152            arr.iter()
153                .filter_map(|s| s.as_str().map(String::from))
154                .collect()
155        });
156
157    (schema_type, properties, required)
158}
159
160/// Helper to create a text content block.
161pub fn text_content(text: &str) -> ContentBlock {
162    ContentBlock::Text {
163        content_type: "text".to_string(),
164        text: text.to_string(),
165    }
166}
167
168/// Helper to create an image content block.
169pub fn image_content(data: &str, mime_type: &str) -> ContentBlock {
170    ContentBlock::Image {
171        content_type: "image".to_string(),
172        data: data.to_string(),
173        mime_type: mime_type.to_string(),
174    }
175}
176
177/// Helper to create a resource content block.
178pub fn resource_content(uri: &str, text: Option<&str>, blob: Option<&str>) -> ContentBlock {
179    ContentBlock::Resource {
180        content_type: "resource".to_string(),
181        resource: ResourceContent {
182            uri: uri.to_string(),
183            text: text.map(String::from),
184            blob: blob.map(String::from),
185        },
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_tool_annotations_default() {
195        let annotations = ToolAnnotations::default();
196        assert!(annotations.read_only_hint.is_none());
197    }
198
199    #[test]
200    fn test_tool_annotations_with_values() {
201        let annotations = ToolAnnotations {
202            read_only_hint: Some(true),
203            destructive_hint: Some(false),
204            idempotent_hint: Some(true),
205            open_world_hint: None,
206        };
207
208        assert_eq!(annotations.read_only_hint, Some(true));
209        assert_eq!(annotations.destructive_hint, Some(false));
210    }
211
212    #[test]
213    fn test_call_tool_result_text() {
214        let result = CallToolResult {
215            content: vec![text_content("Hello world")],
216            is_error: Some(false),
217        };
218
219        assert!(!result.is_error.unwrap());
220        if let ContentBlock::Text { text, .. } = &result.content[0] {
221            assert_eq!(text, "Hello world");
222        } else {
223            panic!("Expected Text content block");
224        }
225    }
226
227    #[test]
228    fn test_create_tool() {
229        let tool = create_tool(
230            "test_tool",
231            "A test tool",
232            serde_json::json!({
233                "type": "object",
234                "properties": {
235                    "arg": { "type": "string" }
236                }
237            }),
238        );
239
240        assert_eq!(tool.name, "test_tool");
241        assert_eq!(tool.description, "A test tool");
242    }
243
244    #[test]
245    fn test_create_tool_with_annotations() {
246        let tool = create_tool_with_annotations(
247            "readonly_tool",
248            "A read-only tool",
249            serde_json::json!({
250                "type": "object",
251                "properties": {}
252            }),
253            ToolAnnotations {
254                read_only_hint: Some(true),
255                ..Default::default()
256            },
257        );
258
259        assert!(tool.annotations.is_some());
260        assert_eq!(tool.annotations.unwrap().read_only_hint, Some(true));
261    }
262
263    #[test]
264    fn test_sdk_tool_to_tool_definition() {
265        let sdk_tool = create_tool(
266            "weather",
267            "Get weather info",
268            serde_json::json!({
269                "type": "object",
270                "properties": {
271                    "city": { "type": "string", "description": "City name" }
272                },
273                "required": ["city"]
274            }),
275        );
276
277        let tool_def = sdk_tool_to_tool_definition(sdk_tool);
278        assert_eq!(tool_def.name, "weather");
279        assert_eq!(tool_def.description, "Get weather info");
280    }
281
282    #[test]
283    fn test_extract_schema_parts() {
284        let schema = serde_json::json!({
285            "type": "object",
286            "properties": {
287                "name": { "type": "string" },
288                "age": { "type": "number" }
289            },
290            "required": ["name"]
291        });
292
293        let (schema_type, properties, required) = extract_schema_parts(&schema);
294
295        assert_eq!(schema_type, "object");
296        assert!(properties.get("name").is_some());
297        assert_eq!(required, Some(vec!["name".to_string()]));
298    }
299
300    #[test]
301    fn test_text_content_helper() {
302        let content = text_content("test");
303        match content {
304            ContentBlock::Text { content_type, text } => {
305                assert_eq!(content_type, "text");
306                assert_eq!(text, "test");
307            }
308            _ => panic!("Expected Text variant"),
309        }
310    }
311
312    #[test]
313    fn test_image_content_helper() {
314        let content = image_content("base64data", "image/png");
315        match content {
316            ContentBlock::Image {
317                content_type,
318                data,
319                mime_type,
320            } => {
321                assert_eq!(content_type, "image");
322                assert_eq!(data, "base64data");
323                assert_eq!(mime_type, "image/png");
324            }
325            _ => panic!("Expected Image variant"),
326        }
327    }
328
329    #[test]
330    fn test_resource_content_helper() {
331        let content = resource_content("file://test.txt", Some("content"), None);
332        match content {
333            ContentBlock::Resource {
334                content_type,
335                resource,
336            } => {
337                assert_eq!(content_type, "resource");
338                assert_eq!(resource.uri, "file://test.txt");
339                assert_eq!(resource.text, Some("content".to_string()));
340            }
341            _ => panic!("Expected Resource variant"),
342        }
343    }
344}