Skip to main content

agentd/intel/
bedrock.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! Amazon Bedrock **Converse** API adapter. Pure translation, no I/O; the SigV4
3//! signing that authenticates the dial is a separate axis
4//! ([`crate::auth::aws`], applied by the transport in [`super::endpoints`]).
5//!
6//! Converse is Bedrock's provider-neutral chat surface, so agentd speaks ONE
7//! dialect to every Bedrock model (Anthropic, Llama, Titan, …). It differs from
8//! both in-binary dialects in ways the loop never sees:
9//!   * the model id rides the **URL path** (`/model/{modelId}/converse`), not the
10//!     body — so there is no `model` field here (see [`converse_path`]);
11//!   * content is a list of blocks keyed by *shape* (`{"text":…}`,
12//!     `{"toolUse":…}`, `{"toolResult":…}`) — no `type` tag;
13//!   * `system` is a block list, inference knobs live under `inferenceConfig`,
14//!     and tools under `toolConfig.tools[].toolSpec` with the JSON Schema nested
15//!     one level as `inputSchema.json`;
16//!   * Bedrock **validates strict user/assistant alternation**, so consecutive
17//!     same-role turns (notably the N tool results of one assistant turn) are
18//!     merged into a single message with N content blocks.
19//!
20//! Auth is SigV4 only (no bearer/api-key), so `token` is ignored here.
21
22use crate::wire::intel::{Message, Request, Response, StopReason, ToolCall, Usage};
23use serde_json::{Map, Value, json};
24
25/// A placeholder default path. Bedrock's real path is computed per-request from
26/// the model id ([`converse_path`]); this is only the host-only resolve-time
27/// fallback and is always overridden before a dial.
28pub const DEFAULT_PATH: &str = "/";
29
30/// The Converse request path for `model`: `/model/{modelId}/converse`. The model
31/// id is a single opaque path parameter, so it is fully percent-encoded
32/// (unreserved `A-Za-z0-9-._~` pass; everything else — notably the `:` of a
33/// versioned id like `…-v2:0`, and the `:`/`/` of an inference-profile ARN —
34/// becomes `%XX`). This exact string is BOTH sent on the wire and fed to the
35/// SigV4 signer, so the canonical URI the signature covers matches the
36/// request-target byte-for-byte (the signer does not re-encode).
37pub fn converse_path(model: &str) -> String {
38    format!("/model/{}/converse", encode_segment(model))
39}
40
41/// Percent-encode one path segment per RFC 3986, the way SigV4 canonicalisation
42/// requires:
43/// the unreserved set `A-Za-z0-9-._~` passes through, and every other byte —
44/// including `/` and `:` — becomes an uppercase `%XX` escape. Uppercase hex is
45/// mandatory: the signer compares the canonical URI byte-for-byte, so lowercase
46/// escapes would produce a signature mismatch.
47fn encode_segment(s: &str) -> String {
48    let mut out = String::with_capacity(s.len());
49    for &b in s.as_bytes() {
50        match b {
51            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
52                out.push(b as char)
53            }
54            _ => out.push_str(&format!("%{b:02X}")),
55        }
56    }
57    out
58}
59
60/// Build the Converse request body (JSON bytes) + headers. `token` is ignored —
61/// Bedrock authenticates by SigV4 (added by the transport), never a bearer.
62pub fn build_request(req: &Request, _token: Option<&str>) -> (Vec<u8>, Vec<(String, String)>) {
63    // System turns are hoisted into the top-level `system` block list.
64    let system: Vec<Value> = req
65        .messages
66        .iter()
67        .filter_map(|m| match m {
68            Message::System(s) if !s.is_empty() => Some(json!({"text": s})),
69            _ => None,
70        })
71        .collect();
72
73    let mut inference = Map::new();
74    inference.insert("maxTokens".into(), json!(req.max_tokens));
75    if let Some(t) = req.temperature {
76        inference.insert("temperature".into(), json!(t));
77    }
78
79    let mut body = Map::new();
80    body.insert("messages".into(), json!(messages_to_bedrock(&req.messages)));
81    if !system.is_empty() {
82        body.insert("system".into(), json!(system));
83    }
84    body.insert("inferenceConfig".into(), Value::Object(inference));
85    if !req.tools.is_empty() {
86        let tools: Vec<Value> = req
87            .tools
88            .iter()
89            .map(|t| {
90                json!({"toolSpec": {
91                    "name": t.name,
92                    "description": t.description,
93                    "inputSchema": {"json": t.input_schema},
94                }})
95            })
96            .collect();
97        body.insert("toolConfig".into(), json!({"tools": tools}));
98    }
99
100    let bytes = serde_json::to_vec(&Value::Object(body)).unwrap_or_default();
101    // content-type/accept are unsigned (SigV4 covers host;x-amz-date only), so
102    // they ride as ordinary headers; the signature is added by the transport.
103    let headers = vec![
104        ("content-type".to_string(), "application/json".to_string()),
105        ("accept".to_string(), "application/json".to_string()),
106    ];
107    (bytes, headers)
108}
109
110/// Translate neutral messages into Converse turns, **merging consecutive
111/// same-role turns** (Bedrock validates strict user/assistant alternation): the
112/// N tool results of one assistant turn collapse into a single user message with
113/// N `toolResult` blocks, and an assistant's text + tool-use blocks share one
114/// message.
115fn messages_to_bedrock(messages: &[Message]) -> Vec<Value> {
116    let mut turns: Vec<(&'static str, Vec<Value>)> = Vec::new();
117    let mut push = |role: &'static str, block: Value| match turns.last_mut() {
118        Some((r, blocks)) if *r == role => blocks.push(block),
119        _ => turns.push((role, vec![block])),
120    };
121    for m in messages {
122        match m {
123            Message::System(_) => {} // hoisted into `system`
124            Message::User(s) => push("user", json!({"text": s})),
125            Message::Assistant { text, tool_calls } => {
126                if let Some(t) = text.as_deref().filter(|t| !t.is_empty()) {
127                    push("assistant", json!({"text": t}));
128                }
129                for tc in tool_calls {
130                    push(
131                        "assistant",
132                        json!({"toolUse": {
133                            "toolUseId": tc.id,
134                            "name": tc.name,
135                            "input": tc.arguments,
136                        }}),
137                    );
138                }
139            }
140            Message::ToolResult {
141                id,
142                content,
143                is_error,
144            } => push(
145                "user",
146                json!({"toolResult": {
147                    "toolUseId": id,
148                    "content": [{"text": content}],
149                    "status": if *is_error { "error" } else { "success" },
150                }}),
151            ),
152        }
153    }
154    turns
155        .into_iter()
156        .map(|(role, content)| json!({"role": role, "content": content}))
157        .collect()
158}
159
160/// Parse a Converse response body into the neutral [`Response`]. Tolerant:
161/// missing usage → zero; an unknown stop reason → [`StopReason::Other`].
162pub fn parse_response(body: &[u8]) -> Result<Response, String> {
163    let v: Value =
164        serde_json::from_slice(body).map_err(|e| format!("intel: bad JSON response: {e}"))?;
165
166    // A 2xx Converse reply always carries `output.message`; a Bedrock error is a
167    // non-2xx `{"message": …}` surfaced upstream as `IntelError::Http` before we
168    // parse. Guard anyway so a stray error body reads clearly.
169    let message = v.pointer("/output/message");
170    if message.is_none()
171        && let Some(msg) = v.get("message").and_then(Value::as_str)
172    {
173        return Err(format!("intel: provider error: {msg}"));
174    }
175
176    let mut text_parts: Vec<String> = Vec::new();
177    let mut tool_calls = Vec::new();
178    if let Some(blocks) = message
179        .and_then(|m| m.get("content"))
180        .and_then(Value::as_array)
181    {
182        for b in blocks {
183            if let Some(t) = b.get("text").and_then(Value::as_str) {
184                text_parts.push(t.to_string());
185            } else if let Some(tu) = b.get("toolUse") {
186                tool_calls.push(ToolCall {
187                    id: tu
188                        .get("toolUseId")
189                        .and_then(Value::as_str)
190                        .unwrap_or("")
191                        .to_string(),
192                    name: tu
193                        .get("name")
194                        .and_then(Value::as_str)
195                        .unwrap_or("")
196                        .to_string(),
197                    arguments: tu.get("input").cloned().unwrap_or(Value::Null),
198                });
199            }
200        }
201    }
202
203    let stop_reason = match v.get("stopReason").and_then(Value::as_str) {
204        Some("end_turn") | Some("stop_sequence") => StopReason::EndTurn,
205        Some("tool_use") => StopReason::ToolUse,
206        Some("max_tokens") => StopReason::MaxTokens,
207        _ => StopReason::Other,
208    };
209
210    let usage = v.get("usage").map(|u| Usage {
211        input_tokens: u.get("inputTokens").and_then(Value::as_u64).unwrap_or(0),
212        output_tokens: u.get("outputTokens").and_then(Value::as_u64).unwrap_or(0),
213    });
214
215    let text = if text_parts.is_empty() {
216        None
217    } else {
218        Some(text_parts.join(""))
219    };
220    Ok(Response {
221        text,
222        tool_calls,
223        stop_reason,
224        usage: usage.unwrap_or_default(),
225    })
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::wire::intel::ToolDef;
232
233    #[test]
234    fn converse_path_encodes_the_model_id() {
235        // A versioned model id: the `:` becomes %3A (both wire + signature).
236        assert_eq!(
237            converse_path("anthropic.claude-3-5-sonnet-20241022-v2:0"),
238            "/model/anthropic.claude-3-5-sonnet-20241022-v2%3A0/converse"
239        );
240        // An inference-profile ARN: `:` and `/` both encode (single opaque param).
241        assert_eq!(
242            converse_path("arn:aws:bedrock:us-east-1::foundation-model/x"),
243            "/model/arn%3Aaws%3Abedrock%3Aus-east-1%3A%3Afoundation-model%2Fx/converse"
244        );
245        // A plain id (no reserved chars) is unchanged.
246        assert_eq!(
247            converse_path("amazon.titan-text-express-v1"),
248            "/model/amazon.titan-text-express-v1/converse"
249        );
250    }
251
252    fn req() -> Request {
253        Request {
254            model: "anthropic.claude-3-5-sonnet-20241022-v2:0".into(),
255            messages: vec![Message::system("be terse"), Message::user("hi")],
256            tools: vec![ToolDef {
257                name: "read_file".into(),
258                description: "read a file".into(),
259                input_schema: json!({"type": "object"}),
260            }],
261            max_tokens: 256,
262            temperature: Some(0.0),
263        }
264    }
265
266    #[test]
267    fn build_hoists_system_and_wraps_inference_and_tools() {
268        let (body, headers) = build_request(&req(), Some("ignored"));
269        let v: Value = serde_json::from_slice(&body).unwrap();
270        // No `model` in the body — it rides the URL path.
271        assert!(
272            v.get("model").is_none(),
273            "model must not be in the body: {v}"
274        );
275        assert_eq!(v["system"][0]["text"], "be terse");
276        assert_eq!(v["messages"][0]["role"], "user");
277        assert_eq!(v["messages"][0]["content"][0]["text"], "hi");
278        assert_eq!(v["inferenceConfig"]["maxTokens"], 256);
279        assert_eq!(v["inferenceConfig"]["temperature"], 0.0);
280        assert_eq!(v["toolConfig"]["tools"][0]["toolSpec"]["name"], "read_file");
281        assert_eq!(
282            v["toolConfig"]["tools"][0]["toolSpec"]["inputSchema"]["json"]["type"],
283            "object"
284        );
285        // No bearer/api-key header — Bedrock authenticates by SigV4 only.
286        assert!(
287            !headers
288                .iter()
289                .any(|(k, _)| k == "authorization" || k == "x-api-key"),
290            "no bearer header for Bedrock: {headers:?}"
291        );
292    }
293
294    #[test]
295    fn build_omits_toolconfig_when_no_tools() {
296        let mut r = req();
297        r.tools.clear();
298        let v: Value = serde_json::from_slice(&build_request(&r, None).0).unwrap();
299        assert!(
300            v.get("toolConfig").is_none(),
301            "empty tools ⇒ no toolConfig: {v}"
302        );
303    }
304
305    #[test]
306    fn consecutive_tool_results_merge_into_one_user_turn() {
307        // Two tool calls in one assistant turn → two ToolResults. Bedrock needs
308        // strict alternation: they must collapse into a SINGLE user message with
309        // two toolResult blocks (not two consecutive user messages).
310        let r = Request {
311            model: "m".into(),
312            messages: vec![
313                Message::user("go"),
314                Message::Assistant {
315                    text: Some("working".into()),
316                    tool_calls: vec![
317                        ToolCall {
318                            id: "t1".into(),
319                            name: "a".into(),
320                            arguments: json!({}),
321                        },
322                        ToolCall {
323                            id: "t2".into(),
324                            name: "b".into(),
325                            arguments: json!({}),
326                        },
327                    ],
328                },
329                Message::tool_result("t1", "r1", false),
330                Message::tool_result("t2", "r2", true),
331            ],
332            tools: vec![],
333            max_tokens: 8,
334            temperature: None,
335        };
336        let v: Value = serde_json::from_slice(&build_request(&r, None).0).unwrap();
337        let msgs = v["messages"].as_array().unwrap();
338        // user("go") | assistant(text+2 toolUse) | user(2 toolResult) == 3 turns.
339        assert_eq!(msgs.len(), 3, "roles must alternate: {v}");
340        assert_eq!(msgs[1]["role"], "assistant");
341        assert_eq!(msgs[1]["content"][0]["text"], "working");
342        assert_eq!(msgs[1]["content"][1]["toolUse"]["toolUseId"], "t1");
343        assert_eq!(msgs[1]["content"][2]["toolUse"]["name"], "b");
344        assert_eq!(msgs[2]["role"], "user");
345        assert_eq!(msgs[2]["content"][0]["toolResult"]["toolUseId"], "t1");
346        assert_eq!(msgs[2]["content"][0]["toolResult"]["status"], "success");
347        assert_eq!(msgs[2]["content"][1]["toolResult"]["status"], "error");
348    }
349
350    #[test]
351    fn parse_text_and_tool_use() {
352        let body = br#"{"output":{"message":{"role":"assistant","content":[
353            {"text":"hi"},
354            {"toolUse":{"toolUseId":"tu_1","name":"read","input":{"p":1}}}
355        ]}},"stopReason":"tool_use","usage":{"inputTokens":5,"outputTokens":7,"totalTokens":12}}"#;
356        let r = parse_response(body).unwrap();
357        assert_eq!(r.text.as_deref(), Some("hi"));
358        assert_eq!(r.tool_calls[0].id, "tu_1");
359        assert_eq!(r.tool_calls[0].name, "read");
360        assert_eq!(r.tool_calls[0].arguments["p"], 1);
361        assert_eq!(r.stop_reason, StopReason::ToolUse);
362        assert_eq!(r.usage.total(), 12);
363    }
364
365    #[test]
366    fn parse_final_text_and_stop_reasons() {
367        let body = br#"{"output":{"message":{"content":[{"text":"done"}]}},"stopReason":"end_turn","usage":{"inputTokens":10,"outputTokens":2}}"#;
368        let r = parse_response(body).unwrap();
369        assert_eq!(r.text.as_deref(), Some("done"));
370        assert_eq!(r.stop_reason, StopReason::EndTurn);
371        assert!(!r.wants_tools());
372        // max_tokens maps through.
373        let body =
374            br#"{"output":{"message":{"content":[{"text":"x"}]}},"stopReason":"max_tokens"}"#;
375        assert_eq!(
376            parse_response(body).unwrap().stop_reason,
377            StopReason::MaxTokens
378        );
379    }
380}