Skip to main content

tirea_agent_loop/engine/
convert.rs

1//! Pure functions for converting between tirea and genai types.
2
3use crate::contracts::thread::{Message, Role, ToolCall};
4use crate::contracts::runtime::tool_call::{Tool, ToolDescriptor, ToolResult};
5use genai::chat::{ChatMessage, ChatRequest, MessageContent, ToolResponse};
6
7/// Convert a ToolDescriptor to a genai Tool.
8pub fn to_genai_tool(desc: &ToolDescriptor) -> genai::chat::Tool {
9    genai::chat::Tool::new(&desc.id)
10        .with_description(&desc.description)
11        .with_schema(desc.parameters.clone())
12}
13
14/// Convert a Message to a genai ChatMessage.
15pub fn to_chat_message(msg: &Message) -> ChatMessage {
16    match msg.role {
17        Role::System => ChatMessage::system(&msg.content),
18        Role::User => ChatMessage::user(&msg.content),
19        Role::Assistant => {
20            if let Some(ref calls) = msg.tool_calls {
21                // Build tool calls for genai
22                let genai_calls: Vec<genai::chat::ToolCall> = calls
23                    .iter()
24                    .map(|c| genai::chat::ToolCall {
25                        call_id: c.id.clone(),
26                        fn_name: c.name.clone(),
27                        fn_arguments: c.arguments.clone(),
28                        thought_signatures: None,
29                    })
30                    .collect();
31
32                // Create assistant message with tool calls
33                let mut content = MessageContent::from(msg.content.as_str());
34                for call in genai_calls {
35                    content.push(genai::chat::ContentPart::ToolCall(call));
36                }
37                ChatMessage::assistant(content)
38            } else {
39                ChatMessage::assistant(&msg.content)
40            }
41        }
42        Role::Tool => {
43            let call_id = msg.tool_call_id.as_deref().unwrap_or("");
44            let response = ToolResponse {
45                call_id: call_id.to_string(),
46                content: msg.content.clone(),
47            };
48            ChatMessage::from(response)
49        }
50    }
51}
52
53/// Build a genai ChatRequest from messages and tools.
54pub fn build_request(messages: &[Message], tools: &[&dyn Tool]) -> ChatRequest {
55    let chat_messages: Vec<ChatMessage> = messages.iter().map(to_chat_message).collect();
56
57    let genai_tools: Vec<genai::chat::Tool> = tools
58        .iter()
59        .map(|t| to_genai_tool(&t.descriptor()))
60        .collect();
61
62    let mut request = ChatRequest::new(chat_messages);
63
64    if !genai_tools.is_empty() {
65        request = request.with_tools(genai_tools);
66    }
67
68    request
69}
70
71/// Create a user message (convenience function).
72pub fn user_message(content: impl Into<String>) -> Message {
73    Message::user(content)
74}
75
76/// Create an assistant message (convenience function).
77pub fn assistant_message(content: impl Into<String>) -> Message {
78    Message::assistant(content)
79}
80
81/// Create an assistant message with tool calls (convenience function).
82pub fn assistant_tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Message {
83    Message::assistant_with_tool_calls(content, calls)
84}
85
86/// Create a tool response message from ToolResult.
87pub fn tool_response(call_id: impl Into<String>, result: &ToolResult) -> Message {
88    let content = serde_json::to_string(result)
89        .unwrap_or_else(|_| result.message.clone().unwrap_or_default());
90    Message::tool(call_id, content)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use serde_json::json;
97
98    // Mock tool for testing
99    struct MockTool;
100
101    #[async_trait::async_trait]
102    impl Tool for MockTool {
103        fn descriptor(&self) -> ToolDescriptor {
104            ToolDescriptor::new("mock", "Mock Tool", "A mock tool for testing").with_parameters(
105                json!({
106                    "type": "object",
107                    "properties": {
108                        "input": { "type": "string" }
109                    }
110                }),
111            )
112        }
113
114        async fn execute(
115            &self,
116            _args: serde_json::Value,
117            _ctx: &crate::contracts::ToolCallContext<'_>,
118        ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
119            Ok(ToolResult::success("mock", json!({"result": "ok"})))
120        }
121    }
122
123    #[test]
124    fn test_to_genai_tool() {
125        let desc = ToolDescriptor::new("calc", "Calculator", "Calculate expressions")
126            .with_parameters(json!({"type": "object"}));
127
128        let genai_tool = to_genai_tool(&desc);
129
130        assert_eq!(genai_tool.name, "calc");
131        assert_eq!(
132            genai_tool.description.as_deref(),
133            Some("Calculate expressions")
134        );
135    }
136
137    #[test]
138    fn test_to_chat_message_user() {
139        let msg = Message::user("Hello");
140        let chat_msg = to_chat_message(&msg);
141
142        // ChatMessage doesn't expose role directly, but we can verify it was created
143        assert!(
144            format!("{:?}", chat_msg).contains("User")
145                || format!("{:?}", chat_msg).to_lowercase().contains("user")
146        );
147    }
148
149    #[test]
150    fn test_to_chat_message_assistant() {
151        let msg = Message::assistant("Hi there");
152        let _chat_msg = to_chat_message(&msg);
153        // Basic smoke test - conversion should not panic
154    }
155
156    #[test]
157    fn test_to_chat_message_assistant_with_tools() {
158        let calls = vec![ToolCall::new("call_1", "search", json!({"q": "rust"}))];
159        let msg = Message::assistant_with_tool_calls("Searching...", calls);
160        let _chat_msg = to_chat_message(&msg);
161        // Basic smoke test - conversion should not panic
162    }
163
164    #[test]
165    fn test_to_chat_message_tool() {
166        let msg = Message::tool("call_1", "Result: 42");
167        let _chat_msg = to_chat_message(&msg);
168        // Basic smoke test - conversion should not panic
169    }
170
171    #[test]
172    fn test_build_request_no_tools() {
173        let messages = vec![Message::user("Hello"), Message::assistant("Hi!")];
174
175        let request = build_request(&messages, &[]);
176
177        assert_eq!(request.messages.len(), 2);
178        assert!(request.tools.is_none());
179    }
180
181    #[test]
182    fn test_build_request_with_tools() {
183        let messages = vec![Message::user("Hello")];
184        let mock_tool = MockTool;
185        let tools: Vec<&dyn Tool> = vec![&mock_tool];
186
187        let request = build_request(&messages, &tools);
188
189        assert_eq!(request.messages.len(), 1);
190        assert!(request.tools.is_some());
191        assert_eq!(request.tools.as_ref().unwrap().len(), 1);
192    }
193
194    #[test]
195    fn test_tool_response_from_result() {
196        let result = ToolResult::success("calc", json!({"answer": 42}));
197        let msg = tool_response("call_1", &result);
198
199        assert_eq!(msg.role, Role::Tool);
200        assert_eq!(msg.tool_call_id.as_deref(), Some("call_1"));
201        assert!(msg.content.contains("42") || msg.content.contains("success"));
202    }
203
204    // Additional edge case tests
205
206    #[test]
207    fn test_to_chat_message_system() {
208        let msg = Message::system("You are a helpful assistant.");
209        let chat_msg = to_chat_message(&msg);
210
211        let debug_str = format!("{:?}", chat_msg);
212        assert!(debug_str.to_lowercase().contains("system") || !debug_str.is_empty());
213    }
214
215    #[test]
216    fn test_build_request_empty_messages() {
217        let messages: Vec<Message> = vec![];
218        let request = build_request(&messages, &[]);
219
220        assert!(request.messages.is_empty());
221    }
222
223    #[test]
224    fn test_build_request_multiple_tools() {
225        struct Tool1;
226        struct Tool2;
227        struct Tool3;
228
229        #[async_trait::async_trait]
230        impl Tool for Tool1 {
231            fn descriptor(&self) -> ToolDescriptor {
232                ToolDescriptor::new("tool1", "Tool 1", "First tool")
233            }
234            async fn execute(
235                &self,
236                _: serde_json::Value,
237                _: &crate::contracts::ToolCallContext<'_>,
238            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
239                Ok(ToolResult::success("tool1", json!({})))
240            }
241        }
242
243        #[async_trait::async_trait]
244        impl Tool for Tool2 {
245            fn descriptor(&self) -> ToolDescriptor {
246                ToolDescriptor::new("tool2", "Tool 2", "Second tool")
247            }
248            async fn execute(
249                &self,
250                _: serde_json::Value,
251                _: &crate::contracts::ToolCallContext<'_>,
252            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
253                Ok(ToolResult::success("tool2", json!({})))
254            }
255        }
256
257        #[async_trait::async_trait]
258        impl Tool for Tool3 {
259            fn descriptor(&self) -> ToolDescriptor {
260                ToolDescriptor::new("tool3", "Tool 3", "Third tool")
261            }
262            async fn execute(
263                &self,
264                _: serde_json::Value,
265                _: &crate::contracts::ToolCallContext<'_>,
266            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
267                Ok(ToolResult::success("tool3", json!({})))
268            }
269        }
270
271        let t1 = Tool1;
272        let t2 = Tool2;
273        let t3 = Tool3;
274        let tools: Vec<&dyn Tool> = vec![&t1, &t2, &t3];
275
276        let request = build_request(&[Message::user("test")], &tools);
277        assert_eq!(request.tools.as_ref().unwrap().len(), 3);
278    }
279
280    #[test]
281    fn test_to_chat_message_with_special_characters() {
282        let msg = Message::user(
283            "Hello! How are you?\n\nI have a question about \"quotes\" and 'apostrophes'.",
284        );
285        let _chat_msg = to_chat_message(&msg);
286        // Should not panic with special characters
287    }
288
289    #[test]
290    fn test_to_chat_message_with_unicode() {
291        let msg = Message::user("你好世界! 🌍 Привет мир! مرحبا بالعالم");
292        let _chat_msg = to_chat_message(&msg);
293        // Should handle unicode properly
294    }
295
296    #[test]
297    fn test_to_chat_message_with_empty_content() {
298        let msg = Message::user("");
299        let _chat_msg = to_chat_message(&msg);
300        // Should handle empty content
301    }
302
303    #[test]
304    fn test_to_chat_message_with_very_long_content() {
305        let long_content = "a".repeat(100_000);
306        let msg = Message::user(&long_content);
307        let _chat_msg = to_chat_message(&msg);
308        // Should handle very long content
309    }
310
311    #[test]
312    fn test_tool_response_from_error_result() {
313        let result = ToolResult::error("calc", "Division by zero");
314        let msg = tool_response("call_err", &result);
315
316        assert_eq!(msg.role, Role::Tool);
317        assert!(msg.content.contains("error") || msg.content.contains("Division"));
318    }
319
320    #[test]
321    fn test_tool_response_from_pending_result() {
322        let result = ToolResult::suspended("long_task", "Processing...");
323        let msg = tool_response("call_pending", &result);
324
325        assert_eq!(msg.role, Role::Tool);
326        assert!(msg.content.contains("pending") || msg.content.contains("Processing"));
327    }
328
329    #[test]
330    fn test_assistant_message_with_multiple_tool_calls() {
331        let calls = vec![
332            ToolCall::new("call_1", "search", json!({"q": "rust"})),
333            ToolCall::new("call_2", "calculate", json!({"expr": "1+1"})),
334            ToolCall::new("call_3", "format", json!({"text": "hello"})),
335        ];
336        let msg = assistant_tool_calls("I'll help you with multiple tasks.", calls);
337
338        assert_eq!(msg.role, Role::Assistant);
339        assert!(msg.tool_calls.is_some());
340        assert_eq!(msg.tool_calls.as_ref().unwrap().len(), 3);
341    }
342
343    #[test]
344    fn test_to_genai_tool_with_complex_schema() {
345        let desc =
346            ToolDescriptor::new("api", "API Call", "Make API requests").with_parameters(json!({
347                "type": "object",
348                "properties": {
349                    "method": {
350                        "type": "string",
351                        "enum": ["GET", "POST", "PUT", "DELETE"]
352                    },
353                    "url": {
354                        "type": "string",
355                        "format": "uri"
356                    },
357                    "headers": {
358                        "type": "object",
359                        "additionalProperties": { "type": "string" }
360                    },
361                    "body": {
362                        "type": "object"
363                    }
364                },
365                "required": ["method", "url"]
366            }));
367
368        let genai_tool = to_genai_tool(&desc);
369        assert_eq!(genai_tool.name, "api");
370    }
371
372    #[test]
373    fn test_build_request_conversation_history() {
374        // Simulate a multi-step conversation
375        let messages = vec![
376            Message::user("What is 2+2?"),
377            Message::assistant("2+2 equals 4."),
378            Message::user("And what is 4*4?"),
379            Message::assistant("4*4 equals 16."),
380            Message::user("Thanks!"),
381            Message::assistant("You're welcome!"),
382        ];
383
384        let request = build_request(&messages, &[]);
385        assert_eq!(request.messages.len(), 6);
386    }
387
388    #[test]
389    fn test_build_request_with_tool_responses() {
390        let messages = vec![
391            Message::user("Calculate 5*5"),
392            Message::assistant_with_tool_calls(
393                "I'll calculate that for you.",
394                vec![ToolCall::new("call_1", "calc", json!({"expr": "5*5"}))],
395            ),
396            Message::tool("call_1", r#"{"result": 25}"#),
397            Message::assistant("5*5 equals 25."),
398        ];
399
400        let request = build_request(&messages, &[]);
401        assert_eq!(request.messages.len(), 4);
402    }
403
404    #[test]
405    fn test_user_message_convenience() {
406        let msg = user_message("Hello");
407        assert_eq!(msg.role, Role::User);
408        assert_eq!(msg.content, "Hello");
409    }
410
411    #[test]
412    fn test_assistant_message_convenience() {
413        let msg = assistant_message("Hi there");
414        assert_eq!(msg.role, Role::Assistant);
415        assert_eq!(msg.content, "Hi there");
416    }
417
418    #[test]
419    fn test_tool_response_with_complex_data() {
420        let result = ToolResult::success(
421            "api",
422            json!({
423                "status": 200,
424                "headers": {"Content-Type": "application/json"},
425                "body": {
426                    "users": [
427                        {"id": 1, "name": "Alice"},
428                        {"id": 2, "name": "Bob"}
429                    ]
430                }
431            }),
432        );
433
434        let msg = tool_response("call_api", &result);
435        assert!(msg.content.contains("users") || msg.content.contains("Alice"));
436    }
437}