Bump version to 1.6.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-06-12 14:17:06 -05:00
co-authored by Claude Opus 4.6
parent 4ba9706e35
commit 59cfd0e2f5
152 changed files with 8276 additions and 1664 deletions
+82 -6
View File
@@ -7,13 +7,23 @@ use aws_sdk_bedrockruntime::Client as BedrockRuntimeClient;
use crate::settings::ai::BedrockAuthMethod;
use super::external_config::ExternalBedrockConfig;
use super::convert::{build_converse_request, CachingConfig, ConversationMessage, ToolDefinition};
use super::diagnostic::BedrockDiagnosticLogger;
use super::external_config::ExternalBedrockConfig;
use super::models::apply_cross_region_prefix;
use super::response_translator::bedrock_stream_to_response_events;
use crate::ai::agent::api::ResponseStream;
fn strip_context_marker(model_id: &str) -> String {
if let Some(base) = model_id.strip_suffix("[1m]") {
base.to_string()
} else if let Some(base) = model_id.strip_suffix("[1M]") {
base.to_string()
} else {
model_id.to_string()
}
}
pub struct BedrockClient {
runtime_client: BedrockRuntimeClient,
region: String,
@@ -141,12 +151,13 @@ impl BedrockClient {
user_query: Option<String>,
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
is_summarization: bool,
tool_result_archive: Vec<ConversationMessage>,
) -> Result<ResponseStream, BedrockError> {
let base_model_id = strip_context_marker(model_id);
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(model_id, &self.region)
apply_cross_region_prefix(&base_model_id, &self.region)
} else {
model_id.to_string()
base_model_id
};
let external_config = ExternalBedrockConfig::load();
@@ -238,9 +249,74 @@ impl BedrockClient {
user_query,
diagnostic_logger,
messages_sent,
effective_model_id,
is_summarization,
model_id.to_string(),
tool_result_archive,
)))
}
/// Performs a non-streaming converse call and collects the full response text.
/// Used for background progressive summarization where we don't need streaming UI.
/// Returns (response_text, input_tokens, output_tokens).
pub async fn converse_collect(
&self,
model_id: &str,
messages: Vec<ConversationMessage>,
system_prompt: Option<String>,
max_tokens: i32,
cross_region_inference: bool,
) -> Result<(String, u32, u32), BedrockError> {
let base_model_id = strip_context_marker(model_id);
let effective_model_id = if cross_region_inference {
apply_cross_region_prefix(&base_model_id, &self.region)
} else {
base_model_id
};
let external_config = ExternalBedrockConfig::load();
let caching_config = CachingConfig::from_external_config(&external_config);
let converted = build_converse_request(
messages,
system_prompt,
None,
vec![],
max_tokens,
None,
None,
None,
caching_config,
);
let request = self
.runtime_client
.converse()
.model_id(&effective_model_id)
.set_system(Some(converted.system))
.set_messages(Some(converted.messages))
.inference_config(converted.inference_config);
let output = request.send().await.map_err(|e| {
let msg = format!("{e}");
log::error!("[bedrock] converse_collect error: {msg}");
BedrockError::ApiError(msg)
})?;
let mut response_text = String::new();
if let Some(output_msg) = output.output() {
if let aws_sdk_bedrockruntime::types::ConverseOutput::Message(msg) = output_msg {
for block in msg.content() {
if let aws_sdk_bedrockruntime::types::ContentBlock::Text(text) = block {
response_text.push_str(text);
}
}
}
}
let (input_tokens, output_tokens) = output
.usage()
.map(|u| (u.input_tokens() as u32, u.output_tokens() as u32))
.unwrap_or((0, 0));
Ok((response_text, input_tokens, output_tokens))
}
}
+15 -11
View File
@@ -2,9 +2,9 @@ use std::collections::HashMap;
use aws_sdk_bedrockruntime::types::{
CachePointBlock, CachePointType, CacheTtl, ContentBlock, ConversationRole,
InferenceConfiguration, Message as BedrockMessage, SystemContentBlock, Tool,
ToolConfiguration, ToolInputSchema, ToolResultBlock, ToolResultContentBlock,
ToolResultStatus, ToolSpecification, ToolUseBlock,
InferenceConfiguration, Message as BedrockMessage, SystemContentBlock, Tool, ToolConfiguration,
ToolInputSchema, ToolResultBlock, ToolResultContentBlock, ToolResultStatus, ToolSpecification,
ToolUseBlock,
};
use aws_smithy_types::Document;
use serde_json::Value as JsonValue;
@@ -141,7 +141,10 @@ fn json_to_document(value: JsonValue) -> Document {
}
}
fn convert_messages(messages: Vec<ConversationMessage>, caching_config: &CachingConfig) -> Vec<BedrockMessage> {
fn convert_messages(
messages: Vec<ConversationMessage>,
caching_config: &CachingConfig,
) -> Vec<BedrockMessage> {
let mut result = Vec::new();
for msg in messages {
@@ -300,7 +303,7 @@ fn coalesce_consecutive_roles(messages: Vec<BedrockMessage>) -> Vec<BedrockMessa
fn convert_system_prompt(
system_prompt: Option<String>,
compact_summary: Option<String>,
_compact_summary: Option<String>,
caching_config: &CachingConfig,
) -> Vec<SystemContentBlock> {
let mut blocks = Vec::new();
@@ -311,11 +314,9 @@ fn convert_system_prompt(
}
}
if let Some(summary) = compact_summary {
blocks.push(SystemContentBlock::Text(format!(
"<conversation-summary>\n{summary}\n</conversation-summary>"
)));
}
// Progressive summary is now prepended to the messages array instead of
// being injected here. The compact_summary parameter is kept for API compat
// but ignored.
if !blocks.is_empty() && caching_config.enabled {
let mut builder = CachePointBlock::builder().r#type(CachePointType::Default);
@@ -351,7 +352,10 @@ fn build_inference_config(
builder.build()
}
fn build_tool_config(tools: Vec<ToolDefinition>, caching_config: &CachingConfig) -> Option<ToolConfiguration> {
fn build_tool_config(
tools: Vec<ToolDefinition>,
caching_config: &CachingConfig,
) -> Option<ToolConfiguration> {
if tools.is_empty() {
return None;
}
+26
View File
@@ -705,6 +705,31 @@ fn tool_definition_for_name(name: &str) -> ToolDefinition {
"required": ["prompt", "label"]
}),
},
"recall_tool_history" => ToolDefinition {
name: "recall_tool_history".to_string(),
description: "Retrieve the full output of a past tool execution from conversation history. Use when you need details from a tool call that is no longer in your current context window.".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"search_query": {
"type": "string",
"description": "Search text to match against tool names, inputs, or outputs"
},
"tool_name": {
"type": "string",
"description": "Filter by tool name (run_shell_command, read_files, grep, file_glob, apply_file_diffs)"
},
"tool_use_id": {
"type": "string",
"description": "Exact tool_use_id to look up (most precise)"
},
"offset_from_end": {
"type": "integer",
"description": "How many matching tool calls back from most recent (0 = most recent match)"
}
}
}),
},
_ => ToolDefinition {
name: name.to_string(),
description: format!("Tool: {}", name),
@@ -724,6 +749,7 @@ fn default_tool_definitions() -> Vec<ToolDefinition> {
tool_definition_for_name("grep"),
tool_definition_for_name("file_glob"),
tool_definition_for_name("suggest_next_prompt"),
tool_definition_for_name("recall_tool_history"),
]
}
+147 -15
View File
@@ -10,7 +10,17 @@ fn test_text_message_converts_to_single_block() {
content: MessageContent::Text("Hello".to_string()),
}];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
@@ -29,7 +39,17 @@ fn test_tool_use_produces_valid_json_input() {
},
}];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].role(), &ConversationRole::Assistant);
@@ -53,7 +73,17 @@ fn test_tool_result_with_matching_id() {
},
}];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 1);
match &result.messages[0].content()[0] {
@@ -75,7 +105,17 @@ fn test_tool_result_error_status() {
},
}];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
match &result.messages[0].content()[0] {
ContentBlock::ToolResult(block) => {
@@ -101,7 +141,17 @@ fn test_consecutive_same_role_messages_coalesced() {
},
];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 1);
assert_eq!(result.messages[0].content().len(), 2);
@@ -126,7 +176,17 @@ fn test_alternating_roles_not_coalesced() {
},
];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 3);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
@@ -160,17 +220,46 @@ fn test_system_prompt_separated_from_messages() {
#[test]
fn test_empty_system_prompt_produces_empty_vec() {
let result =
build_converse_request(vec![], Some("".to_string()), None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
vec![],
Some("".to_string()),
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert!(result.system.is_empty());
let result2 = build_converse_request(vec![], None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result2 = build_converse_request(
vec![],
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert!(result2.system.is_empty());
}
#[test]
fn test_empty_tools_produce_none_config() {
let result = build_converse_request(vec![], None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
vec![],
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert!(result.tool_config.is_none());
}
@@ -188,7 +277,17 @@ fn test_tool_definitions_produce_tool_config() {
}),
}];
let result = build_converse_request(vec![], None, None, tools, 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
vec![],
None,
None,
tools,
4096,
None,
None,
None,
CachingConfig::default(),
);
assert!(result.tool_config.is_some());
let config = result.tool_config.unwrap();
@@ -197,7 +296,17 @@ fn test_tool_definitions_produce_tool_config() {
#[test]
fn test_inference_config_max_tokens_only() {
let result = build_converse_request(vec![], None, None, vec![], 8192, None, None, None, CachingConfig::default());
let result = build_converse_request(
vec![],
None,
None,
vec![],
8192,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.inference_config.max_tokens(), Some(8192));
assert_eq!(result.inference_config.temperature(), None);
assert_eq!(result.inference_config.top_p(), None);
@@ -237,7 +346,17 @@ fn test_multipart_content_produces_multiple_blocks() {
]),
}];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages[0].content().len(), 2);
assert!(matches!(
@@ -275,7 +394,17 @@ fn test_tool_result_after_tool_use_coalesced_into_user_message() {
},
];
let result = build_converse_request(messages, None, None, vec![], 4096, None, None, None, CachingConfig::default());
let result = build_converse_request(
messages,
None,
None,
vec![],
4096,
None,
None,
None,
CachingConfig::default(),
);
assert_eq!(result.messages.len(), 3);
assert_eq!(result.messages[0].role(), &ConversationRole::User);
@@ -392,7 +521,10 @@ fn test_caching_enabled_has_cache_points() {
.content()
.iter()
.any(|c| matches!(c, ContentBlock::CachePoint(_)));
assert!(has_cache_point, "Second-to-last message should have cache point");
assert!(
has_cache_point,
"Second-to-last message should have cache point"
);
// Check that cache point exists in system
use aws_sdk_bedrockruntime::types::SystemContentBlock;
-176
View File
@@ -1,176 +0,0 @@
use std::collections::BTreeSet;
use std::path::PathBuf;
use anyhow::Result;
use aws_config::BehaviorVersion;
use aws_sdk_bedrock::types::InferenceProfileType;
use aws_sdk_bedrock::Client as BedrockControlClient;
use aws_sdk_bedrockruntime::config::Region;
use crate::settings::ai::{BedrockAuthMethod, BedrockModelConfig};
use super::client::BedrockClientConfig;
pub fn list_aws_profiles() -> Vec<String> {
let mut profiles = BTreeSet::new();
if let Some(home) = dirs::home_dir() {
parse_config_file(home.join(".aws").join("config"), &mut profiles);
parse_credentials_file(home.join(".aws").join("credentials"), &mut profiles);
}
profiles.into_iter().collect()
}
fn parse_config_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = &trimmed[1..trimmed.len() - 1];
if section == "default" {
profiles.insert("default".to_string());
} else if let Some(name) = section.strip_prefix("profile ") {
profiles.insert(name.trim().to_string());
}
}
}
}
fn parse_credentials_file(path: PathBuf, profiles: &mut BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let name = &trimmed[1..trimmed.len() - 1];
profiles.insert(name.trim().to_string());
}
}
}
pub async fn discover_inference_profiles(
config: &BedrockClientConfig,
) -> Result<Vec<BedrockModelConfig>> {
let aws_config = build_aws_config(config).await;
let client = BedrockControlClient::new(&aws_config);
let mut models = Vec::new();
fetch_profiles_by_type(&client, InferenceProfileType::SystemDefined, &mut models).await?;
fetch_profiles_by_type(&client, InferenceProfileType::Application, &mut models).await?;
models.sort_by(|a, b| a.display_name.cmp(&b.display_name));
models.dedup_by(|a, b| a.model_id == b.model_id);
Ok(models)
}
async fn fetch_profiles_by_type(
client: &BedrockControlClient,
profile_type: InferenceProfileType,
models: &mut Vec<BedrockModelConfig>,
) -> Result<()> {
let mut next_token: Option<String> = None;
loop {
let mut req = client
.list_inference_profiles()
.type_equals(profile_type.clone())
.max_results(100);
if let Some(token) = next_token.take() {
req = req.next_token(token);
}
let resp = req.send().await?;
for summary in resp.inference_profile_summaries() {
let profile_id = summary.inference_profile_id();
let profile_name = summary.inference_profile_name();
let profile_arn = summary.inference_profile_arn();
let model_id = if profile_arn.contains(":application-inference-profile/") {
profile_arn.to_string()
} else {
profile_id.to_string()
};
if should_skip_model(profile_name) {
continue;
}
models.push(BedrockModelConfig {
model_id,
display_name: profile_name.to_string(),
vision_supported: true,
});
}
next_token = resp.next_token().map(|s| s.to_string());
if next_token.is_none() {
break;
}
}
Ok(())
}
fn should_skip_model(name: &str) -> bool {
let lower = name.to_lowercase();
lower.contains("embed")
|| lower.contains("stable image")
|| lower.contains("stable-image")
|| lower.contains("upscale")
|| lower.contains("outpaint")
|| lower.contains("inpaint")
|| lower.contains("recolor")
|| lower.contains("erase")
|| lower.contains("style transfer")
|| lower.contains("style guide")
|| lower.contains("remove background")
|| lower.contains("search and replace")
|| lower.contains("control sketch")
|| lower.contains("control structure")
}
async fn build_aws_config(config: &BedrockClientConfig) -> aws_config::SdkConfig {
match config.auth_method {
BedrockAuthMethod::Profile | BedrockAuthMethod::Sso => {
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).profile_name(&config.profile);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
}
loader.load().await
}
BedrockAuthMethod::StaticKeys => {
let creds = aws_credential_types::Credentials::new(
&config.access_key_id,
&config.secret_access_key,
None,
None,
"warp-bedrock-discovery",
);
let mut loader =
aws_config::defaults(BehaviorVersion::latest()).credentials_provider(creds);
if !config.region.is_empty() {
loader = loader.region(Region::new(config.region.clone()));
} else {
loader = loader.region(Region::new("us-east-1".to_string()));
}
loader.load().await
}
}
}
+8 -8
View File
@@ -558,7 +558,7 @@ impl AgentSimulation {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("converse_stream should succeed");
@@ -1147,7 +1147,7 @@ async fn test_reasoning_model_produces_substantial_output() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("converse_stream should succeed");
@@ -1272,7 +1272,7 @@ async fn test_event_sequence_matches_controller_expectations() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("should connect");
@@ -1389,7 +1389,7 @@ async fn test_followup_turn_does_not_send_create_task() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("should connect");
@@ -1475,7 +1475,7 @@ async fn run_slash_command_test(
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("stream should connect");
@@ -1705,7 +1705,7 @@ async fn test_slash_resume_conversation() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("stream should connect");
@@ -1819,7 +1819,7 @@ async fn test_empty_messages_safety_check() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("safety fallback message should work");
@@ -1982,7 +1982,7 @@ async fn test_full_proto_round_trip_with_tool_history() {
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await;
+57 -14
View File
@@ -22,12 +22,8 @@ impl ExternalBedrockConfig {
// Claude Code takes priority over OpenCode since it has richer model mappings
Self {
profile: claude_config
.profile
.or(opencode_config.profile),
region: claude_config
.region
.or(opencode_config.region),
profile: claude_config.profile.or(opencode_config.profile),
region: claude_config.region.or(opencode_config.region),
models: if claude_config.models.is_empty() {
opencode_config.models
} else {
@@ -73,10 +69,12 @@ fn parse_claude_code_config(path: PathBuf) -> ExternalBedrockConfig {
let env = match json.get("env").and_then(|v| v.as_object()) {
Some(e) => e,
None => return ExternalBedrockConfig {
auth_refresh_command,
..Default::default()
},
None => {
return ExternalBedrockConfig {
auth_refresh_command,
..Default::default()
}
}
};
let profile = env
@@ -146,6 +144,7 @@ fn parse_claude_code_model_map(
model_id: arn,
display_name,
vision_supported: true,
context_size: 200_000,
}
})
.collect()
@@ -175,10 +174,7 @@ fn derive_display_name(model_id: &str) -> String {
fn prettify_model_slug(slug: &str) -> String {
// Remove version suffixes like "-v1:0", "-v1", "-v2:0"
let slug = slug
.split("-v")
.next()
.unwrap_or(slug);
let slug = slug.split("-v").next().unwrap_or(slug);
// Remove date suffixes like "-20250514" or "-20251001"
let parts: Vec<&str> = slug.split('-').collect();
@@ -275,6 +271,53 @@ fn parse_opencode_config(path: PathBuf) -> ExternalBedrockConfig {
}
}
pub fn list_aws_profiles() -> Vec<String> {
use std::collections::BTreeSet;
let mut profiles = BTreeSet::new();
if let Some(home) = dirs::home_dir() {
parse_aws_config_file(home.join(".aws").join("config"), &mut profiles);
parse_aws_credentials_file(home.join(".aws").join("credentials"), &mut profiles);
}
profiles.into_iter().collect()
}
fn parse_aws_config_file(path: PathBuf, profiles: &mut std::collections::BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let section = &trimmed[1..trimmed.len() - 1];
if section == "default" {
profiles.insert("default".to_string());
} else if let Some(name) = section.strip_prefix("profile ") {
profiles.insert(name.trim().to_string());
}
}
}
}
fn parse_aws_credentials_file(path: PathBuf, profiles: &mut std::collections::BTreeSet<String>) {
let contents = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => return,
};
for line in contents.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') && trimmed.ends_with(']') {
let name = &trimmed[1..trimmed.len() - 1];
profiles.insert(name.trim().to_string());
}
}
}
#[cfg(test)]
#[path = "external_config_tests.rs"]
mod tests;
+3 -3
View File
@@ -156,18 +156,18 @@ fn test_claude_code_takes_priority_over_opencode() {
let claude = ExternalBedrockConfig {
profile: Some("claude-profile".to_string()),
region: Some("us-east-1".to_string()),
models: Vec::new(),
..Default::default()
};
let opencode = ExternalBedrockConfig {
profile: Some("opencode-profile".to_string()),
region: Some("eu-west-1".to_string()),
models: Vec::new(),
..Default::default()
};
let merged = ExternalBedrockConfig {
profile: claude.profile.or(opencode.profile),
region: claude.region.or(opencode.region),
models: Vec::new(),
..Default::default()
};
assert_eq!(merged.profile, Some("claude-profile".to_string()));
+10 -3
View File
@@ -67,7 +67,7 @@ async fn collect_stream_output(
None,
None,
Arc::new(Mutex::new(Vec::new())),
false,
Vec::new(),
)
.await
.expect("converse_stream should succeed");
@@ -624,7 +624,11 @@ async fn test_all_tools_visible_to_model() {
let tools = super::request_translator::default_tool_definitions();
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
println!("[test] Sending {} tools to Bedrock: {:?}", tools.len(), tool_names);
println!(
"[test] Sending {} tools to Bedrock: {:?}",
tools.len(),
tool_names
);
let messages = vec![ConversationMessage {
role: MessageRole::User,
@@ -656,7 +660,10 @@ async fn test_all_tools_visible_to_model() {
}
if !missing_tools.is_empty() {
println!("[test] WARNING: Model did not mention these tools: {:?}", missing_tools);
println!(
"[test] WARNING: Model did not mention these tools: {:?}",
missing_tools
);
}
let expected_core_tools = [
-1
View File
@@ -1,7 +1,6 @@
pub mod client;
pub mod convert;
pub mod diagnostic;
pub mod discovery;
pub mod external_config;
pub mod models;
pub mod request_translator;
+74 -20
View File
@@ -6,53 +6,105 @@ pub struct DefaultModel {
pub model_id: &'static str,
pub display_name: &'static str,
pub vision_supported: bool,
pub context_size: u32,
}
pub const DEFAULT_BEDROCK_MODELS: &[DefaultModel] = &[
DefaultModel {
model_id: "anthropic.claude-opus-4-6",
display_name: "Claude Opus 4.6",
model_id: "us.anthropic.claude-opus-4-6-v1[1m]",
display_name: "Claude Opus 4.6 (1M)",
vision_supported: true,
context_size: 1_000_000,
},
DefaultModel {
model_id: "anthropic.claude-opus-4-7",
display_name: "Claude Opus 4.7",
model_id: "us.anthropic.claude-sonnet-4-6[1m]",
display_name: "Claude Sonnet 4.6 (1M)",
vision_supported: true,
context_size: 1_000_000,
},
DefaultModel {
model_id: "anthropic.claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6",
model_id: "us.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "anthropic.claude-sonnet-4-20250514-v1:0",
model_id: "us.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "anthropic.claude-haiku-4-5-20251001-v1:0",
model_id: "us.anthropic.claude-3-sonnet-20240229-v1:0",
display_name: "Claude 3 Sonnet",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-6",
display_name: "Claude Sonnet 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-5-20250929-v1:0",
display_name: "Claude Sonnet 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-sonnet-4-20250514-v1:0",
display_name: "Claude Sonnet 4 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-opus-4-1-20250805-v1:0",
display_name: "Claude Opus 4.1",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-6-v1",
display_name: "Claude Opus 4.6 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "global.anthropic.claude-opus-4-5-20251101-v1:0",
display_name: "Claude Opus 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "us.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "amazon.nova-pro-v1:0",
display_name: "Amazon Nova Pro",
model_id: "us.anthropic.claude-3-haiku-20240307-v1:0",
display_name: "Claude 3 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "amazon.nova-lite-v1:0",
display_name: "Amazon Nova Lite",
model_id: "us.anthropic.claude-3-5-haiku-20241022-v1:0",
display_name: "Claude 3.5 Haiku",
vision_supported: true,
context_size: 200_000,
},
DefaultModel {
model_id: "amazon.nova-micro-v1:0",
display_name: "Amazon Nova Micro",
vision_supported: false,
},
DefaultModel {
model_id: "deepseek.r1-v1:0",
display_name: "DeepSeek R1",
vision_supported: false,
model_id: "global.anthropic.claude-haiku-4-5-20251001-v1:0",
display_name: "Claude Haiku 4.5 (Global)",
vision_supported: true,
context_size: 200_000,
},
];
@@ -76,6 +128,7 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
context_size: m.context_size,
})
.collect();
for default in defaults {
@@ -92,6 +145,7 @@ pub fn get_effective_models(user_models: &[BedrockModelConfig]) -> Vec<BedrockMo
model_id: m.model_id.to_string(),
display_name: m.display_name.to_string(),
vision_supported: m.vision_supported,
context_size: m.context_size,
})
.collect()
}
+1 -1
View File
@@ -82,7 +82,7 @@ fn test_cross_region_prefix_unknown_region() {
fn test_get_effective_models_empty_returns_defaults() {
let models = get_effective_models(&[]);
assert_eq!(models.len(), DEFAULT_BEDROCK_MODELS.len());
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6");
assert_eq!(models[0].model_id, "anthropic.claude-opus-4-6[1m]");
assert_eq!(models[0].display_name, "Claude Opus 4.6");
}
+55 -23
View File
@@ -408,12 +408,10 @@ fn extract_input_messages(request: &api::Request) -> Vec<api::Message> {
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::UserQuery(
api::message::UserQuery {
query: prompt,
..Default::default()
},
)),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: prompt,
..Default::default()
})),
});
}
_ => {}
@@ -440,8 +438,13 @@ pub fn sanitize_messages_for_bedrock(messages: &mut Vec<ConversationMessage>) {
/// If the last message is an assistant message (e.g. after compaction),
/// append a continuation prompt.
fn ensure_ends_with_user_message(messages: &mut Vec<ConversationMessage>) {
if messages.last().is_some_and(|m| m.role == MessageRole::Assistant) {
log::info!("[bedrock] Appending continuation prompt (conversation ended with assistant message)");
if messages
.last()
.is_some_and(|m| m.role == MessageRole::Assistant)
{
log::info!(
"[bedrock] Appending continuation prompt (conversation ended with assistant message)"
);
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text("Continue.".to_string()),
@@ -514,12 +517,24 @@ fn strip_tool_result_parts(content: &mut MessageContent) {
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::ToolUse { tool_use_id, name, input } => {
MessageContent::ToolUse { tool_use_id, name, input }
}
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
MessageContent::ToolResult { tool_use_id, content: c, is_error }
}
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => MessageContent::ToolUse {
tool_use_id,
name,
input,
},
ContentPart::ToolResult {
tool_use_id,
content: c,
is_error,
} => MessageContent::ToolResult {
tool_use_id,
content: c,
is_error,
},
};
}
}
@@ -544,12 +559,24 @@ fn strip_orphaned_tool_result_parts(
let part = parts.remove(0);
*content = match part {
ContentPart::Text(t) => MessageContent::Text(t),
ContentPart::ToolUse { tool_use_id, name, input } => {
MessageContent::ToolUse { tool_use_id, name, input }
}
ContentPart::ToolResult { tool_use_id, content: c, is_error } => {
MessageContent::ToolResult { tool_use_id, content: c, is_error }
}
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => MessageContent::ToolUse {
tool_use_id,
name,
input,
},
ContentPart::ToolResult {
tool_use_id,
content: c,
is_error,
} => MessageContent::ToolResult {
tool_use_id,
content: c,
is_error,
},
};
}
}
@@ -565,7 +592,10 @@ fn is_empty_content(content: &MessageContent) -> bool {
}
}
fn collect_tool_use_ids_into(content: &MessageContent, ids: &mut std::collections::HashSet<String>) {
fn collect_tool_use_ids_into(
content: &MessageContent,
ids: &mut std::collections::HashSet<String>,
) {
match content {
MessageContent::ToolUse { tool_use_id, .. } => {
ids.insert(tool_use_id.clone());
@@ -895,7 +925,9 @@ pub fn extract_system_prompt(request: &api::Request) -> Option<String> {
prompt.push_str("- For tasks that require interacting with external services, web UIs, or capabilities not covered by your filesystem and shell tools, use your MCP tools.\n");
prompt.push_str("- For complex multi-step tasks where a single script would replace many tool calls, write code (Python, Node, bash) via `run_shell_command` to reduce round-trips. But never use scripts for simple operations that a single command handles.\n\n");
prompt.push_str("**Critical rules:**\n");
prompt.push_str("- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n");
prompt.push_str(
"- Use ONLY the tools in your tool configuration. Never invent or guess tool names.\n",
);
prompt.push_str("- ALWAYS pass `--no-pager` (or equivalent) flags to CLI tools like git, less, man, etc. Tools that lock stdin will freeze the session.\n");
prompt.push_str("- Output text directly in your response instead of using `echo` — echo requires user approval and adds unnecessary friction.\n");
prompt.push_str("- Use absolute paths based on the working directory shown above.\n");
@@ -1442,6 +1474,7 @@ fn is_server_result_error(text: &str) -> bool {
|| lower.starts_with("mcp tool error:")
|| lower.starts_with("search codebase error:")
|| lower.starts_with("failed to read files")
|| lower.starts_with("user cancelled")
|| (lower.starts_with("exit code:") && !lower.starts_with("exit code: 0"))
}
@@ -1533,7 +1566,6 @@ fn convert_proto_message_for_test(msg: &api::Message) -> Option<ConversationMess
convert_proto_message(msg)
}
#[cfg(test)]
#[path = "request_translator_tests.rs"]
mod tests;
@@ -1,7 +1,7 @@
use serde_json::json;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
use super::sanitize_messages_for_bedrock;
use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageContent, MessageRole};
#[test]
fn test_sanitize_messages_prepends_synthetic_tool_result_before_existing_user_text() {
+302 -76
View File
@@ -65,7 +65,7 @@ pub fn bedrock_stream_to_response_events(
diagnostic_logger: Option<Arc<BedrockDiagnosticLogger>>,
messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
model_id: String,
is_summarization: bool,
tool_result_archive: Vec<ConversationMessage>,
) -> BoxStream<'static, Event> {
let request_id = Uuid::new_v4().to_string();
let conversation_id = Uuid::new_v4().to_string();
@@ -224,6 +224,50 @@ pub fn bedrock_stream_to_response_events(
current_tool_use_id.clear();
current_tool_name.clear();
current_tool_input_json.clear();
} else if current_tool_name == "recall_tool_history" {
// Handle recall_tool_history locally by searching
// the conversation messages that were sent.
log::info!("[bedrock] Handling recall_tool_history locally");
let input_json: serde_json::Value = serde_json::from_str(&current_tool_input_json)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
let search_query = input_json.get("search_query")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_name_filter = input_json.get("tool_name")
.and_then(|v| v.as_str())
.unwrap_or("");
let tool_use_id = input_json.get("tool_use_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let offset = input_json.get("offset_from_end")
.and_then(|v| v.as_u64())
.unwrap_or(0) as usize;
let recall_result = match messages_sent.lock() {
Ok(sent) => recall_from_history(
&sent,
&tool_result_archive,
search_query,
tool_name_filter,
tool_use_id,
offset,
),
Err(_) => "Error: could not access conversation history.".to_string(),
};
history_tool_calls.push(ContentPart::ToolUse {
tool_use_id: current_tool_use_id.clone(),
name: current_tool_name.clone(),
input: input_json,
});
synthetic_tool_results.push(ContentPart::ToolResult {
tool_use_id: current_tool_use_id.clone(),
content: recall_result,
is_error: false,
});
current_tool_use_id.clear();
current_tool_name.clear();
current_tool_input_json.clear();
} else if !is_known_tool(&current_tool_name) {
// Unknown/hallucinated tool: record it in history
// with a paired error result so the conversation
@@ -233,6 +277,10 @@ pub fn bedrock_stream_to_response_events(
"[bedrock] Model called unknown tool '{}' (id={}), synthesizing error result",
current_tool_name, current_tool_use_id
);
let error_text = format!(
"Error: '{}' is not a valid tool. Available tools are: run_shell_command, read_files, apply_file_diffs, grep, file_glob. Please use one of these tools instead.",
current_tool_name
);
let input_json: serde_json::Value = serde_json::from_str(&current_tool_input_json)
.unwrap_or(serde_json::Value::Object(serde_json::Map::new()));
history_tool_calls.push(ContentPart::ToolUse {
@@ -245,12 +293,20 @@ pub fn bedrock_stream_to_response_events(
// fix it up later (and so the executor doesn't hang).
synthetic_tool_results.push(ContentPart::ToolResult {
tool_use_id: current_tool_use_id.clone(),
content: format!(
"Error: '{}' is not a valid tool. Available tools are: run_shell_command, read_files, apply_file_diffs, grep, file_glob. Please use one of these tools instead.",
current_tool_name
),
content: error_text.clone(),
is_error: true,
});
// Emit to the UI so the user sees the failed tool call
let error_msg_id = Uuid::new_v4().to_string();
let error_display = format!(
"Failed tool call: `{}`\n\n{}",
current_tool_name, error_text
);
yield Ok(build_add_agent_output_message(
&task_id,
&error_msg_id,
&error_display,
));
if let Some(ref logger) = diagnostic_logger {
logger.log_stream_event(&format!(
"UnknownToolCall: name={}, id={} — synthesized error result",
@@ -496,7 +552,7 @@ pub fn bedrock_stream_to_response_events(
cache_read_input_tokens,
cache_write_input_tokens,
&model_id,
is_summarization,
false,
);
yield Ok(finished_event);
};
@@ -537,12 +593,10 @@ fn build_user_query_message(task_id: &str, query_text: &str) -> ResponseEvent {
timestamp: None,
server_message_data: String::new(),
citations: vec![],
message: Some(api::message::Message::UserQuery(
api::message::UserQuery {
query: query_text.to_string(),
..Default::default()
},
)),
message: Some(api::message::Message::UserQuery(api::message::UserQuery {
query: query_text.to_string(),
..Default::default()
})),
};
let action = ClientAction {
@@ -656,7 +710,7 @@ pub(super) fn build_stream_finished(
/// Nova Lite: input $0.06, output $0.24
/// Nova Micro: input $0.035, output $0.14
/// DeepSeek R1: input $1.35, output $5.40
fn estimate_cost_cents(
pub fn estimate_cost_cents(
input_tokens: u32,
output_tokens: u32,
cache_read_tokens: u32,
@@ -666,23 +720,22 @@ fn estimate_cost_cents(
let lower = model_id.to_lowercase();
// (input_per_1m, output_per_1m, cache_read_per_1m, cache_write_per_1m) in dollars
let (input_rate, output_rate, cache_read_rate, cache_write_rate) =
if lower.contains("opus") {
(15.0, 75.0, 1.50, 18.75)
} else if lower.contains("haiku") {
(0.80, 4.0, 0.08, 1.0)
} else if lower.contains("nova-pro") {
(0.80, 3.20, 0.0, 0.0)
} else if lower.contains("nova-lite") {
(0.06, 0.24, 0.0, 0.0)
} else if lower.contains("nova-micro") {
(0.035, 0.14, 0.0, 0.0)
} else if lower.contains("deepseek") {
(1.35, 5.40, 0.0, 0.0)
} else {
// Default to Sonnet pricing
(3.0, 15.0, 0.30, 3.75)
};
let (input_rate, output_rate, cache_read_rate, cache_write_rate) = if lower.contains("opus") {
(15.0, 75.0, 1.50, 18.75)
} else if lower.contains("haiku") {
(0.80, 4.0, 0.08, 1.0)
} else if lower.contains("nova-pro") {
(0.80, 3.20, 0.0, 0.0)
} else if lower.contains("nova-lite") {
(0.06, 0.24, 0.0, 0.0)
} else if lower.contains("nova-micro") {
(0.035, 0.14, 0.0, 0.0)
} else if lower.contains("deepseek") {
(1.35, 5.40, 0.0, 0.0)
} else {
// Default to Sonnet pricing
(3.0, 15.0, 0.30, 3.75)
};
// Convert from dollars per 1M tokens to cents per token
let input_cost = input_tokens as f64 * input_rate * 100.0 / 1_000_000.0;
@@ -914,22 +967,22 @@ fn build_tool_call_message(
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::WriteToLongRunningShellCommand(
api::message::tool_call::WriteToLongRunningShellCommand {
input: text_input.into_bytes(),
mode: None,
command_id: String::new(),
},
))
}
"read_shell_command_output" => {
Some(api::message::tool_call::Tool::ReadShellCommandOutput(
api::message::tool_call::ReadShellCommandOutput {
command_id: String::new(),
delay: None,
},
))
Some(
api::message::tool_call::Tool::WriteToLongRunningShellCommand(
api::message::tool_call::WriteToLongRunningShellCommand {
input: text_input.into_bytes(),
mode: None,
command_id: String::new(),
},
),
)
}
"read_shell_command_output" => Some(api::message::tool_call::Tool::ReadShellCommandOutput(
api::message::tool_call::ReadShellCommandOutput {
command_id: String::new(),
delay: None,
},
)),
"read_mcp_resource" => {
let server_id = input
.get("server_id")
@@ -942,10 +995,7 @@ fn build_tool_call_message(
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::ReadMcpResource(
api::message::tool_call::ReadMcpResource {
uri,
server_id,
},
api::message::tool_call::ReadMcpResource { uri, server_id },
))
}
"read_documents" => {
@@ -994,8 +1044,16 @@ fn build_tool_call_message(
.filter_map(|d| {
Some(api::message::tool_call::edit_documents::DocumentDiff {
document_id: d.get("document_id")?.as_str()?.to_string(),
search: d.get("search").and_then(|v| v.as_str()).unwrap_or("").to_string(),
replace: d.get("replace").and_then(|v| v.as_str()).unwrap_or("").to_string(),
search: d
.get("search")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
replace: d
.get("replace")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
})
})
.collect()
@@ -1006,20 +1064,34 @@ fn build_tool_call_message(
))
}
"start_agent" => {
let name = input.get("name").and_then(|v| v.as_str()).unwrap_or("").to_string();
let prompt = input.get("prompt").and_then(|v| v.as_str()).unwrap_or("").to_string();
Some(api::message::tool_call::Tool::StartAgent(
api::StartAgent {
name,
prompt,
execution_mode: None,
lifecycle_subscription: None,
},
))
let name = input
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let prompt = input
.get("prompt")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::StartAgent(api::StartAgent {
name,
prompt,
execution_mode: None,
lifecycle_subscription: None,
}))
}
"send_message_to_agent" => {
let agent_id = input.get("agent_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let message = input.get("message").and_then(|v| v.as_str()).unwrap_or("").to_string();
let agent_id = input
.get("agent_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let message = input
.get("message")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::SendMessageToAgent(
api::SendMessageToAgent {
addresses: vec![agent_id],
@@ -1029,28 +1101,36 @@ fn build_tool_call_message(
))
}
"ask_user_question" => {
let question_text = input.get("question").and_then(|v| v.as_str()).unwrap_or("").to_string();
let question_text = input
.get("question")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let options: Vec<api::ask_user_question::Option> = input
.get("options")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|o| o.as_str())
.map(|label| api::ask_user_question::Option { label: label.to_string() })
.map(|label| api::ask_user_question::Option {
label: label.to_string(),
})
.collect()
})
.unwrap_or_default();
let question = api::ask_user_question::Question {
question_id: Uuid::new_v4().to_string(),
question: question_text,
question_type: Some(api::ask_user_question::question::QuestionType::MultipleChoice(
api::ask_user_question::MultipleChoice {
options,
is_multiselect: false,
supports_other: true,
recommended_option_index: 0,
},
)),
question_type: Some(
api::ask_user_question::question::QuestionType::MultipleChoice(
api::ask_user_question::MultipleChoice {
options,
is_multiselect: false,
supports_other: true,
recommended_option_index: 0,
},
),
),
};
Some(api::message::tool_call::Tool::AskUserQuestion(
api::AskUserQuestion {
@@ -1059,7 +1139,11 @@ fn build_tool_call_message(
))
}
"read_skill" => {
let skill = input.get("skill").and_then(|v| v.as_str()).unwrap_or("").to_string();
let skill = input
.get("skill")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::ReadSkill(
api::message::tool_call::ReadSkill {
name: skill.clone(),
@@ -1070,7 +1154,11 @@ fn build_tool_call_message(
))
}
"fetch_conversation" => {
let conversation_id = input.get("conversation_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
let conversation_id = input
.get("conversation_id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
Some(api::message::tool_call::Tool::FetchConversation(
api::message::tool_call::FetchConversation { conversation_id },
))
@@ -1103,7 +1191,10 @@ fn build_tool_call_message(
let (server_name, mcp_tool_name) = if parts.len() == 3 {
(parts[1].to_string(), parts[2].to_string())
} else {
(String::new(), name.strip_prefix("mcp__").unwrap_or(name).to_string())
(
String::new(),
name.strip_prefix("mcp__").unwrap_or(name).to_string(),
)
};
let args = json_to_prost_struct(&input);
Some(api::message::tool_call::Tool::CallMcpTool(
@@ -1193,8 +1284,143 @@ const KNOWN_TOOLS: &[&str] = &[
"suggest_next_prompt",
"read_skill",
"fetch_conversation",
"recall_tool_history",
];
fn is_known_tool(name: &str) -> bool {
KNOWN_TOOLS.contains(&name) || name.starts_with("mcp__")
}
/// Searches conversation message history for tool call results matching the given criteria.
fn recall_from_history(
messages: &[ConversationMessage],
archive: &[ConversationMessage],
search_query: &str,
tool_name_filter: &str,
tool_use_id: &str,
offset_from_end: usize,
) -> String {
use super::convert::{ContentPart, MessageContent};
struct ToolEntry {
tool_use_id: String,
name: String,
input: String,
result: String,
}
let mut tool_entries: Vec<ToolEntry> = Vec::new();
let mut pending_tool_uses: Vec<(String, String, String)> = Vec::new(); // (id, name, input)
for msg in messages.iter().chain(archive.iter()) {
match &msg.content {
MessageContent::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((tool_use_id.clone(), name.clone(), input.to_string()));
}
MessageContent::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse {
tool_use_id,
name,
input,
} => {
pending_tool_uses.push((
tool_use_id.clone(),
name.clone(),
input.to_string(),
));
}
ContentPart::ToolResult {
tool_use_id,
content,
..
} => {
if let Some(pos) = pending_tool_uses
.iter()
.position(|(id, _, _)| id == tool_use_id)
{
let (tuid, name, input) = pending_tool_uses.remove(pos);
tool_entries.push(ToolEntry {
tool_use_id: tuid,
name,
input,
result: content.clone(),
});
}
}
_ => {}
}
}
}
_ => {}
}
}
let filtered: Vec<&ToolEntry> = tool_entries
.iter()
.filter(|entry| {
if !tool_use_id.is_empty() && entry.tool_use_id != tool_use_id {
return false;
}
if !tool_name_filter.is_empty() && entry.name != tool_name_filter {
return false;
}
if !search_query.is_empty() {
let haystack = format!("{} {} {}", entry.name, entry.input, entry.result);
let query_lower = search_query.to_lowercase();
if !haystack.to_lowercase().contains(&query_lower) {
return false;
}
}
true
})
.collect();
if filtered.is_empty() {
return "No matching tool calls found in conversation history.".to_string();
}
// Get the entry at offset_from_end (0 = most recent)
let idx = if offset_from_end >= filtered.len() {
0
} else {
filtered.len() - 1 - offset_from_end
};
let entry = &filtered[idx];
let result_display = if entry.result.len() > 50000 {
let trunc = entry.result.chars().take(50000).collect::<String>();
format!("{trunc}... [truncated, {} total chars]", entry.result.len())
} else {
entry.result.clone()
};
format!(
"Tool: {}\nTool Use ID: {}\nInput: {}\nResult:\n{}",
entry.name, entry.tool_use_id, entry.input, result_display
)
}
+63 -15
View File
@@ -19,7 +19,7 @@ fn test_build_stream_init_has_valid_ids() {
#[test]
fn test_build_stream_finished_done_reason() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let event = build_stream_finished(reason, 100, 50, 20, 10, "anthropic.claude-sonnet-4-6");
let event = build_stream_finished(reason, 100, 50, 20, 10, "anthropic.claude-sonnet-4-6", false);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -53,7 +53,7 @@ fn test_build_stream_finished_done_reason() {
#[test]
fn test_build_stream_finished_max_token_limit() {
let reason = stream_finished::Reason::MaxTokenLimit(stream_finished::ReachedMaxTokenLimit {});
let event = build_stream_finished(reason, 200, 100, 0, 0, "anthropic.claude-sonnet-4-6");
let event = build_stream_finished(reason, 200, 100, 0, 0, "anthropic.claude-sonnet-4-6", false);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -69,7 +69,7 @@ fn test_build_stream_finished_max_token_limit() {
#[test]
fn test_build_stream_finished_other_reason() {
let reason = stream_finished::Reason::Other(stream_finished::Other {});
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6", false);
match event.r#type {
Some(api::response_event::Type::Finished(finished)) => {
@@ -121,16 +121,34 @@ fn test_build_create_task_has_no_parent() {
#[test]
fn test_context_window_for_model_1m_marker() {
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6[1m]"), 1_000_000);
assert_eq!(context_window_for_model("us.anthropic.claude-opus-4-6[1M]"), 1_000_000);
assert_eq!(context_window_for_model("anthropic.claude-sonnet-4-6[1m]"), 1_000_000);
assert_eq!(
context_window_for_model("anthropic.claude-opus-4-6[1m]"),
1_000_000
);
assert_eq!(
context_window_for_model("us.anthropic.claude-opus-4-6[1M]"),
1_000_000
);
assert_eq!(
context_window_for_model("anthropic.claude-sonnet-4-6[1m]"),
1_000_000
);
}
#[test]
fn test_context_window_for_model_standard_claude() {
assert_eq!(context_window_for_model("anthropic.claude-opus-4-6"), 200_000);
assert_eq!(context_window_for_model("us.anthropic.claude-sonnet-4-6"), 200_000);
assert_eq!(context_window_for_model("anthropic.claude-haiku-4-5-20251001-v1:0"), 200_000);
assert_eq!(
context_window_for_model("anthropic.claude-opus-4-6"),
200_000
);
assert_eq!(
context_window_for_model("us.anthropic.claude-sonnet-4-6"),
200_000
);
assert_eq!(
context_window_for_model("anthropic.claude-haiku-4-5-20251001-v1:0"),
200_000
);
}
#[test]
@@ -148,11 +166,35 @@ fn test_context_window_for_model_deepseek() {
#[test]
fn test_cost_varies_by_model() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let opus_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-opus-4-6");
let opus_event = build_stream_finished(
reason.clone(),
1000,
1000,
0,
0,
"anthropic.claude-opus-4-6",
false,
);
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let sonnet_event = build_stream_finished(reason.clone(), 1000, 1000, 0, 0, "anthropic.claude-sonnet-4-6");
let sonnet_event = build_stream_finished(
reason.clone(),
1000,
1000,
0,
0,
"anthropic.claude-sonnet-4-6",
false,
);
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let haiku_event = build_stream_finished(reason, 1000, 1000, 0, 0, "anthropic.claude-haiku-4-5-20251001-v1:0");
let haiku_event = build_stream_finished(
reason,
1000,
1000,
0,
0,
"anthropic.claude-haiku-4-5-20251001-v1:0",
false,
);
let opus_cost = match opus_event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
@@ -168,14 +210,20 @@ fn test_cost_varies_by_model() {
};
assert!(opus_cost > sonnet_cost, "Opus should cost more than Sonnet");
assert!(sonnet_cost > haiku_cost, "Sonnet should cost more than Haiku");
assert!(haiku_cost > 0.0, "All costs should be positive for non-zero tokens");
assert!(
sonnet_cost > haiku_cost,
"Sonnet should cost more than Haiku"
);
assert!(
haiku_cost > 0.0,
"All costs should be positive for non-zero tokens"
);
}
#[test]
fn test_cost_zero_for_zero_tokens() {
let reason = stream_finished::Reason::Done(stream_finished::Done {});
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6");
let event = build_stream_finished(reason, 0, 0, 0, 0, "anthropic.claude-sonnet-4-6", false);
let cost = match event.r#type {
Some(api::response_event::Type::Finished(f)) => f.token_usage[0].cost_in_cents,
+1 -3
View File
@@ -3,9 +3,7 @@ use crate::ai::bedrock::external_config::ExternalBedrockConfig;
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use galaxyui::{
elements::{
Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text,
},
elements::{Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
+49 -12
View File
@@ -13,9 +13,9 @@ pub struct TranslatorRequest {
pub model_id: String,
pub root_task_id: Option<String>,
pub bedrock_message_history: Vec<ConversationMessage>,
pub bedrock_compact_summary: Option<String>,
pub bedrock_tool_result_archive: Vec<ConversationMessage>,
pub bedrock_progressive_summary: Option<String>,
pub bedrock_messages_sent: Arc<Mutex<Vec<ConversationMessage>>>,
pub is_summarization: bool,
}
pub async fn execute(
@@ -45,7 +45,7 @@ pub async fn execute(
let mut model_id = params.model_id;
if model_id.is_empty() || model_id == "auto" {
// Fall back to default if nothing is set
model_id = "us.anthropic.claude-opus-4-6".to_string();
model_id = "us.anthropic.claude-opus-4-6[1m]".to_string();
}
log::info!("[bedrock] Translator: model={model_id}, task_id={task_id}, needs_create_task={needs_create_task}");
@@ -60,13 +60,38 @@ pub async fn execute(
request_translator::inject_input_messages_into_task(request);
let new_input_messages = request_translator::extract_new_input_messages(request);
let new_input_count = new_input_messages.len();
let mut messages = Vec::new();
// Prepend progressive summary as the first message pair if present
if let Some(ref summary) = params.bedrock_progressive_summary {
use crate::ai::bedrock::convert::{MessageContent, MessageRole};
messages.push(ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!(
"<conversation-history-summary>\n{}\n</conversation-history-summary>\n\n\
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges.",
summary
)),
});
messages.push(ConversationMessage {
role: MessageRole::Assistant,
content: MessageContent::Text(
"Understood, I have the prior context. Continuing with the recent conversation."
.to_string(),
),
});
}
let history_len = params.bedrock_message_history.len();
messages.extend(params.bedrock_message_history);
let mut messages = params.bedrock_message_history;
if !new_input_messages.is_empty() {
log::info!(
"[bedrock] Appending {} new input messages to history of {}",
new_input_messages.len(),
messages.len()
history_len
);
messages.extend(new_input_messages);
}
@@ -74,20 +99,24 @@ pub async fn execute(
request_translator::sanitize_messages_for_bedrock(&mut messages);
let system_prompt = request_translator::extract_system_prompt(request);
let compact_summary = params.bedrock_compact_summary;
let tools = request_translator::extract_tools(request);
log::info!(
"[bedrock] Sending {} messages, system_prompt={}, compact_summary={}, tools={}",
"[bedrock] Sending {} messages, system_prompt={}, progressive_summary={}, tools={}",
messages.len(),
system_prompt.is_some(),
compact_summary.is_some(),
params.bedrock_progressive_summary.is_some(),
tools.len()
);
for (i, msg) in messages.iter().enumerate() {
let content_desc = describe_message_content(&msg.content);
log::info!("[bedrock] msg[{}]: role={:?}, content={}", i, msg.role, content_desc);
log::info!(
"[bedrock] msg[{}]: role={:?}, content={}",
i,
msg.role,
content_desc
);
}
let user_query_text = request_translator::extract_user_query_text(request);
@@ -99,7 +128,7 @@ pub async fn execute(
needs_create_task,
messages.clone(),
system_prompt,
compact_summary,
None, // progressive summary is in messages array, not system prompt
tools,
64000,
None,
@@ -107,12 +136,20 @@ pub async fn execute(
user_query_text,
diagnostic_logger,
params.bedrock_messages_sent.clone(),
params.is_summarization,
params.bedrock_tool_result_archive,
)
.await?;
if let Ok(mut sent) = params.bedrock_messages_sent.lock() {
*sent = messages;
// Only persist the actual conversation history (history + new inputs), not the
// ephemeral prepended summary pair, so we don't duplicate the summary on every
// subsequent write-back. The summary is prepended at request time each turn.
let persistent_count = history_len + new_input_count;
if persistent_count > 0 && messages.len() >= persistent_count {
*sent = messages.split_off(messages.len() - persistent_count);
} else {
*sent = messages;
}
}
Ok(stream)