Skip to main content

swink_agent/
convert.rs

1//! Shared message conversion utilities for LLM adapters.
2//!
3//! Provides a [`MessageConverter`] trait that each adapter implements to supply
4//! format-specific conversion logic, while the generic [`convert_messages`]
5//! function handles the common iteration and pattern matching over
6//! [`AgentMessage`] / [`LlmMessage`] variants.
7
8use std::sync::Arc;
9
10use serde_json::Value;
11
12use crate::AgentTool;
13use crate::types::{AgentMessage, AssistantMessage, LlmMessage, ToolResultMessage, UserMessage};
14
15// ─── MessageConverter trait ─────────────────────────────────────────────────
16
17/// Callbacks for provider-specific message conversion.
18///
19/// Each adapter implements this trait so the shared [`convert_messages`]
20/// function can build the provider's message list without duplicating the
21/// iteration / pattern-matching boilerplate.
22pub trait MessageConverter {
23    /// The provider-specific message type (e.g. `OllamaMessage`, `OpenAiMessage`).
24    type Message;
25
26    /// Optionally produce a system message from the system prompt.
27    /// Return `None` if the provider handles system prompts out-of-band
28    /// (e.g. Anthropic uses a top-level `system` field).
29    fn system_message(system_prompt: &str) -> Option<Self::Message>;
30
31    /// Convert a user message.
32    fn user_message(user: &UserMessage) -> Self::Message;
33
34    /// Convert an assistant message.
35    fn assistant_message(assistant: &AssistantMessage) -> Self::Message;
36
37    /// Convert a tool-result message.
38    fn tool_result_message(result: &ToolResultMessage) -> Self::Message;
39}
40
41/// Generic message conversion that iterates [`AgentMessage`] values, skips
42/// non-LLM variants, and delegates to the [`MessageConverter`] implementation
43/// for format-specific construction.
44pub fn convert_messages<C: MessageConverter>(
45    messages: &[AgentMessage],
46    system_prompt: &str,
47) -> Vec<C::Message> {
48    let mut result = Vec::new();
49
50    if !system_prompt.is_empty()
51        && let Some(sys) = C::system_message(system_prompt)
52    {
53        result.push(sys);
54    }
55
56    for msg in messages {
57        let AgentMessage::Llm(llm) = msg else {
58            continue;
59        };
60        match llm {
61            LlmMessage::User(user) => result.push(C::user_message(user)),
62            LlmMessage::Assistant(assistant) => result.push(C::assistant_message(assistant)),
63            LlmMessage::ToolResult(tool_result) => {
64                result.push(C::tool_result_message(tool_result));
65            }
66        }
67    }
68
69    result
70}
71
72// ─── Tool schema extraction ─────────────────────────────────────────────────
73
74/// Common tool metadata extracted from [`AgentTool`] instances.
75///
76/// Used by adapters to avoid duplicating the `name` / `description` /
77/// `parameters` mapping before wrapping in provider-specific types.
78pub struct ToolSchema {
79    pub name: String,
80    pub description: String,
81    pub parameters: Value,
82}
83
84/// Extract tool metadata from a slice of [`AgentTool`] trait objects.
85pub fn extract_tool_schemas(tools: &[Arc<dyn AgentTool>]) -> Vec<ToolSchema> {
86    tools
87        .iter()
88        .map(|t| ToolSchema {
89            name: t.name().to_string(),
90            description: t.description().to_string(),
91            parameters: t.parameters_schema().clone(),
92        })
93        .collect()
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::types::{
100        AgentMessage, AssistantMessage, ContentBlock, Cost, LlmMessage, StopReason,
101        ToolResultMessage, Usage, UserMessage,
102    };
103
104    // ── Test converter ──────────────────────────────────────────────────
105
106    #[derive(Debug, PartialEq)]
107    struct TestMessage {
108        role: String,
109        content: String,
110    }
111
112    struct TestConverter;
113
114    impl MessageConverter for TestConverter {
115        type Message = TestMessage;
116
117        fn system_message(prompt: &str) -> Option<Self::Message> {
118            Some(TestMessage {
119                role: "system".to_string(),
120                content: prompt.to_string(),
121            })
122        }
123
124        fn user_message(msg: &UserMessage) -> Self::Message {
125            let text = ContentBlock::extract_text(&msg.content);
126            TestMessage {
127                role: "user".to_string(),
128                content: text,
129            }
130        }
131
132        fn assistant_message(msg: &AssistantMessage) -> Self::Message {
133            let text = ContentBlock::extract_text(&msg.content);
134            TestMessage {
135                role: "assistant".to_string(),
136                content: text,
137            }
138        }
139
140        fn tool_result_message(msg: &ToolResultMessage) -> Self::Message {
141            let text = ContentBlock::extract_text(&msg.content);
142            TestMessage {
143                role: "tool".to_string(),
144                content: text,
145            }
146        }
147    }
148
149    /// A converter that returns `None` for system messages.
150    struct NoSystemConverter;
151
152    impl MessageConverter for NoSystemConverter {
153        type Message = TestMessage;
154
155        fn system_message(_prompt: &str) -> Option<Self::Message> {
156            None
157        }
158
159        fn user_message(msg: &UserMessage) -> Self::Message {
160            TestConverter::user_message(msg)
161        }
162
163        fn assistant_message(msg: &AssistantMessage) -> Self::Message {
164            TestConverter::assistant_message(msg)
165        }
166
167        fn tool_result_message(msg: &ToolResultMessage) -> Self::Message {
168            TestConverter::tool_result_message(msg)
169        }
170    }
171
172    // ── Helpers ─────────────────────────────────────────────────────────
173
174    fn make_user(text: &str) -> AgentMessage {
175        AgentMessage::Llm(LlmMessage::User(UserMessage {
176            content: vec![ContentBlock::Text {
177                text: text.to_string(),
178            }],
179            timestamp: 0,
180            cache_hint: None,
181        }))
182    }
183
184    fn make_assistant(text: &str) -> AgentMessage {
185        AgentMessage::Llm(LlmMessage::Assistant(AssistantMessage {
186            content: vec![ContentBlock::Text {
187                text: text.to_string(),
188            }],
189            provider: String::new(),
190            model_id: String::new(),
191            usage: Usage::default(),
192            cost: Cost::default(),
193            stop_reason: StopReason::Stop,
194            error_message: None,
195            error_kind: None,
196            timestamp: 0,
197            cache_hint: None,
198        }))
199    }
200
201    fn make_tool_result(text: &str) -> AgentMessage {
202        AgentMessage::Llm(LlmMessage::ToolResult(ToolResultMessage {
203            tool_call_id: "tc1".to_string(),
204            content: vec![ContentBlock::Text {
205                text: text.to_string(),
206            }],
207            is_error: false,
208            timestamp: 0,
209            details: serde_json::Value::Null,
210            cache_hint: None,
211        }))
212    }
213
214    // ── convert_messages tests ──────────────────────────────────────────
215
216    #[test]
217    fn convert_empty_messages_no_system() {
218        let result = convert_messages::<TestConverter>(&[], "");
219        assert!(result.is_empty());
220    }
221
222    #[test]
223    fn convert_system_prompt_only() {
224        let result = convert_messages::<TestConverter>(&[], "test prompt");
225        assert_eq!(result.len(), 1);
226        assert_eq!(
227            result[0],
228            TestMessage {
229                role: "system".to_string(),
230                content: "test prompt".to_string(),
231            }
232        );
233    }
234
235    #[test]
236    fn convert_user_message_included() {
237        let messages = vec![make_user("hello")];
238        let result = convert_messages::<TestConverter>(&messages, "");
239        assert_eq!(result.len(), 1);
240        assert_eq!(
241            result[0],
242            TestMessage {
243                role: "user".to_string(),
244                content: "hello".to_string(),
245            }
246        );
247    }
248
249    #[test]
250    fn convert_assistant_message_included() {
251        let messages = vec![make_assistant("hi there")];
252        let result = convert_messages::<TestConverter>(&messages, "");
253        assert_eq!(result.len(), 1);
254        assert_eq!(
255            result[0],
256            TestMessage {
257                role: "assistant".to_string(),
258                content: "hi there".to_string(),
259            }
260        );
261    }
262
263    #[test]
264    fn convert_tool_result_message_included() {
265        let messages = vec![make_tool_result("result data")];
266        let result = convert_messages::<TestConverter>(&messages, "");
267        assert_eq!(result.len(), 1);
268        assert_eq!(
269            result[0],
270            TestMessage {
271                role: "tool".to_string(),
272                content: "result data".to_string(),
273            }
274        );
275    }
276
277    #[test]
278    fn convert_mixed_messages() {
279        let messages = vec![
280            make_user("question"),
281            make_assistant("answer"),
282            make_tool_result("tool output"),
283        ];
284        let result = convert_messages::<TestConverter>(&messages, "sys");
285        assert_eq!(result.len(), 4);
286        assert_eq!(result[0].role, "system");
287        assert_eq!(result[1].role, "user");
288        assert_eq!(result[2].role, "assistant");
289        assert_eq!(result[3].role, "tool");
290    }
291
292    #[test]
293    fn convert_skips_custom_messages() {
294        use std::any::Any;
295
296        #[derive(Debug)]
297        struct MyCustom;
298        impl crate::types::CustomMessage for MyCustom {
299            fn as_any(&self) -> &dyn Any {
300                self
301            }
302        }
303
304        let messages = vec![
305            make_user("before"),
306            AgentMessage::Custom(Box::new(MyCustom)),
307            make_user("after"),
308        ];
309        let result = convert_messages::<TestConverter>(&messages, "");
310        assert_eq!(result.len(), 2);
311        assert_eq!(result[0].content, "before");
312        assert_eq!(result[1].content, "after");
313    }
314
315    #[test]
316    fn convert_no_system_when_converter_returns_none() {
317        let messages = vec![make_user("hello")];
318        let result = convert_messages::<NoSystemConverter>(&messages, "ignored prompt");
319        assert_eq!(result.len(), 1);
320        assert_eq!(result[0].role, "user");
321    }
322}