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    repair_orphan_tool_uses(&mut api_messages);
91    (system_parts, api_messages)
92}
93
94/// Convert crate-internal [`ToolDefinition`]s into Bedrock `toolConfig.tools`
95/// entries.
96///
97/// # Examples
98///
99/// ```rust
100/// use codetether_agent::provider::bedrock::convert_tools;
101/// use codetether_agent::provider::ToolDefinition;
102/// use serde_json::json;
103///
104/// let t = vec![ToolDefinition {
105///     name: "ls".into(),
106///     description: "List files".into(),
107///     parameters: json!({"type":"object"}),
108/// }];
109/// let out = convert_tools(&t);
110/// assert_eq!(out[0]["toolSpec"]["description"], "List files");
111/// ```
112pub fn convert_tools(tools: &[ToolDefinition]) -> Vec<Value> {
113    tools
114        .iter()
115        .map(|t| {
116            json!({
117                "toolSpec": {
118                    "name": t.name,
119                    "description": t.description,
120                    "inputSchema": {
121                        "json": t.parameters
122                    }
123                }
124            })
125        })
126        .collect()
127}
128
129fn append_system(msg: &Message, system_parts: &mut Vec<Value>) {
130    let text: String = msg
131        .content
132        .iter()
133        .filter_map(|p| match p {
134            ContentPart::Text { text } => Some(text.clone()),
135            _ => None,
136        })
137        .collect::<Vec<_>>()
138        .join("\n");
139    if !text.trim().is_empty() {
140        system_parts.push(json!({"text": text}));
141    }
142}
143
144fn append_user(msg: &Message, api_messages: &mut Vec<Value>) {
145    let mut content_parts: Vec<Value> = Vec::new();
146    for part in &msg.content {
147        if let ContentPart::Text { text } = part
148            && !text.trim().is_empty()
149        {
150            content_parts.push(json!({"text": text}));
151        }
152    }
153    if content_parts.is_empty() {
154        return;
155    }
156    if let Some(last) = api_messages.last_mut()
157        && last.get("role").and_then(|r| r.as_str()) == Some("user")
158        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
159    {
160        arr.extend(content_parts);
161        return;
162    }
163    api_messages.push(json!({
164        "role": "user",
165        "content": content_parts
166    }));
167}
168
169fn append_assistant(msg: &Message, api_messages: &mut Vec<Value>) {
170    let mut content_parts: Vec<Value> = Vec::new();
171    for part in &msg.content {
172        match part {
173            ContentPart::Text { text } => {
174                if !text.trim().is_empty() {
175                    content_parts.push(json!({"text": text}));
176                }
177            }
178            ContentPart::ToolCall {
179                id,
180                name,
181                arguments,
182                ..
183            } => {
184                let input: Value =
185                    serde_json::from_str(arguments).unwrap_or_else(|_| json!({"raw": arguments}));
186                content_parts.push(json!({
187                    "toolUse": {
188                        "toolUseId": id,
189                        "name": name,
190                        "input": input
191                    }
192                }));
193            }
194            _ => {}
195        }
196    }
197    // Bedrock rejects whitespace-only text blocks; drop empty assistant turns.
198    if content_parts.is_empty() {
199        return;
200    }
201    if let Some(last) = api_messages.last_mut()
202        && last.get("role").and_then(|r| r.as_str()) == Some("assistant")
203        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
204    {
205        arr.extend(content_parts);
206        return;
207    }
208    api_messages.push(json!({
209        "role": "assistant",
210        "content": content_parts
211    }));
212}
213
214fn append_tool(msg: &Message, api_messages: &mut Vec<Value>) {
215    let mut content_parts: Vec<Value> = Vec::new();
216    for part in &msg.content {
217        if let ContentPart::ToolResult {
218            tool_call_id,
219            content,
220        } = part
221        {
222            let content = if content.trim().is_empty() {
223                "(empty tool result)".to_string()
224            } else {
225                content.clone()
226            };
227            content_parts.push(json!({
228                "toolResult": {
229                    "toolUseId": tool_call_id,
230                    "content": [{"text": content}],
231                    "status": "success"
232                }
233            }));
234        }
235    }
236    if content_parts.is_empty() {
237        return;
238    }
239    if let Some(last) = api_messages.last_mut()
240        && last.get("role").and_then(|r| r.as_str()) == Some("user")
241        && let Some(arr) = last.get_mut("content").and_then(|c| c.as_array_mut())
242    {
243        arr.extend(content_parts);
244        return;
245    }
246    api_messages.push(json!({
247        "role": "user",
248        "content": content_parts
249    }));
250}
251
252/// Synthesize `toolResult` blocks for every assistant `toolUse` whose
253/// `toolUseId` is not matched in the following user turn.
254///
255/// Bedrock strictly requires that every assistant `toolUse` be paired
256/// with a `toolResult` (same `toolUseId`) in the immediately following
257/// user message. If the session was interrupted (stream error, crash,
258/// network drop, `continue`-after-failure), the persisted transcript
259/// can contain orphan `toolUse` blocks that make every subsequent
260/// request fail with:
261///
262/// ```text
263/// Expected toolResult blocks at messages.X.content for the following Ids: ...
264/// ```
265///
266/// This repair pass scans each assistant message, collects the
267/// `toolUseId`s declared in it, inspects the next message (or inserts
268/// a synthetic `user` message if the assistant was last), and appends
269/// a `toolResult` with `status: "error"` and a short text payload for
270/// each missing id. This keeps the model moving forward instead of
271/// hard-failing on every retry.
272fn repair_orphan_tool_uses(api_messages: &mut Vec<Value>) {
273    let mut i = 0;
274    while i < api_messages.len() {
275        if api_messages[i].get("role").and_then(Value::as_str) != Some("assistant") {
276            i += 1;
277            continue;
278        }
279        let declared_ids: Vec<String> = api_messages[i]
280            .get("content")
281            .and_then(Value::as_array)
282            .map(|arr| {
283                arr.iter()
284                    .filter_map(|p| {
285                        p.get("toolUse")
286                            .and_then(|tu| tu.get("toolUseId"))
287                            .and_then(Value::as_str)
288                            .map(String::from)
289                    })
290                    .collect()
291            })
292            .unwrap_or_default();
293        if declared_ids.is_empty() {
294            i += 1;
295            continue;
296        }
297
298        let satisfied_ids: Vec<String> = api_messages
299            .get(i + 1)
300            .filter(|m| m.get("role").and_then(Value::as_str) == Some("user"))
301            .and_then(|m| m.get("content").and_then(Value::as_array))
302            .map(|arr| {
303                arr.iter()
304                    .filter_map(|p| {
305                        p.get("toolResult")
306                            .and_then(|tr| tr.get("toolUseId"))
307                            .and_then(Value::as_str)
308                            .map(String::from)
309                    })
310                    .collect()
311            })
312            .unwrap_or_default();
313
314        let missing: Vec<String> = declared_ids
315            .into_iter()
316            .filter(|id| !satisfied_ids.contains(id))
317            .collect();
318        if missing.is_empty() {
319            i += 1;
320            continue;
321        }
322
323        let synthetic: Vec<Value> = missing
324            .iter()
325            .map(|id| {
326                json!({
327                    "toolResult": {
328                        "toolUseId": id,
329                        "content": [{"text": "(tool call interrupted; no result recorded)"}],
330                        "status": "error"
331                    }
332                })
333            })
334            .collect();
335
336        let next_is_user = api_messages
337            .get(i + 1)
338            .and_then(|m| m.get("role").and_then(Value::as_str))
339            == Some("user");
340
341        if next_is_user {
342            if let Some(arr) = api_messages[i + 1]
343                .get_mut("content")
344                .and_then(Value::as_array_mut)
345            {
346                let mut merged = synthetic;
347                merged.extend(arr.drain(..));
348                *arr = merged;
349            }
350        } else {
351            api_messages.insert(
352                i + 1,
353                json!({
354                    "role": "user",
355                    "content": synthetic
356                }),
357            );
358        }
359        i += 1;
360    }
361}
362
363#[cfg(test)]
364mod repair_tests {
365    use super::*;
366
367    #[test]
368    fn synthesizes_missing_tool_result_when_assistant_is_last() {
369        let mut msgs = vec![json!({
370            "role": "assistant",
371            "content": [{"toolUse": {"toolUseId": "call_x", "name": "t", "input": {}}}]
372        })];
373        repair_orphan_tool_uses(&mut msgs);
374        assert_eq!(msgs.len(), 2);
375        assert_eq!(msgs[1]["role"], "user");
376        assert_eq!(msgs[1]["content"][0]["toolResult"]["toolUseId"], "call_x");
377        assert_eq!(msgs[1]["content"][0]["toolResult"]["status"], "error");
378    }
379
380    #[test]
381    fn prepends_missing_result_into_existing_user_turn() {
382        let mut msgs = vec![
383            json!({
384                "role": "assistant",
385                "content": [{"toolUse": {"toolUseId": "call_a", "name": "t", "input": {}}}]
386            }),
387            json!({
388                "role": "user",
389                "content": [{"text": "continue"}]
390            }),
391        ];
392        repair_orphan_tool_uses(&mut msgs);
393        assert_eq!(msgs.len(), 2);
394        assert_eq!(msgs[1]["content"][0]["toolResult"]["toolUseId"], "call_a");
395        assert_eq!(msgs[1]["content"][1]["text"], "continue");
396    }
397
398    #[test]
399    fn leaves_already_paired_tool_uses_alone() {
400        let before = vec![
401            json!({
402                "role": "assistant",
403                "content": [{"toolUse": {"toolUseId": "call_ok", "name": "t", "input": {}}}]
404            }),
405            json!({
406                "role": "user",
407                "content": [{"toolResult": {"toolUseId": "call_ok", "content": [{"text": "ok"}], "status": "success"}}]
408            }),
409        ];
410        let mut after = before.clone();
411        repair_orphan_tool_uses(&mut after);
412        assert_eq!(before, after);
413    }
414
415    #[test]
416    fn handles_multiple_missing_ids_in_one_assistant_turn() {
417        let mut msgs = vec![json!({
418            "role": "assistant",
419            "content": [
420                {"toolUse": {"toolUseId": "call_1", "name": "t", "input": {}}},
421                {"toolUse": {"toolUseId": "call_2", "name": "t", "input": {}}}
422            ]
423        })];
424        repair_orphan_tool_uses(&mut msgs);
425        assert_eq!(msgs[1]["content"].as_array().unwrap().len(), 2);
426        assert_eq!(msgs[1]["content"][0]["toolResult"]["toolUseId"], "call_1");
427        assert_eq!(msgs[1]["content"][1]["toolResult"]["toolUseId"], "call_2");
428    }
429}