Filter unsupported reasoning from Bedrock request history
This commit is contained in:
@@ -223,6 +223,7 @@ Key invariants:
|
|||||||
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
|
- `recall_tool_history` excludes earlier calls to itself; archived tool results remain searchable by query or exact `tool_use_id`
|
||||||
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
|
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
|
||||||
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
|
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
|
||||||
|
- Bedrock requests project reasoning history for the selected foundation model: omit unsigned response-only reasoning and signed blocks from incompatible models while preserving Claude's complete signed blocks for tool continuations. Keep durable/UI history intact, remove messages emptied by projection, and use the discovered foundation model when invocation targets an opaque inference-profile ARN.
|
||||||
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
||||||
- Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes
|
- Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes
|
||||||
- Direct-provider progressive compaction runs in a separate request alongside foreground work. Start early against the smaller usable history budget of the current and terminal-use profiles, including each profile's prompt/tools/output reservation. Apply a result only at a safe model boundary after validating its exact snapshotted prefix, preserving all newer history and recomputing live context accounting. Wait for pending compaction only after context exhaustion; cancellation and terminal cleanup must remain immediate. Cleanly completed conversations may accept a late summary while idle; stale, cancelled, or superseded generations must never replace a newer run's history.
|
- Direct-provider progressive compaction runs in a separate request alongside foreground work. Start early against the smaller usable history budget of the current and terminal-use profiles, including each profile's prompt/tools/output reservation. Apply a result only at a safe model boundary after validating its exact snapshotted prefix, preserving all newer history and recomputing live context accounting. Wait for pending compaction only after context exhaustion; cancellation and terminal cleanup must remain immediate. Cleanly completed conversations may accept a late summary while idle; stale, cancelled, or superseded generations must never replace a newer run's history.
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ impl BedrockClient {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let converted = build_converse_request(
|
let converted = build_converse_request(
|
||||||
|
model_id,
|
||||||
messages.clone(),
|
messages.clone(),
|
||||||
system_prompt.clone(),
|
system_prompt.clone(),
|
||||||
compact_summary,
|
compact_summary,
|
||||||
@@ -321,6 +322,7 @@ impl BedrockClient {
|
|||||||
let caching_config = CachingConfig::from_external_config(&external_config);
|
let caching_config = CachingConfig::from_external_config(&external_config);
|
||||||
|
|
||||||
let converted = build_converse_request(
|
let converted = build_converse_request(
|
||||||
|
model_id,
|
||||||
messages,
|
messages,
|
||||||
system_prompt,
|
system_prompt,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use aws_sdk_bedrockruntime::types::{
|
|||||||
ToolUseBlock,
|
ToolUseBlock,
|
||||||
};
|
};
|
||||||
use aws_smithy_types::{Blob, Document};
|
use aws_smithy_types::{Blob, Document};
|
||||||
|
use galaxy_agent_rig::prepare_bedrock_history;
|
||||||
use serde_json::Value as JsonValue;
|
use serde_json::Value as JsonValue;
|
||||||
|
|
||||||
use super::external_config::ExternalBedrockConfig;
|
use super::external_config::ExternalBedrockConfig;
|
||||||
@@ -65,6 +66,7 @@ pub struct ConvertedRequest {
|
|||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub fn build_converse_request(
|
pub fn build_converse_request(
|
||||||
|
model_id: &str,
|
||||||
messages: Vec<ConversationMessage>,
|
messages: Vec<ConversationMessage>,
|
||||||
system_prompt: Option<String>,
|
system_prompt: Option<String>,
|
||||||
compact_summary: Option<String>,
|
compact_summary: Option<String>,
|
||||||
@@ -75,6 +77,7 @@ pub fn build_converse_request(
|
|||||||
stop_sequences: Option<Vec<String>>,
|
stop_sequences: Option<Vec<String>>,
|
||||||
caching_config: CachingConfig,
|
caching_config: CachingConfig,
|
||||||
) -> ConvertedRequest {
|
) -> ConvertedRequest {
|
||||||
|
let messages = prepare_bedrock_history(model_id, messages);
|
||||||
let bedrock_messages = convert_messages(messages, &caching_config);
|
let bedrock_messages = convert_messages(messages, &caching_config);
|
||||||
let system = convert_system_prompt(system_prompt, compact_summary, &caching_config);
|
let system = convert_system_prompt(system_prompt, compact_summary, &caching_config);
|
||||||
let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences);
|
let inference_config = build_inference_config(max_tokens, temperature, top_p, stop_sequences);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ fn test_text_message_converts_to_single_block() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -54,6 +55,7 @@ fn test_multimodal_user_message_converts_image_to_bedrock_block() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -100,6 +102,7 @@ fn test_tool_use_produces_valid_json_input() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -134,6 +137,7 @@ fn test_tool_result_with_matching_id() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -166,6 +170,7 @@ fn test_tool_result_error_status() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -202,6 +207,7 @@ fn test_consecutive_same_role_messages_coalesced() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -237,6 +243,7 @@ fn test_alternating_roles_not_coalesced() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -262,6 +269,7 @@ fn test_system_prompt_separated_from_messages() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
Some("You are a helpful assistant.".to_string()),
|
Some("You are a helpful assistant.".to_string()),
|
||||||
None,
|
None,
|
||||||
@@ -284,6 +292,7 @@ fn test_system_prompt_separated_from_messages() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_empty_system_prompt_produces_empty_vec() {
|
fn test_empty_system_prompt_produces_empty_vec() {
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
Some("".to_string()),
|
Some("".to_string()),
|
||||||
None,
|
None,
|
||||||
@@ -297,6 +306,7 @@ fn test_empty_system_prompt_produces_empty_vec() {
|
|||||||
assert!(result.system.is_empty());
|
assert!(result.system.is_empty());
|
||||||
|
|
||||||
let result2 = build_converse_request(
|
let result2 = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -313,6 +323,7 @@ fn test_empty_system_prompt_produces_empty_vec() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_empty_tools_produce_none_config() {
|
fn test_empty_tools_produce_none_config() {
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -341,6 +352,7 @@ fn test_tool_definitions_produce_tool_config() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -363,6 +375,7 @@ fn test_tool_definitions_produce_tool_config() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_inference_config_max_tokens_only() {
|
fn test_inference_config_max_tokens_only() {
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -382,6 +395,7 @@ fn test_inference_config_max_tokens_only() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_inference_config_all_params() {
|
fn test_inference_config_all_params() {
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![],
|
vec![],
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -413,6 +427,7 @@ fn test_multipart_content_produces_multiple_blocks() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -461,6 +476,7 @@ fn test_tool_result_after_tool_use_coalesced_into_user_message() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -511,6 +527,7 @@ fn test_caching_disabled_no_cache_points() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
Some("System prompt".to_string()),
|
Some("System prompt".to_string()),
|
||||||
None,
|
None,
|
||||||
@@ -570,6 +587,7 @@ fn test_caching_enabled_has_cache_points() {
|
|||||||
}];
|
}];
|
||||||
|
|
||||||
let result = build_converse_request(
|
let result = build_converse_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
messages,
|
messages,
|
||||||
Some("System prompt".to_string()),
|
Some("System prompt".to_string()),
|
||||||
None,
|
None,
|
||||||
@@ -608,3 +626,193 @@ fn test_caching_enabled_has_cache_points() {
|
|||||||
assert!(has_tool_cache, "Tools should have cache point");
|
assert!(has_tool_cache, "Tools should have cache point");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reasoning_test_request(model_id: &str, messages: Vec<ConversationMessage>) -> ConvertedRequest {
|
||||||
|
build_converse_request(
|
||||||
|
model_id,
|
||||||
|
messages,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
vec![],
|
||||||
|
4096,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
CachingConfig {
|
||||||
|
enabled: false,
|
||||||
|
extended_ttl_requested: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsupported_reasoning_history_preserves_visible_content_images_and_tool_pairs() {
|
||||||
|
for model_id in [
|
||||||
|
"us.deepseek.r1-v1:0",
|
||||||
|
"amazon.nova-2-lite-v1:0",
|
||||||
|
"openai.gpt-oss-120b-1:0",
|
||||||
|
"qwen.qwen3-32b-v1:0",
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||||
|
] {
|
||||||
|
let result = reasoning_test_request(
|
||||||
|
model_id,
|
||||||
|
vec![
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Text("Inspect this image".to_owned()),
|
||||||
|
ContentPart::Image {
|
||||||
|
data: vec![1, 2, 3, 4],
|
||||||
|
mime_type: "image/png".to_owned(),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Display-only reasoning".to_owned(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Reasoning from an earlier Claude turn".to_owned(),
|
||||||
|
signature: Some("original-claude-signature".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::Text("I will inspect the manifest.".to_owned()),
|
||||||
|
ContentPart::ToolUse {
|
||||||
|
tool_use_id: "manifest-call".to_owned(),
|
||||||
|
name: "read_files".to_owned(),
|
||||||
|
input: json!({"files": ["Cargo.toml"]}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::ToolResult {
|
||||||
|
tool_use_id: "manifest-call".to_owned(),
|
||||||
|
content: "[package]".to_owned(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![ContentPart::Reasoning {
|
||||||
|
text: "No visible assistant content".to_owned(),
|
||||||
|
signature: None,
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("Continue".to_owned()),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert_eq!(result.messages.len(), 3, "model: {model_id}");
|
||||||
|
assert!(result.messages.iter().all(|message| {
|
||||||
|
!message.content().is_empty()
|
||||||
|
&& message
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.all(|part| !matches!(part, ContentBlock::ReasoningContent(_)))
|
||||||
|
}));
|
||||||
|
let user_content = result.messages[0].content();
|
||||||
|
assert!(
|
||||||
|
matches!(&user_content[0], ContentBlock::Text(text) if text == "Inspect this image")
|
||||||
|
);
|
||||||
|
let ContentBlock::Image(image) = &user_content[1] else {
|
||||||
|
panic!("the image must survive reasoning filtering");
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
image.source().unwrap().as_bytes().unwrap().as_ref(),
|
||||||
|
&[1, 2, 3, 4]
|
||||||
|
);
|
||||||
|
let assistant_content = result.messages[1].content();
|
||||||
|
assert_eq!(assistant_content.len(), 2);
|
||||||
|
assert!(
|
||||||
|
matches!(&assistant_content[0], ContentBlock::Text(text) if text == "I will inspect the manifest.")
|
||||||
|
);
|
||||||
|
let ContentBlock::ToolUse(tool) = &assistant_content[1] else {
|
||||||
|
panic!("the tool call must survive reasoning filtering");
|
||||||
|
};
|
||||||
|
assert_eq!(tool.tool_use_id(), "manifest-call");
|
||||||
|
assert_eq!(tool.name(), "read_files");
|
||||||
|
let final_user_content = result.messages[2].content();
|
||||||
|
let ContentBlock::ToolResult(tool_result) = &final_user_content[0] else {
|
||||||
|
panic!("the paired tool result must survive reasoning filtering");
|
||||||
|
};
|
||||||
|
assert_eq!(tool_result.tool_use_id(), "manifest-call");
|
||||||
|
assert!(matches!(&final_user_content[1], ContentBlock::Text(text) if text == "Continue"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn signed_claude_reasoning_keeps_exact_text_signatures_and_tool_order() {
|
||||||
|
use aws_sdk_bedrockruntime::types::ReasoningContentBlock;
|
||||||
|
|
||||||
|
let result = reasoning_test_request(
|
||||||
|
"global.anthropic.claude-sonnet-4-6[1m]",
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Exact signed thinking\nincluding whitespace.\n".to_owned(),
|
||||||
|
signature: Some("first-signature".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: String::new(),
|
||||||
|
signature: Some("signature-only-block".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::ToolUse {
|
||||||
|
tool_use_id: "signed-call".to_owned(),
|
||||||
|
name: "read_files".to_owned(),
|
||||||
|
input: json!({"files": ["Cargo.toml"]}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert_eq!(result.messages.len(), 1);
|
||||||
|
let parts = result.messages[0].content();
|
||||||
|
assert_eq!(parts.len(), 3);
|
||||||
|
for (index, text, signature) in [
|
||||||
|
(
|
||||||
|
0,
|
||||||
|
"Exact signed thinking\nincluding whitespace.\n",
|
||||||
|
"first-signature",
|
||||||
|
),
|
||||||
|
(1, "", "signature-only-block"),
|
||||||
|
] {
|
||||||
|
let ContentBlock::ReasoningContent(ReasoningContentBlock::ReasoningText(reasoning)) =
|
||||||
|
&parts[index]
|
||||||
|
else {
|
||||||
|
panic!("signed Claude reasoning must retain its native block");
|
||||||
|
};
|
||||||
|
assert_eq!(reasoning.text(), text);
|
||||||
|
assert_eq!(reasoning.signature(), Some(signature));
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
matches!(&parts[2], ContentBlock::ToolUse(tool) if tool.tool_use_id() == "signed-call")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unsigned_reasoning_is_not_replayed_even_to_claude() {
|
||||||
|
for signature in [None, Some(String::new()), Some(" ".to_owned())] {
|
||||||
|
let result = reasoning_test_request(
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Unsigned reasoning cannot be replayed as Claude thinking".to_owned(),
|
||||||
|
signature,
|
||||||
|
},
|
||||||
|
ContentPart::Text("Visible answer".to_owned()),
|
||||||
|
]),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
assert_eq!(result.messages.len(), 1);
|
||||||
|
assert!(
|
||||||
|
matches!(result.messages[0].content(), [ContentBlock::Text(text)] if text == "Visible answer")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+17
-11
@@ -495,18 +495,24 @@ pub(crate) async fn provider_runtime_for_request(
|
|||||||
let cross_region_inference = config.invocation_model_id.is_none() && config.cross_region_inference;
|
let cross_region_inference = config.invocation_model_id.is_none() && config.cross_region_inference;
|
||||||
let caching_config =
|
let caching_config =
|
||||||
CachingConfig::from_external_config(&ExternalBedrockConfig::load()).for_model(&model);
|
CachingConfig::from_external_config(&ExternalBedrockConfig::load()).for_model(&model);
|
||||||
let invocation_model = config.invocation_model_id.clone().unwrap_or(model);
|
let invocation_model = config
|
||||||
|
.invocation_model_id
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| model.clone());
|
||||||
let client = BedrockClient::from_config(config).await?;
|
let client = BedrockClient::from_config(config).await?;
|
||||||
Arc::new(BedrockRuntime::from_aws_client(
|
Arc::new(
|
||||||
client.runtime_client(),
|
BedrockRuntime::from_aws_client(
|
||||||
BedrockRigConfig {
|
client.runtime_client(),
|
||||||
model: invocation_model,
|
BedrockRigConfig {
|
||||||
region: client.region().to_string(),
|
model: invocation_model,
|
||||||
cross_region_inference,
|
region: client.region().to_string(),
|
||||||
prompt_caching: caching_config.enabled,
|
cross_region_inference,
|
||||||
max_output_tokens,
|
prompt_caching: caching_config.enabled,
|
||||||
},
|
max_output_tokens,
|
||||||
)?)
|
},
|
||||||
|
)?
|
||||||
|
.with_reasoning_model(model),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
crate::ai::provider::ProviderConfig::None => {
|
crate::ai::provider::ProviderConfig::None => {
|
||||||
anyhow::bail!(
|
anyhow::bail!(
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
|
use aws_sdk_bedrockruntime::Client as AwsBedrockClient;
|
||||||
use galaxy_agent_core::{
|
use galaxy_agent_core::{
|
||||||
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities,
|
AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, ContentPart, ConversationMessage,
|
||||||
RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest,
|
MessageContent, MessageRole, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl,
|
||||||
|
TurnRequest,
|
||||||
};
|
};
|
||||||
use rig_bedrock::client::Client as RigBedrockClient;
|
use rig_bedrock::client::Client as RigBedrockClient;
|
||||||
use rig_bedrock::completion::CompletionModel;
|
use rig_bedrock::completion::CompletionModel;
|
||||||
@@ -33,6 +34,7 @@ pub struct BedrockRuntime {
|
|||||||
client: RigBedrockClient,
|
client: RigBedrockClient,
|
||||||
config: BedrockRigConfig,
|
config: BedrockRigConfig,
|
||||||
resolved_model: String,
|
resolved_model: String,
|
||||||
|
reasoning_model: String,
|
||||||
descriptor: RuntimeDescriptor,
|
descriptor: RuntimeDescriptor,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ impl BedrockRuntime {
|
|||||||
Ok(Self {
|
Ok(Self {
|
||||||
client: RigBedrockClient::from(client),
|
client: RigBedrockClient::from(client),
|
||||||
config,
|
config,
|
||||||
|
reasoning_model: resolved_model.clone(),
|
||||||
resolved_model,
|
resolved_model,
|
||||||
descriptor,
|
descriptor,
|
||||||
})
|
})
|
||||||
@@ -62,6 +65,13 @@ impl BedrockRuntime {
|
|||||||
&self.resolved_model
|
&self.resolved_model
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Use the discovered foundation model's capabilities when invocation targets an
|
||||||
|
/// opaque application inference profile rather than a foundation-model ID.
|
||||||
|
pub fn with_reasoning_model(mut self, model: impl Into<String>) -> Self {
|
||||||
|
self.reasoning_model = model.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn completion_model(&self) -> CompletionModel {
|
pub fn completion_model(&self) -> CompletionModel {
|
||||||
let model = self.client.completion_model(&self.resolved_model);
|
let model = self.client.completion_model(&self.resolved_model);
|
||||||
if self.config.prompt_caching {
|
if self.config.prompt_caching {
|
||||||
@@ -84,8 +94,11 @@ impl AgentRuntime for BedrockRuntime {
|
|||||||
control: TurnControl,
|
control: TurnControl,
|
||||||
) -> Result<AgentEventStream, AgentError> {
|
) -> Result<AgentEventStream, AgentError> {
|
||||||
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
|
let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens);
|
||||||
let mut completion_request =
|
let mut completion_request = build_bedrock_completion_request_for_model(
|
||||||
build_bedrock_completion_request(request, self.config.max_output_tokens)?;
|
request,
|
||||||
|
self.config.max_output_tokens,
|
||||||
|
&self.reasoning_model,
|
||||||
|
)?;
|
||||||
// Context markers and inference-profile expansion are Galaxy model
|
// Context markers and inference-profile expansion are Galaxy model
|
||||||
// configuration, not identifiers that Rig should send unchanged.
|
// configuration, not identifiers that Rig should send unchanged.
|
||||||
completion_request.model = Some(self.resolved_model.clone());
|
completion_request.model = Some(self.resolved_model.clone());
|
||||||
@@ -103,9 +116,74 @@ pub fn build_bedrock_completion_request(
|
|||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
configured_max_output_tokens: Option<u64>,
|
configured_max_output_tokens: Option<u64>,
|
||||||
) -> Result<CompletionRequest, AgentError> {
|
) -> Result<CompletionRequest, AgentError> {
|
||||||
|
let model = request.model.as_str().to_owned();
|
||||||
|
build_bedrock_completion_request_for_model(request, configured_max_output_tokens, &model)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_bedrock_completion_request_for_model(
|
||||||
|
mut request: TurnRequest,
|
||||||
|
configured_max_output_tokens: Option<u64>,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<CompletionRequest, AgentError> {
|
||||||
|
request.messages = prepare_bedrock_history(model, request.messages);
|
||||||
build_completion_request(request, configured_max_output_tokens, true, true, None)
|
build_completion_request(request, configured_max_output_tokens, true, true, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Project durable history into Bedrock's input format without changing the saved
|
||||||
|
/// transcript. Reasoning output is not universally accepted as reasoning input:
|
||||||
|
/// DeepSeek, Nova and other providers return unsigned, display-only reasoning.
|
||||||
|
/// Claude tool continuations need their complete signed blocks unchanged.
|
||||||
|
pub fn prepare_bedrock_history(
|
||||||
|
model_id: &str,
|
||||||
|
messages: Vec<ConversationMessage>,
|
||||||
|
) -> Vec<ConversationMessage> {
|
||||||
|
let supports_signed_reasoning = accepts_signed_reasoning(model_id);
|
||||||
|
messages
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|mut message| {
|
||||||
|
if let MessageContent::MultiPart(parts) = &mut message.content {
|
||||||
|
parts.retain(|part| match part {
|
||||||
|
ContentPart::Reasoning { signature, .. } => {
|
||||||
|
message.role == MessageRole::Assistant
|
||||||
|
&& supports_signed_reasoning
|
||||||
|
&& signature
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|signature| !signature.trim().is_empty())
|
||||||
|
}
|
||||||
|
ContentPart::Text(_)
|
||||||
|
| ContentPart::Image { .. }
|
||||||
|
| ContentPart::ToolUse { .. }
|
||||||
|
| ContentPart::ToolResult { .. } => true,
|
||||||
|
});
|
||||||
|
if parts.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(message)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn accepts_signed_reasoning(model_id: &str) -> bool {
|
||||||
|
let model_id = model_id.trim();
|
||||||
|
// An opaque application profile may point to Claude. Preserve its signed
|
||||||
|
// blocks when no foundation-model hint is available; unsigned output is
|
||||||
|
// still omitted. Production discovery supplies that hint to the runtime.
|
||||||
|
if model_id.starts_with("arn:") && model_id.contains(":application-inference-profile/") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let model = strip_context_marker(model_id.rsplit('/').next().unwrap_or(model_id));
|
||||||
|
let model = INFERENCE_PROFILE_PREFIXES
|
||||||
|
.iter()
|
||||||
|
.find_map(|prefix| model.strip_prefix(prefix))
|
||||||
|
.unwrap_or(model);
|
||||||
|
model.starts_with("anthropic.claude-")
|
||||||
|
&& !model.starts_with("anthropic.claude-instant-")
|
||||||
|
&& !model.starts_with("anthropic.claude-v1")
|
||||||
|
&& !model.starts_with("anthropic.claude-v2")
|
||||||
|
&& (!model.starts_with("anthropic.claude-3-") || model.starts_with("anthropic.claude-3-7-"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn resolve_bedrock_model_id(
|
pub fn resolve_bedrock_model_id(
|
||||||
configured_model: &str,
|
configured_model: &str,
|
||||||
region: &str,
|
region: &str,
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ async fn cancellation_before_bedrock_stream_start_never_contacts_aws() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bedrock_request_preserves_system_image_reasoning_tool_and_token_semantics() {
|
fn bedrock_request_preserves_system_image_reasoning_tool_and_token_semantics() {
|
||||||
let mut request = TurnRequest::new(
|
let mut request = TurnRequest::new(
|
||||||
"anthropic.claude-test",
|
"anthropic.claude-sonnet-4-6",
|
||||||
vec![
|
vec![
|
||||||
ConversationMessage {
|
ConversationMessage {
|
||||||
role: MessageRole::User,
|
role: MessageRole::User,
|
||||||
@@ -278,3 +278,151 @@ fn bedrock_request_preserves_system_image_reasoning_tool_and_token_semantics() {
|
|||||||
Some(ToolResultContent::Text(text)) if text.text == "[ERROR] permission denied"
|
Some(ToolResultContent::Text(text)) if text.text == "[ERROR] permission denied"
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bedrock_follow_ups_omit_unsigned_reasoning_without_losing_tool_calls_or_results() {
|
||||||
|
for model in [
|
||||||
|
"deepseek.r1-v1:0",
|
||||||
|
"us.amazon.nova-2-lite-v1:0",
|
||||||
|
"openai.gpt-oss-120b-1:0",
|
||||||
|
"qwen.qwen3-32b-v1:0",
|
||||||
|
"anthropic.claude-sonnet-4-6",
|
||||||
|
] {
|
||||||
|
let request = TurnRequest::new(
|
||||||
|
model,
|
||||||
|
vec![
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("Inspect the project".to_owned()),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "display-only reasoning".to_owned(),
|
||||||
|
signature: None,
|
||||||
|
},
|
||||||
|
ContentPart::Text("Reading the manifest".to_owned()),
|
||||||
|
ContentPart::ToolUse {
|
||||||
|
tool_use_id: "manifest-call".to_owned(),
|
||||||
|
name: "read_files".to_owned(),
|
||||||
|
input: serde_json::json!({"files": ["Cargo.toml"]}),
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::ToolResult {
|
||||||
|
tool_use_id: "manifest-call".to_owned(),
|
||||||
|
content: "manifest contents".to_owned(),
|
||||||
|
is_error: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let converted = build_bedrock_completion_request(request, None).unwrap();
|
||||||
|
let messages = converted.chat_history.iter().collect::<Vec<_>>();
|
||||||
|
let [
|
||||||
|
_,
|
||||||
|
Message::Assistant { content, .. },
|
||||||
|
Message::User { content: result },
|
||||||
|
] = messages.as_slice()
|
||||||
|
else {
|
||||||
|
panic!("expected the tool exchange for {model}");
|
||||||
|
};
|
||||||
|
assert!(matches!(content.iter().collect::<Vec<_>>().as_slice(),
|
||||||
|
[AssistantContent::Text(text), AssistantContent::ToolCall(call)]
|
||||||
|
if text.text == "Reading the manifest" && call.id == "manifest-call"
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
matches!(result.iter().next(), Some(UserContent::ToolResult(result))
|
||||||
|
if result.id == "manifest-call")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bedrock_model_switch_drops_foreign_signed_reasoning_and_empty_messages() {
|
||||||
|
for model in [
|
||||||
|
"amazon.nova-pro-v1:0",
|
||||||
|
"us.deepseek.r1-v1:0",
|
||||||
|
"openai.gpt-oss-120b-1:0",
|
||||||
|
"anthropic.claude-3-5-sonnet-20241022-v2:0",
|
||||||
|
"anthropic.claude-3-haiku-20240307-v1:0",
|
||||||
|
"anthropic.claude-instant-v1",
|
||||||
|
"anthropic.claude-v1",
|
||||||
|
"anthropic.claude-v2:1",
|
||||||
|
] {
|
||||||
|
let messages = vec![
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("Question".to_owned()),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![ContentPart::Reasoning {
|
||||||
|
text: "Earlier thinking".to_owned(),
|
||||||
|
signature: Some("original-signature".to_owned()),
|
||||||
|
}]),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Earlier thinking".to_owned(),
|
||||||
|
signature: Some("original-signature".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::Text("Visible answer".to_owned()),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
// The runtime's selected model takes precedence over a historical request model.
|
||||||
|
let request = TurnRequest::new("anthropic.claude-sonnet-4-6", messages);
|
||||||
|
let converted = build_bedrock_completion_request_for_model(request, None, model).unwrap();
|
||||||
|
assert_eq!(converted.chat_history.len(), 2);
|
||||||
|
assert!(
|
||||||
|
matches!(converted.chat_history.iter().nth(1), Some(Message::Assistant { content, .. })
|
||||||
|
if matches!(content.iter().collect::<Vec<_>>().as_slice(), [AssistantContent::Text(text)] if text.text == "Visible answer"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bedrock_preserves_signed_claude_blocks_exactly_across_identifier_forms() {
|
||||||
|
let signed = ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: "Unmodified thinking".to_owned(),
|
||||||
|
signature: Some("signed-text".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::Reasoning {
|
||||||
|
text: String::new(),
|
||||||
|
signature: Some("signature-only".to_owned()),
|
||||||
|
},
|
||||||
|
ContentPart::Text("Answer".to_owned()),
|
||||||
|
]),
|
||||||
|
};
|
||||||
|
for model in [
|
||||||
|
"anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
|
"global.anthropic.claude-sonnet-4-6[1m]",
|
||||||
|
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-sonnet-4-6",
|
||||||
|
"arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-sonnet-4-6",
|
||||||
|
"arn:aws:bedrock:us-east-1:123456789012:application-inference-profile/opaque-profile",
|
||||||
|
] {
|
||||||
|
assert_eq!(
|
||||||
|
prepare_bedrock_history(model, vec![signed.clone()]),
|
||||||
|
vec![signed.clone()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for signature in [None, Some(String::new()), Some(" ".to_owned())] {
|
||||||
|
let message = ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::MultiPart(vec![ContentPart::Reasoning {
|
||||||
|
text: "Display only".to_owned(),
|
||||||
|
signature,
|
||||||
|
}]),
|
||||||
|
};
|
||||||
|
assert!(prepare_bedrock_history("anthropic.claude-sonnet-4-6", vec![message]).is_empty());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user