Skip to main content

tirea_agent_loop/engine/
convert.rs

1//! Pure functions for converting between tirea and genai types.
2
3use crate::contracts::runtime::tool_call::{Tool, ToolDescriptor, ToolResult};
4use crate::contracts::thread::{Message, Role, ToolCall};
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/// Apply prompt cache hints to a chat request.
72///
73/// Sets `CacheControl::Ephemeral` on the last system message in the request,
74/// which tells Anthropic to cache everything up to (and including) that message.
75/// This is a no-op for providers that don't support cache control.
76pub fn apply_prompt_cache_hints(request: &mut ChatRequest) {
77    // Find the last system message and mark it as the cache boundary.
78    if let Some(pos) = request
79        .messages
80        .iter()
81        .rposition(|m| matches!(m.role, genai::chat::ChatRole::System))
82    {
83        let msg = request.messages.remove(pos);
84        request
85            .messages
86            .insert(pos, msg.with_options(genai::chat::CacheControl::Ephemeral));
87    }
88}
89
90/// Create a user message (convenience function).
91pub fn user_message(content: impl Into<String>) -> Message {
92    Message::user(content)
93}
94
95/// Create an assistant message (convenience function).
96pub fn assistant_message(content: impl Into<String>) -> Message {
97    Message::assistant(content)
98}
99
100/// Create an assistant message with tool calls (convenience function).
101pub fn assistant_tool_calls(content: impl Into<String>, calls: Vec<ToolCall>) -> Message {
102    Message::assistant_with_tool_calls(content, calls)
103}
104
105/// Create a tool response message from ToolResult.
106pub fn tool_response(call_id: impl Into<String>, result: &ToolResult) -> Message {
107    let content = serde_json::to_string(result)
108        .unwrap_or_else(|_| result.message.clone().unwrap_or_default());
109    Message::tool(call_id, content)
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use serde_json::json;
116
117    // Mock tool for testing
118    struct MockTool;
119
120    #[async_trait::async_trait]
121    impl Tool for MockTool {
122        fn descriptor(&self) -> ToolDescriptor {
123            ToolDescriptor::new("mock", "Mock Tool", "A mock tool for testing").with_parameters(
124                json!({
125                    "type": "object",
126                    "properties": {
127                        "input": { "type": "string" }
128                    }
129                }),
130            )
131        }
132
133        async fn execute(
134            &self,
135            _args: serde_json::Value,
136            _ctx: &crate::contracts::ToolCallContext<'_>,
137        ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
138            Ok(ToolResult::success("mock", json!({"result": "ok"})))
139        }
140    }
141
142    #[test]
143    fn test_to_genai_tool() {
144        let desc = ToolDescriptor::new("calc", "Calculator", "Calculate expressions")
145            .with_parameters(json!({"type": "object"}));
146
147        let genai_tool = to_genai_tool(&desc);
148
149        assert_eq!(genai_tool.name.to_string(), "calc");
150        assert_eq!(
151            genai_tool.description.as_deref(),
152            Some("Calculate expressions")
153        );
154    }
155
156    #[test]
157    fn test_to_chat_message_user() {
158        let msg = Message::user("Hello");
159        let chat_msg = to_chat_message(&msg);
160
161        // ChatMessage doesn't expose role directly, but we can verify it was created
162        assert!(
163            format!("{:?}", chat_msg).contains("User")
164                || format!("{:?}", chat_msg).to_lowercase().contains("user")
165        );
166    }
167
168    #[test]
169    fn test_to_chat_message_assistant() {
170        let msg = Message::assistant("Hi there");
171        let _chat_msg = to_chat_message(&msg);
172        // Basic smoke test - conversion should not panic
173    }
174
175    #[test]
176    fn test_to_chat_message_assistant_with_tools() {
177        let calls = vec![ToolCall::new("call_1", "search", json!({"q": "rust"}))];
178        let msg = Message::assistant_with_tool_calls("Searching...", calls);
179        let _chat_msg = to_chat_message(&msg);
180        // Basic smoke test - conversion should not panic
181    }
182
183    #[test]
184    fn test_to_chat_message_tool() {
185        let msg = Message::tool("call_1", "Result: 42");
186        let _chat_msg = to_chat_message(&msg);
187        // Basic smoke test - conversion should not panic
188    }
189
190    #[test]
191    fn test_build_request_no_tools() {
192        let messages = vec![Message::user("Hello"), Message::assistant("Hi!")];
193
194        let request = build_request(&messages, &[]);
195
196        assert_eq!(request.messages.len(), 2);
197        assert!(request.tools.is_none());
198    }
199
200    #[test]
201    fn test_build_request_with_tools() {
202        let messages = vec![Message::user("Hello")];
203        let mock_tool = MockTool;
204        let tools: Vec<&dyn Tool> = vec![&mock_tool];
205
206        let request = build_request(&messages, &tools);
207
208        assert_eq!(request.messages.len(), 1);
209        assert!(request.tools.is_some());
210        assert_eq!(request.tools.as_ref().unwrap().len(), 1);
211    }
212
213    #[test]
214    fn test_tool_response_from_result() {
215        let result = ToolResult::success("calc", json!({"answer": 42}));
216        let msg = tool_response("call_1", &result);
217
218        assert_eq!(msg.role, Role::Tool);
219        assert_eq!(msg.tool_call_id.as_deref(), Some("call_1"));
220        assert!(msg.content.contains("42") || msg.content.contains("success"));
221    }
222
223    // Additional edge case tests
224
225    #[test]
226    fn test_to_chat_message_system() {
227        let msg = Message::system("You are a helpful assistant.");
228        let chat_msg = to_chat_message(&msg);
229
230        let debug_str = format!("{:?}", chat_msg);
231        assert!(debug_str.to_lowercase().contains("system") || !debug_str.is_empty());
232    }
233
234    #[test]
235    fn test_build_request_empty_messages() {
236        let messages: Vec<Message> = vec![];
237        let request = build_request(&messages, &[]);
238
239        assert!(request.messages.is_empty());
240    }
241
242    #[test]
243    fn test_build_request_multiple_tools() {
244        struct Tool1;
245        struct Tool2;
246        struct Tool3;
247
248        #[async_trait::async_trait]
249        impl Tool for Tool1 {
250            fn descriptor(&self) -> ToolDescriptor {
251                ToolDescriptor::new("tool1", "Tool 1", "First tool")
252            }
253            async fn execute(
254                &self,
255                _: serde_json::Value,
256                _: &crate::contracts::ToolCallContext<'_>,
257            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
258                Ok(ToolResult::success("tool1", json!({})))
259            }
260        }
261
262        #[async_trait::async_trait]
263        impl Tool for Tool2 {
264            fn descriptor(&self) -> ToolDescriptor {
265                ToolDescriptor::new("tool2", "Tool 2", "Second tool")
266            }
267            async fn execute(
268                &self,
269                _: serde_json::Value,
270                _: &crate::contracts::ToolCallContext<'_>,
271            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
272                Ok(ToolResult::success("tool2", json!({})))
273            }
274        }
275
276        #[async_trait::async_trait]
277        impl Tool for Tool3 {
278            fn descriptor(&self) -> ToolDescriptor {
279                ToolDescriptor::new("tool3", "Tool 3", "Third tool")
280            }
281            async fn execute(
282                &self,
283                _: serde_json::Value,
284                _: &crate::contracts::ToolCallContext<'_>,
285            ) -> Result<ToolResult, crate::contracts::runtime::tool_call::ToolError> {
286                Ok(ToolResult::success("tool3", json!({})))
287            }
288        }
289
290        let t1 = Tool1;
291        let t2 = Tool2;
292        let t3 = Tool3;
293        let tools: Vec<&dyn Tool> = vec![&t1, &t2, &t3];
294
295        let request = build_request(&[Message::user("test")], &tools);
296        assert_eq!(request.tools.as_ref().unwrap().len(), 3);
297    }
298
299    #[test]
300    fn test_to_chat_message_with_special_characters() {
301        let msg = Message::user(
302            "Hello! How are you?\n\nI have a question about \"quotes\" and 'apostrophes'.",
303        );
304        let _chat_msg = to_chat_message(&msg);
305        // Should not panic with special characters
306    }
307
308    #[test]
309    fn test_to_chat_message_with_unicode() {
310        let msg = Message::user("你好世界! 🌍 Привет мир! مرحبا بالعالم");
311        let _chat_msg = to_chat_message(&msg);
312        // Should handle unicode properly
313    }
314
315    #[test]
316    fn test_to_chat_message_with_empty_content() {
317        let msg = Message::user("");
318        let _chat_msg = to_chat_message(&msg);
319        // Should handle empty content
320    }
321
322    #[test]
323    fn test_to_chat_message_with_very_long_content() {
324        let long_content = "a".repeat(100_000);
325        let msg = Message::user(&long_content);
326        let _chat_msg = to_chat_message(&msg);
327        // Should handle very long content
328    }
329
330    #[test]
331    fn test_tool_response_from_error_result() {
332        let result = ToolResult::error("calc", "Division by zero");
333        let msg = tool_response("call_err", &result);
334
335        assert_eq!(msg.role, Role::Tool);
336        assert!(msg.content.contains("error") || msg.content.contains("Division"));
337    }
338
339    #[test]
340    fn test_tool_response_from_pending_result() {
341        let result = ToolResult::suspended("long_task", "Processing...");
342        let msg = tool_response("call_pending", &result);
343
344        assert_eq!(msg.role, Role::Tool);
345        assert!(msg.content.contains("pending") || msg.content.contains("Processing"));
346    }
347
348    #[test]
349    fn test_assistant_message_with_multiple_tool_calls() {
350        let calls = vec![
351            ToolCall::new("call_1", "search", json!({"q": "rust"})),
352            ToolCall::new("call_2", "calculate", json!({"expr": "1+1"})),
353            ToolCall::new("call_3", "format", json!({"text": "hello"})),
354        ];
355        let msg = assistant_tool_calls("I'll help you with multiple tasks.", calls);
356
357        assert_eq!(msg.role, Role::Assistant);
358        assert!(msg.tool_calls.is_some());
359        assert_eq!(msg.tool_calls.as_ref().unwrap().len(), 3);
360    }
361
362    #[test]
363    fn test_to_genai_tool_with_complex_schema() {
364        let desc =
365            ToolDescriptor::new("api", "API Call", "Make API requests").with_parameters(json!({
366                "type": "object",
367                "properties": {
368                    "method": {
369                        "type": "string",
370                        "enum": ["GET", "POST", "PUT", "DELETE"]
371                    },
372                    "url": {
373                        "type": "string",
374                        "format": "uri"
375                    },
376                    "headers": {
377                        "type": "object",
378                        "additionalProperties": { "type": "string" }
379                    },
380                    "body": {
381                        "type": "object"
382                    }
383                },
384                "required": ["method", "url"]
385            }));
386
387        let genai_tool = to_genai_tool(&desc);
388        assert_eq!(genai_tool.name.to_string(), "api");
389    }
390
391    #[test]
392    fn test_build_request_conversation_history() {
393        // Simulate a multi-step conversation
394        let messages = vec![
395            Message::user("What is 2+2?"),
396            Message::assistant("2+2 equals 4."),
397            Message::user("And what is 4*4?"),
398            Message::assistant("4*4 equals 16."),
399            Message::user("Thanks!"),
400            Message::assistant("You're welcome!"),
401        ];
402
403        let request = build_request(&messages, &[]);
404        assert_eq!(request.messages.len(), 6);
405    }
406
407    #[test]
408    fn test_build_request_with_tool_responses() {
409        let messages = vec![
410            Message::user("Calculate 5*5"),
411            Message::assistant_with_tool_calls(
412                "I'll calculate that for you.",
413                vec![ToolCall::new("call_1", "calc", json!({"expr": "5*5"}))],
414            ),
415            Message::tool("call_1", r#"{"result": 25}"#),
416            Message::assistant("5*5 equals 25."),
417        ];
418
419        let request = build_request(&messages, &[]);
420        assert_eq!(request.messages.len(), 4);
421    }
422
423    #[test]
424    fn test_user_message_convenience() {
425        let msg = user_message("Hello");
426        assert_eq!(msg.role, Role::User);
427        assert_eq!(msg.content, "Hello");
428    }
429
430    #[test]
431    fn test_assistant_message_convenience() {
432        let msg = assistant_message("Hi there");
433        assert_eq!(msg.role, Role::Assistant);
434        assert_eq!(msg.content, "Hi there");
435    }
436
437    #[test]
438    fn apply_prompt_cache_hints_marks_last_system_message() {
439        let messages = vec![
440            Message::system("System prompt"),
441            Message::system("Session context"),
442            Message::user("Hello"),
443            Message::assistant("Hi!"),
444        ];
445        let mut request = build_request(&messages, &[]);
446        apply_prompt_cache_hints(&mut request);
447
448        // Last system message (index 1) should have CacheControl::Ephemeral.
449        // First system message should not.
450        let debug_0 = format!("{:?}", request.messages[0]);
451        let debug_1 = format!("{:?}", request.messages[1]);
452        assert!(
453            !debug_0.contains("Ephemeral"),
454            "first system message should not have cache hint"
455        );
456        assert!(
457            debug_1.contains("Ephemeral"),
458            "last system message should have Ephemeral cache hint"
459        );
460        // Message count should be preserved.
461        assert_eq!(request.messages.len(), 4);
462    }
463
464    #[test]
465    fn apply_prompt_cache_hints_noop_without_system_messages() {
466        let messages = vec![Message::user("Hello"), Message::assistant("Hi!")];
467        let mut request = build_request(&messages, &[]);
468        let before = format!("{:?}", request.messages);
469        apply_prompt_cache_hints(&mut request);
470        let after = format!("{:?}", request.messages);
471        assert_eq!(
472            before, after,
473            "should be no-op when no system messages exist"
474        );
475    }
476
477    #[test]
478    fn apply_prompt_cache_hints_single_system_message() {
479        let messages = vec![Message::system("Only system"), Message::user("Hello")];
480        let mut request = build_request(&messages, &[]);
481        apply_prompt_cache_hints(&mut request);
482        let debug_0 = format!("{:?}", request.messages[0]);
483        assert!(
484            debug_0.contains("Ephemeral"),
485            "single system message should get cache hint"
486        );
487    }
488
489    #[test]
490    fn test_tool_response_with_complex_data() {
491        let result = ToolResult::success(
492            "api",
493            json!({
494                "status": 200,
495                "headers": {"Content-Type": "application/json"},
496                "body": {
497                    "users": [
498                        {"id": 1, "name": "Alice"},
499                        {"id": 2, "name": "Bob"}
500                    ]
501                }
502            }),
503        );
504
505        let msg = tool_response("call_api", &result);
506        assert!(msg.content.contains("users") || msg.content.contains("Alice"));
507    }
508}