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}