Skip to main content

codetether_agent/provider/bedrock/
convert.rs

1//! Convert the crate's generic [`Message`] format to the Bedrock Converse
2//! API's JSON schema, and back-convert tool definitions.
3//!
4//! Bedrock Converse requires:
5//! - A separate top-level `system` array for system prompts.
6//! - Strict alternation of `user` / `assistant` messages.
7//! - Assistant tool-use blocks with `toolUse` objects (`input` as an object).
8//! - Tool results appear in the *next user* message as `toolResult` blocks.
9//!
10//! # Examples
11//!
12//! ```rust
13//! use codetether_agent::provider::bedrock::{convert_messages, convert_tools};
14//! use codetether_agent::provider::{ContentPart, Message, Role, ToolDefinition};
15//! use serde_json::json;
16//!
17//! let msgs = vec![
18//!     Message {
19//!         role: Role::System,
20//!         content: vec![ContentPart::Text { text: "You are helpful.".into() }],
21//!     },
22//!     Message {
23//!         role: Role::User,
24//!         content: vec![ContentPart::Text { text: "hi".into() }],
25//!     },
26//! ];
27//! let (system, api_msgs) = convert_messages(&msgs);
28//! assert_eq!(system.len(), 1);
29//! assert_eq!(api_msgs.len(), 1);
30//! assert_eq!(api_msgs[0]["role"], "user");
31//!
32//! let tools = vec![ToolDefinition {
33//!     name: "echo".into(),
34//!     description: "Echo text".into(),
35//!     parameters: json!({"type":"object"}),
36//! }];
37//! let converted = convert_tools(&tools);
38//! assert_eq!(converted[0]["toolSpec"]["name"], "echo");
39//! ```
40
41use crate::provider::{ContentPart, Message, Role, ToolDefinition};
42use serde_json::{Value, json};
43
44/// Convert generic [`Message`]s to Bedrock Converse API format.
45///
46/// IMPORTANT: Bedrock requires strict role alternation (user/assistant).
47/// Consecutive [`Role::Tool`] messages are merged into a single `"user"`
48/// message so all `toolResult` blocks for a given assistant turn appear
49/// together. Consecutive same-role messages are also merged to prevent
50/// validation errors.
51///
52/// # Arguments
53///
54/// * `messages` — The crate-internal chat transcript to send.
55///
56/// # Returns
57///
58/// A tuple `(system_parts, api_messages)`:
59/// - `system_parts`: objects suitable for the top-level `"system"` array.
60/// - `api_messages`: objects suitable for the top-level `"messages"` array.
61///
62/// # Examples
63///
64/// ```rust
65/// use codetether_agent::provider::bedrock::convert_messages;
66/// use codetether_agent::provider::{ContentPart, Message, Role};
67///
68/// let msgs = vec![Message {
69///     role: Role::User,
70///     content: vec![ContentPart::Text { text: "hello".into() }],
71/// }];
72/// let (system, api_msgs) = convert_messages(&msgs);
73/// assert!(system.is_empty());
74/// assert_eq!(api_msgs.len(), 1);
75/// assert_eq!(api_msgs[0]["content"][0]["text"], "hello");
76/// ```
77pub fn convert_messages(messages: &[Message]) -> (Vec<Value>, Vec<Value>) {
78    let mut system_parts: Vec<Value> = Vec::new();
79    let mut api_messages: Vec<Value> = Vec::new();
80
81    for msg in messages {
82        match msg.role {
83            Role::System => append_system(msg, &mut system_parts),
84            Role::User => append_user(msg, &mut api_messages),
85            Role::Assistant => append_assistant(msg, &mut api_messages),
86            Role::Tool => append_tool(msg, &mut api_messages),
87        }
88    }
89
90    (system_parts, api_messages)
91}
92
93/// Convert crate-internal [`ToolDefinition`]s into Bedrock `toolConfig.tools`
94/// entries.
95///
96/// # Examples
97///
98/// ```rust
99/// use codetether_agent::provider::bedrock::convert_tools;
100/// use codetether_agent::provider::ToolDefinition;
101/// use serde_json::json;
102///
103/// let t = vec![ToolDefinition {
104///     name: "ls".into(),
105///     description: "List files".into(),
106///     parameters: json!({"type":"object"}),
107/// }];
108/// let out = convert_tools(&t);
109/// assert_eq!(out[0]["toolSpec"]["description"], "List files");
110/// ```
111pub fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
112    tools
113        .iter()
114        .map(|t| {
115            json!({
116                "toolSpec": {
117                    "name": t.name,
118                    "description": t.description,
119                    "inputSchema": {
120                        "json": t.parameters
121                    }
122                }
123            })
124        })
125        .collect()
126}
127
128fn append_system(msg: &Message, system_parts: &mut Vec<Value>) {
129    let text: String = msg
130        .content
131        .iter()
132        .filter_map(|p| match p {
133            ContentPart::Text { text } => Some(text.clone()),
134            _ => None,
135        })
136        .collect::<Vec<_>>()
137        .join("\n");
138    if !text.trim().is_empty() {
139        system_parts.push(json!({"text": text}));
140    }
141}
142
143fn append_user(msg: &Message, api_messages: &mut Vec<Value>) {
144    let mut content_parts: Vec<Value> = Vec::new();
145    for part in &msg.content {
146        if let ContentPart::Text { text } = part
147            && !text.trim().is_empty()
148        {
149            content_parts.push(json!({"text": text}));
150        }
151    }
152    if content_parts.is_empty() {
153        return;
154    }
155    if let Some(last) = api_messages.last_mut()
156        && last.get("role").and_then(|r| r.as_str()) == Some("user")
157        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
158    {
159        arr.extend(content_parts);
160        return;
161    }
162    api_messages.push(json!({
163        "role": "user",
164        "content": content_parts
165    }));
166}
167
168fn append_assistant(msg: &Message, api_messages: &mut Vec<Value>) {
169    let mut content_parts: Vec<Value> = Vec::new();
170    for part in &msg.content {
171        match part {
172            ContentPart::Text { text } => {
173                if !text.trim().is_empty() {
174                    content_parts.push(json!({"text": text}));
175                }
176            }
177            ContentPart::ToolCall {
178                id,
179                name,
180                arguments,
181                ..
182            } => {
183                let input: Value =
184                    serde_json::from_str(arguments).unwrap_or_else(|_| json!({"raw": arguments}));
185                content_parts.push(json!({
186                    "toolUse": {
187                        "toolUseId": id,
188                        "name": name,
189                        "input": input
190                    }
191                }));
192            }
193            _ => {}
194        }
195    }
196    // Bedrock rejects whitespace-only text blocks; drop empty assistant turns.
197    if content_parts.is_empty() {
198        return;
199    }
200    if let Some(last) = api_messages.last_mut()
201        && last.get("role").and_then(|r| r.as_str()) == Some("assistant")
202        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
203    {
204        arr.extend(content_parts);
205        return;
206    }
207    api_messages.push(json!({
208        "role": "assistant",
209        "content": content_parts
210    }));
211}
212
213fn append_tool(msg: &Message, api_messages: &mut Vec<Value>) {
214    let mut content_parts: Vec<Value> = Vec::new();
215    for part in &msg.content {
216        if let ContentPart::ToolResult {
217            tool_call_id,
218            content,
219        } = part
220        {
221            let content = if content.trim().is_empty() {
222                "(empty tool result)".to_string()
223            } else {
224                content.clone()
225            };
226            content_parts.push(json!({
227                "toolResult": {
228                    "toolUseId": tool_call_id,
229                    "content": [{"text": content}],
230                    "status": "success"
231                }
232            }));
233        }
234    }
235    if content_parts.is_empty() {
236        return;
237    }
238    if let Some(last) = api_messages.last_mut()
239        && last.get("role").and_then(|r| r.as_str()) == Some("user")
240        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
241    {
242        arr.extend(content_parts);
243        return;
244    }
245    api_messages.push(json!({
246        "role": "user",
247        "content": content_parts
248    }));
249}