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
+5 -1
View File
@@ -5,7 +5,7 @@ description = "Galaxy - AI-powered terminal"
edition = "2021"
autobins = false
name = "galaxy"
version = "1.6.2"
version = "1.6.3"
publish.workspace = true
license.workspace = true
@@ -123,6 +123,7 @@ lsp-types = "0.97.0"
indexmap = { version = "2.0.2", features = ["serde"] }
input_classifier.workspace = true
instant.workspace = true
local_inference = { workspace = true, optional = true }
ipc.workspace = true
itertools.workspace = true
kmeans_colors = { version = "0.5", default-features = false, features = [
@@ -613,6 +614,7 @@ default = [
"drag_tabs_to_windows",
"plugin_host",
"file_and_diff_set_comments",
"local_ai",
]
# Enable this feature to automatically perform heap profiling. NOTE: This will
# substantially slow down program execution.
@@ -832,6 +834,8 @@ projects = []
vim_code_editor = []
allow_opening_file_links_using_editor_env = []
nld_improvements = ["nld_onnx_model"]
local_ai = ["dep:local_inference"]
local_ai_metal = ["local_ai", "local_inference/metal"]
undo_closed_panes = []
revert_diff_hunk = []
code_review_save_changes = []
+1 -1
View File
@@ -69,7 +69,6 @@ fn main() -> Result<()> {
let plugin_src = Path::new("DockTilePlugin/GalaxyDockTilePlugin.docktileplugin");
let plugin_dst = target_dir.join("GalaxyDockTilePlugin.docktileplugin");
if !status.success() {
fs::remove_dir_all(plugin_src).expect("Failed to clean up plugin directory");
panic!("Dock tile plugin build failed");
@@ -170,6 +169,7 @@ fn add_features(target_family: &str, target_os: &str) {
if env::var("PROFILE").ok().is_some_and(|val| val == "debug") {
println!("cargo:rustc-cfg=feature=\"agent_mode_debug\"");
}
}
fn build_and_link_sentry() {
+9 -7
View File
@@ -134,16 +134,18 @@ pub struct RequestParams {
/// Full Bedrock conversation history for direct Bedrock calls.
/// When present, the Bedrock path uses this instead of extracting from task_context.
pub bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Compacted summary from a prior summarization pass. Injected into the system
/// prompt so it benefits from system-level caching.
pub bedrock_compact_summary: Option<String>,
/// Progressive summary of older conversation history. Prepended as the first
/// message pair in the messages array sent to Bedrock.
pub bedrock_progressive_summary: Option<String>,
/// Archived tool_use/tool_result pairs from previous summarization drains.
/// Passed to the Bedrock translator so `recall_tool_history` can search archived
/// results even after they've been summarized away from live history.
pub bedrock_tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Populated by the Bedrock path after building the message list.
/// Contains the full messages sent (old history + new input) so the controller
/// can store them back into the conversation for the next request cycle.
pub bedrock_messages_sent:
std::sync::Arc<std::sync::Mutex<Vec<crate::ai::bedrock::convert::ConversationMessage>>>,
/// Whether this request is a conversation summarization/compaction.
pub is_summarization: bool,
}
pub type Event = Result<warp_multi_agent_api::ResponseEvent, Arc<AIApiError>>;
@@ -331,9 +333,9 @@ impl RequestParams {
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_compact_summary: None,
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
is_summarization: false,
}
}
}
@@ -85,6 +85,8 @@ pub fn convert_conversation_data_to_ai_conversation(
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
RestorationMode::Continue => AgentConversationData {
server_conversation_token: Some(
@@ -104,6 +106,8 @@ pub fn convert_conversation_data_to_ai_conversation(
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
};
+2 -2
View File
@@ -153,9 +153,9 @@ pub async fn generate_multi_agent_output(
model_id,
root_task_id: params.root_task_id.clone(),
bedrock_message_history: params.bedrock_message_history.clone(),
bedrock_compact_summary: params.bedrock_compact_summary.clone(),
bedrock_tool_result_archive: params.bedrock_tool_result_archive.clone(),
bedrock_progressive_summary: params.bedrock_progressive_summary.clone(),
bedrock_messages_sent: params.bedrock_messages_sent.clone(),
is_summarization: params.is_summarization,
};
match translator::execute(translator_request, &mut request).await {
+2 -2
View File
@@ -41,9 +41,9 @@ fn request_params_with_ask_user_question_enabled(ask_user_question_enabled: bool
parent_agent_id: None,
agent_name: None,
bedrock_message_history: Vec::new(),
bedrock_compact_summary: None,
bedrock_progressive_summary: None,
bedrock_tool_result_archive: Vec::new(),
bedrock_messages_sent: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
is_summarization: false,
}
}
+124 -44
View File
@@ -236,19 +236,28 @@ pub struct AIConversation {
/// request cycles. This is the source of truth for what Bedrock sees.
bedrock_message_history: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Archived tool_use/tool_result pairs from messages that were drained by
/// progressive summarization. Kept separately so `recall_tool_history` can
/// find results even after they've been summarized away from live history.
tool_result_archive: Vec<crate::ai::bedrock::convert::ConversationMessage>,
/// Live context token count from the most recent Bedrock response.
/// This is the actual input_tokens reported by Bedrock — represents the current
/// context window size, NOT a cumulative total.
current_context_tokens: u32,
/// When set, contains the compacted summary of prior conversation history.
/// Injected into the system prompt (not the messages array) so it benefits
/// from system-level caching at the 1-hour TTL.
compact_summary: Option<String>,
/// Progressive summary of older conversation history.
/// Prepended as the first message in the messages array sent to Bedrock,
/// while recent messages are kept verbatim.
progressive_summary: Option<String>,
/// Guards against repeated auto-compact triggers within the same high-usage window.
/// Set to true when auto-compact fires; reset when summarization completes.
has_pending_auto_compact: bool,
/// The number of messages that were drained from bedrock_message_history
/// when the progressive summary was last produced. Used to handle race
/// conditions if new messages arrive during an in-flight summarization.
messages_summarized_up_to: usize,
/// Guards against concurrent progressive summarization requests.
has_pending_progressive_summary: bool,
subagent_retry_count: u8,
}
@@ -303,9 +312,11 @@ impl AIConversation {
is_remote_child: false,
last_event_sequence: None,
bedrock_message_history: Vec::new(),
compact_summary: None,
tool_result_archive: Vec::new(),
progressive_summary: None,
messages_summarized_up_to: 0,
current_context_tokens: 0,
has_pending_auto_compact: false,
has_pending_progressive_summary: false,
subagent_retry_count: 0,
}
}
@@ -401,6 +412,8 @@ impl AIConversation {
run_id,
autoexecute_override,
last_event_sequence,
progressive_summary,
messages_summarized_up_to,
) = if let Some(data) = conversation_data {
let server_conversation_token = data
.server_conversation_token
@@ -432,6 +445,8 @@ impl AIConversation {
AIConversationAutoexecuteMode::default()
};
let last_event_sequence = data.last_event_sequence;
let progressive_summary = data.progressive_summary;
let messages_summarized_up_to = data.messages_summarized_up_to;
(
server_conversation_token,
@@ -445,6 +460,8 @@ impl AIConversation {
run_id,
autoexecute_override,
last_event_sequence,
progressive_summary,
messages_summarized_up_to,
)
} else {
(
@@ -459,6 +476,8 @@ impl AIConversation {
None,
AIConversationAutoexecuteMode::default(),
None,
None,
0,
)
};
@@ -526,9 +545,11 @@ impl AIConversation {
is_remote_child: false,
last_event_sequence,
bedrock_message_history,
compact_summary: None,
tool_result_archive: Vec::new(),
progressive_summary,
messages_summarized_up_to,
current_context_tokens: 0,
has_pending_auto_compact: false,
has_pending_progressive_summary: false,
subagent_retry_count: 0,
})
}
@@ -547,6 +568,56 @@ impl AIConversation {
&mut self.bedrock_message_history
}
pub fn tool_result_archive(&self) -> &[crate::ai::bedrock::convert::ConversationMessage] {
&self.tool_result_archive
}
pub fn archive_tool_results(
&mut self,
messages: Vec<crate::ai::bedrock::convert::ConversationMessage>,
) {
use crate::ai::bedrock::convert::{ContentPart, MessageContent};
let mut pending_tool_uses: Vec<crate::ai::bedrock::convert::ConversationMessage> =
Vec::new();
for msg in messages {
match &msg.content {
MessageContent::ToolUse { .. } => pending_tool_uses.push(msg),
MessageContent::ToolResult { .. } => {
if !pending_tool_uses.is_empty() {
let tool_use = pending_tool_uses.remove(pending_tool_uses.len() - 1);
self.tool_result_archive.push(tool_use);
}
self.tool_result_archive.push(msg);
}
MessageContent::MultiPart(parts) => {
for part in parts {
match part {
ContentPart::ToolUse { .. } => {
pending_tool_uses.push(msg.clone());
}
ContentPart::ToolResult { .. } => {
if !pending_tool_uses.is_empty() {
let tool_use =
pending_tool_uses.remove(pending_tool_uses.len() - 1);
self.tool_result_archive.push(tool_use);
}
self.tool_result_archive.push(msg.clone());
}
_ => {}
}
}
}
_ => {}
}
}
for tool_use in pending_tool_uses {
self.tool_result_archive.push(tool_use);
}
}
pub fn append_to_bedrock_history(
&mut self,
messages: Vec<crate::ai::bedrock::convert::ConversationMessage>,
@@ -554,12 +625,21 @@ impl AIConversation {
self.bedrock_message_history.extend(messages);
}
pub fn compact_summary(&self) -> Option<&str> {
self.compact_summary.as_deref()
pub fn progressive_summary(&self) -> Option<&str> {
self.progressive_summary.as_deref()
}
pub fn set_compact_summary(&mut self, summary: Option<String>) {
self.compact_summary = summary;
pub fn set_progressive_summary(&mut self, summary: Option<String>, messages_summarized: usize) {
self.progressive_summary = summary;
self.messages_summarized_up_to = messages_summarized;
}
pub fn messages_summarized_up_to(&self) -> usize {
self.messages_summarized_up_to
}
pub fn reset_messages_summarized_up_to(&mut self) {
self.messages_summarized_up_to = 0;
}
/// Assigns fresh exchange IDs to all exchanges in this conversation.
@@ -601,12 +681,12 @@ impl AIConversation {
self.current_context_tokens = tokens;
}
pub fn has_pending_auto_compact(&self) -> bool {
self.has_pending_auto_compact
pub fn has_pending_progressive_summary(&self) -> bool {
self.has_pending_progressive_summary
}
pub fn set_has_pending_auto_compact(&mut self, value: bool) {
self.has_pending_auto_compact = value;
pub fn set_has_pending_progressive_summary(&mut self, value: bool) {
self.has_pending_progressive_summary = value;
}
pub fn credits_spent(&self) -> f32 {
@@ -1424,22 +1504,18 @@ impl AIConversation {
self.all_exchanges()
.into_iter()
.flat_map(|exchange| {
exchange
.output_status
.output()
.into_iter()
.map(|output| {
output
.get()
.actions()
.filter(|a| {
matches!(
a.action,
super::AIAgentActionType::RequestCommandOutput { .. }
)
})
.count()
})
exchange.output_status.output().into_iter().map(|output| {
output
.get()
.actions()
.filter(|a| {
matches!(
a.action,
super::AIAgentActionType::RequestCommandOutput { .. }
)
})
.count()
})
})
.sum()
}
@@ -1825,7 +1901,7 @@ impl AIConversation {
// so we only update the summarized flag if it's going from false to true.
if usage_metadata.summarized && !self.conversation_usage_metadata.was_summarized {
self.conversation_usage_metadata.was_summarized = usage_metadata.summarized;
self.has_pending_auto_compact = false;
self.has_pending_progressive_summary = false;
}
}
Ok(())
@@ -2783,16 +2859,12 @@ impl AIConversation {
Some(result) => match result {
Ok(todos_op) => todos_op,
Err(e) => {
log::error!(
"[bedrock] AppendToMessageContent failed: {e:?}"
);
log::error!("[bedrock] AppendToMessageContent failed: {e:?}");
return Err(e.into());
}
},
None => {
log::error!(
"[bedrock] AppendToMessageContent: TaskNotFound in task_store"
);
log::error!("[bedrock] AppendToMessageContent: TaskNotFound in task_store");
return Err(UpdateConversationError::TaskNotFound);
}
};
@@ -3125,6 +3197,8 @@ impl AIConversation {
run_id: self.task_id.map(|id| id.to_string()),
autoexecute_override: Some(self.autoexecute_override.into()),
last_event_sequence: self.last_event_sequence,
progressive_summary: self.progressive_summary.clone(),
messages_summarized_up_to: self.messages_summarized_up_to,
},
};
ctx.spawn(
@@ -3320,7 +3394,10 @@ impl AIConversation {
pub fn cache_miss_tokens(&self) -> u32 {
self.total_token_usage_by_model
.values()
.map(|u| u.total_input.saturating_sub(u.input_cache_read + u.input_cache_write))
.map(|u| {
u.total_input
.saturating_sub(u.input_cache_read + u.input_cache_write)
})
.sum()
}
@@ -3341,7 +3418,10 @@ impl AIConversation {
pub fn last_block_cache_miss_tokens(&self) -> u32 {
self.last_block_token_usage_by_model
.values()
.map(|u| u.total_input.saturating_sub(u.input_cache_read + u.input_cache_write))
.map(|u| {
u.total_input
.saturating_sub(u.input_cache_read + u.input_cache_write)
})
.sum()
}
@@ -155,6 +155,8 @@ fn test_display_status_uses_matching_conversation_for_in_progress_task() {
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -207,6 +209,8 @@ fn test_display_status_updates_when_blocked_conversation_resumes() {
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -283,6 +287,8 @@ fn test_display_status_terminal_task_state_overrides_matching_conversation() {
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -335,6 +341,8 @@ fn test_status_filter_uses_display_status_for_task_backed_conversations() {
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -770,6 +778,8 @@ fn test_get_tasks_and_conversations_prefers_task_when_task_id_matches_conversati
run_id: Some(task_id.clone()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -827,6 +837,8 @@ fn test_get_tasks_and_conversations_prefers_task_when_server_token_matches() {
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -883,6 +895,8 @@ fn test_get_tasks_and_conversations_keeps_unrelated_tasks_and_conversations() {
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -42,6 +42,10 @@ fn create_conversation_metadata(
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_cache_miss_tokens: 0,
total_cost_cents: 0.0,
},
metadata: create_mock_server_metadata(),
permissions: ServerPermissions::mock_personal(),
+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)
+3 -1
View File
@@ -482,7 +482,9 @@ impl BlocklistAIActionModel {
.and_then(|queue| queue.front())
.cloned()
else {
log::info!("[tool-debug] try_to_execute_available_actions: no more pending actions");
log::info!(
"[tool-debug] try_to_execute_available_actions: no more pending actions"
);
return;
};
+8 -2
View File
@@ -739,14 +739,20 @@ impl BlocklistAIActionExecutor {
);
match execution {
AnyActionExecution::NotReady => {
log::info!("[tool-debug] try_to_execute_action: NOT READY - action_id={:?}", action_id);
log::info!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
action_id
);
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
action: Box::new(action_clone),
}
}
AnyActionExecution::InvalidAction => {
log::error!("[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}", action_id);
log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
action_id
);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
@@ -55,7 +55,8 @@ impl AskUserQuestionExecutor {
// For child agent conversations, route the question to the parent
// for silent auto-answer instead of presenting UI to the user.
if let Some(parent_conversation_id) = self.parent_conversation_id(input.conversation_id, ctx)
if let Some(parent_conversation_id) =
self.parent_conversation_id(input.conversation_id, ctx)
{
let question_text = questions
.iter()
@@ -86,14 +87,12 @@ impl AskUserQuestionExecutor {
async move { receiver.recv().await },
|result, _ctx| match result {
Ok(AskUserQuestionDecision::Completed(answers)) => {
AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::Success { answers },
)
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success {
answers,
})
}
Ok(AskUserQuestionDecision::Cancelled) | Err(_) => {
AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::Cancelled,
)
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
},
);
@@ -141,12 +141,18 @@ impl CallMCPToolExecutor {
};
let Some(reconnecting_peer) = templatable_peer else {
log::error!("[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND", name_owned);
log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
name_owned
);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!("[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'", name_owned);
log::info!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned
);
let name_owned_inner = name_owned.clone();
ActionExecution::new_async(
@@ -111,7 +111,11 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}", patterns, path);
log::info!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns,
path
);
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
@@ -252,7 +252,11 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}", queries, path);
log::info!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
queries,
path
);
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_type = self.active_session.as_ref(ctx).shell_type(ctx);
@@ -151,10 +151,16 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}", id);
log::info!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
id
);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!("[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}", id);
log::warn!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
id
);
return ActionExecution::NotReady;
};
@@ -68,7 +68,9 @@ impl StartAgentExecutor {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self { pending: Vec::new() }
Self {
pending: Vec::new(),
}
}
fn handle_history_event(
@@ -91,8 +93,7 @@ impl StartAgentExecutor {
let parent_id = conversation.parent_conversation_id();
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
if let Some(pending) = self.pending.iter_mut().find(|p| {
p.child_conversation_id.is_none()
&& parent_id == Some(p.parent_conversation_id)
p.child_conversation_id.is_none() && parent_id == Some(p.parent_conversation_id)
}) {
pending.child_conversation_id = Some(*new_conversation_id);
}
@@ -100,17 +101,19 @@ impl StartAgentExecutor {
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
return;
};
// Don't remove yet if we're waiting for completion — we need
// the entry to stay so UpdatedConversationStatus can find it.
if self.pending[idx].wait_for_completion {
// Just log and continue — we'll resolve on Success status.
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
@@ -132,8 +135,8 @@ impl StartAgentExecutor {
return;
}
let pending = self.pending.remove(idx);
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
// orchestration_agent_id() uses run_id in v2 mode, which won't
// exist for locally-spawned Bedrock child agents. Fall back to
// the server conversation token (set by the stream Init event)
@@ -195,9 +198,11 @@ impl StartAgentExecutor {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
return;
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
@@ -227,10 +232,9 @@ impl StartAgentExecutor {
.map(|t| t.as_str().to_string())
})
.unwrap_or_else(|| conversation_id.to_string());
let _ = pending.sender.try_send(StartAgentDecision::Completed {
agent_id,
output,
});
let _ = pending
.sender
.try_send(StartAgentDecision::Completed { agent_id, output });
}
status => {
let error_msg = start_agent_error_message_for_status(
@@ -72,7 +72,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
assert_eq!(
executor
.pending
.as_ref()
.first()
.and_then(|pending| pending.child_conversation_id),
Some(child_conversation_id)
);
@@ -102,7 +102,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_none());
assert!(executor.pending.is_empty());
});
});
}
@@ -60,10 +60,7 @@ pub fn render_subagent_inline_panel(
};
let status = conversation.status().clone();
let agent_name = conversation
.agent_name()
.unwrap_or("Subagent")
.to_string();
let agent_name = conversation.agent_name().unwrap_or("Subagent").to_string();
let panel_bg = blended_colors::neutral_2(theme);
@@ -76,13 +73,7 @@ pub fn render_subagent_inline_panel(
let header_expanded = state.is_expanded;
column.add_child(
Hoverable::new(header_mouse_state, move |_mouse_state| {
render_panel_header(
&agent_name,
&header_status,
header_expanded,
panel_bg,
app,
)
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
@@ -137,12 +128,11 @@ fn render_panel_header(
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let (icon, icon_color) = status.status_icon_and_color(theme);
let status_icon_element = ConstrainedBox::new(
galaxyui::elements::Icon::new(icon.into(), icon_color).finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
let status_icon_element =
ConstrainedBox::new(galaxyui::elements::Icon::new(icon.into(), icon_color).finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
left_side.add_child(
Container::new(status_icon_element)
@@ -192,11 +182,7 @@ fn render_panel_header(
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
right_side.add_child(
Container::new(chevron)
.with_margin_right(4.)
.finish(),
);
right_side.add_child(Container::new(chevron).with_margin_right(4.).finish());
header_row.add_child(right_side.finish());
@@ -208,10 +194,7 @@ fn render_panel_header(
.finish()
}
fn collect_mini_transcript(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Vec<String> {
fn collect_mini_transcript(conversation_id: &AIConversationId, app: &AppContext) -> Vec<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(conversation_id) else {
return vec![];
@@ -290,10 +273,7 @@ fn render_mini_transcript(
.finish()
}
fn get_completion_summary(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Option<String> {
fn get_completion_summary(conversation_id: &AIConversationId, app: &AppContext) -> Option<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(conversation_id)?;
@@ -317,11 +297,7 @@ fn get_completion_summary(
None
}
fn render_summary_footer(
summary: &str,
_background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let surface = theme.surface_2();
+2 -2
View File
@@ -391,12 +391,12 @@ pub(super) struct AIBlockStateHandles {
/// Mouse state handle for the fork conversation button
fork_conversation_handle: MouseStateHandle,
/// Mouse state handles per citation.
/// A given citation should only appear once per block.
footer_citation_chip_handles: HashMap<AIAgentCitation, MouseStateHandle>,
orchestration_navigation_card_handles: HashMap<AIAgentActionId, MouseStateHandle>,
pub(super) subagent_panel_states: HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
pub(super) subagent_panel_states:
HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
references_section_collapsible_handle: MouseStateHandle,
+2 -3
View File
@@ -4,10 +4,9 @@ use super::{
cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason},
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
view_impl::common::{
render_switch_control_to_user_button, render_warping_indicator,
random_load_output_message, render_switch_control_to_user_button, render_warping_indicator,
render_warping_indicator_base, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
WarpingIndicatorProps, WarpingProps, random_load_output_message,
WAITING_FOR_USER_INPUT_MESSAGE,
WarpingIndicatorProps, WarpingProps, WAITING_FOR_USER_INPUT_MESSAGE,
},
};
use crate::{
+53 -11
View File
@@ -474,8 +474,10 @@ pub fn render_warping_indicator<V: View>(
} else {
// Show elapsed timer alongside the random Galaxy status message.
if let Some(start_time) = props.warping_start_time {
non_shimmering_text =
Some(format!(" ({})", format_elapsed_compact(start_time.elapsed())));
non_shimmering_text = Some(format!(
" ({})",
format_elapsed_compact(start_time.elapsed())
));
}
props.default_warping_text.clone()
}
@@ -3430,19 +3432,59 @@ pub struct FindContext<'a> {
/// A palette of colors for the user avatar silhouette, randomly selected per session.
const USER_AVATAR_PALETTE: &[ColorU] = &[
ColorU { r: 99, g: 179, b: 237, a: 255 }, // blue
ColorU { r: 129, g: 230, b: 217, a: 255 }, // teal
ColorU { r: 183, g: 148, b: 244, a: 255 }, // purple
ColorU { r: 252, g: 165, b: 165, a: 255 }, // red/coral
ColorU { r: 251, g: 191, b: 36, a: 255 }, // amber
ColorU { r: 110, g: 231, b: 183, a: 255 }, // green
ColorU { r: 249, g: 168, b: 212, a: 255 }, // pink
ColorU { r: 253, g: 186, b: 116, a: 255 }, // orange
ColorU {
r: 99,
g: 179,
b: 237,
a: 255,
}, // blue
ColorU {
r: 129,
g: 230,
b: 217,
a: 255,
}, // teal
ColorU {
r: 183,
g: 148,
b: 244,
a: 255,
}, // purple
ColorU {
r: 252,
g: 165,
b: 165,
a: 255,
}, // red/coral
ColorU {
r: 251,
g: 191,
b: 36,
a: 255,
}, // amber
ColorU {
r: 110,
g: 231,
b: 183,
a: 255,
}, // green
ColorU {
r: 249,
g: 168,
b: 212,
a: 255,
}, // pink
ColorU {
r: 253,
g: 186,
b: 116,
a: 255,
}, // orange
];
fn session_avatar_color() -> ColorU {
use std::sync::OnceLock;
use rand::Rng;
use std::sync::OnceLock;
static COLOR: OnceLock<ColorU> = OnceLock::new();
*COLOR.get_or_init(|| {
let idx = rand::thread_rng().gen_range(0..USER_AVATAR_PALETTE.len());
@@ -11,9 +11,8 @@ use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
use super::{
collect_visual_markdown_lightbox_collection, compute_visual_section_width,
format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section,
query_prefix_highlight_len, render_scrollable_collapsible_content,
text_sections_with_indices, CollapsibleElementState, CollapsibleExpansionState,
VisualMarkdownLightboxCollection,
query_prefix_highlight_len, render_scrollable_collapsible_content, text_sections_with_indices,
CollapsibleElementState, CollapsibleExpansionState, VisualMarkdownLightboxCollection,
};
use crate::{
ai::agent::{
@@ -308,7 +307,10 @@ fn format_elapsed_compact_shows_minutes_under_120_minutes() {
assert_eq!(format_elapsed_compact(Duration::from_secs(90)), "1m");
assert_eq!(format_elapsed_compact(Duration::from_secs(120)), "2m");
assert_eq!(format_elapsed_compact(Duration::from_secs(45 * 60)), "45m");
assert_eq!(format_elapsed_compact(Duration::from_secs(119 * 60 + 59)), "119m");
assert_eq!(
format_elapsed_compact(Duration::from_secs(119 * 60 + 59)),
"119m"
);
}
#[test]
@@ -433,9 +433,7 @@ pub(super) fn render_start_agent(
}
if let Some(card_data) = child_conversation_card_data {
// Render inline subagent panel instead of navigation card
if let Some(panel_state) =
props.state_handles.subagent_panel_states.get(action_id)
{
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
panel_state,
+5 -10
View File
@@ -85,8 +85,8 @@ use crate::{
},
requested_command::RequestedCommand,
search_codebase::SearchCodebaseView,
summarization::SummarizationView,
suggested_unit_tests::SuggestedUnitTestsView,
summarization::SummarizationView,
web_fetch::WebFetchView,
web_search::WebSearchView,
},
@@ -127,8 +127,8 @@ use super::{
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Shrinkable, Text, Wrap,
Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
ParentElement, Radius, Shrinkable, Text, Wrap,
},
keymap::Keystroke,
platform::{Cursor, OperatingSystem},
@@ -217,9 +217,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
if is_summarize_input {
let key = crate::ai::agent::MessageId::new(
"__summarization_inline_view__".to_string(),
);
let key = crate::ai::agent::MessageId::new("__summarization_inline_view__".to_string());
if let Some(summarization_view) = props.summarization_views.get(&key) {
output_items.add_child(ChildView::new(summarization_view).finish());
}
@@ -827,8 +825,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
if let Some(summarization_view) =
props.summarization_views.get(&output_message.id)
{
output_items
.add_child(ChildView::new(summarization_view).finish());
output_items.add_child(ChildView::new(summarization_view).finish());
} else if !are_all_text_sections_empty(&text.sections) {
let header_text = "Conversation summarized".to_string();
if let Some(element) = render_collapsible_block(
@@ -3178,7 +3175,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
flex.add_child(fork_button);
}
// Review changes button.
if props.has_accepted_edits && !props.shared_session_status.is_viewer() {
// Only show Review Changes button if we're in a git repository
@@ -3209,7 +3205,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
Some(flex.finish().with_content_item_spacing().finish())
}
pub fn action_icon<V: View>(
action_id: &AIAgentActionId,
action_model: &ModelHandle<BlocklistAIActionModel>,
+500 -114
View File
@@ -73,7 +73,7 @@ use itertools::Itertools;
use parking_lot::FairMutex;
use pending_response_streams::PendingResponseStreams;
use session_sharing_protocol::common::ParticipantId;
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use warp_multi_agent_api::{message, Task, ToolType};
@@ -168,6 +168,60 @@ pub enum BlocklistAIControllerEvent {
FreeTierLimitCheckTriggered,
}
/// Tracks recent failed action signatures for loop detection.
/// When the same tool+input pattern fails repeatedly, we inject
/// corrective instructions to break the cycle.
#[derive(Debug, Clone)]
struct LoopDetectionEntry {
/// Discriminant of the action result type (e.g. RequestCommandOutput, ApplyFileDiffs)
tool_discriminant: std::mem::Discriminant<AIAgentActionResultType>,
/// Hash of the action's identifying input (command string, file paths, etc.)
input_hash: u64,
/// Human-readable description of what failed
description: String,
}
#[derive(Debug, Default, Clone)]
struct LoopDetectionState {
recent_failures: VecDeque<LoopDetectionEntry>,
}
const LOOP_DETECTION_WINDOW: usize = 10;
const LOOP_DETECTION_THRESHOLD: usize = 3;
impl LoopDetectionState {
fn record_failure(&mut self, entry: LoopDetectionEntry) {
self.recent_failures.push_back(entry);
if self.recent_failures.len() > LOOP_DETECTION_WINDOW {
self.recent_failures.pop_front();
}
}
fn detect_loop(&self) -> Option<&LoopDetectionEntry> {
use std::collections::HashMap as CountMap;
let mut counts: CountMap<
(std::mem::Discriminant<AIAgentActionResultType>, u64),
(usize, usize),
> = CountMap::new();
for (idx, entry) in self.recent_failures.iter().enumerate() {
let key = (entry.tool_discriminant, entry.input_hash);
let counter = counts.entry(key).or_insert((0, 0));
counter.0 += 1;
counter.1 = idx; // Track most recent occurrence
}
for ((_disc, _hash), (count, latest_idx)) in &counts {
if *count >= LOOP_DETECTION_THRESHOLD {
return self.recent_failures.get(*latest_idx);
}
}
None
}
fn clear(&mut self) {
self.recent_failures.clear();
}
}
#[derive(Debug)]
pub struct RequestInput {
pub conversation_id: AIConversationId,
@@ -317,6 +371,9 @@ pub struct BlocklistAIController {
pending_auto_resume_handles: HashMap<AIConversationId, SpawnedFutureHandle>,
/// Passive conversations explicitly requested to follow up after actions complete.
pending_passive_follow_ups: HashSet<AIConversationId>,
/// Per-conversation loop detection state for preventing recursive tool failures.
loop_detection: HashMap<AIConversationId, LoopDetectionState>,
/// Passive suggestion results that should be included with the next request
/// for a given conversation (e.g. accepted/iterated code diffs that weren't
/// auto-resumed).
@@ -555,6 +612,7 @@ impl BlocklistAIController {
pending_auto_resume_handles: HashMap::new(),
pending_passive_follow_ups: HashSet::new(),
pending_passive_suggestion_results: HashMap::new(),
loop_detection: HashMap::new(),
}
}
@@ -1026,6 +1084,9 @@ impl BlocklistAIController {
is_queued_prompt: bool,
ctx: &mut ModelContext<Self>,
) {
// User sending a new query resets loop detection — fresh context.
self.loop_detection.remove(&conversation_id);
let is_viewer = self
.terminal_model
.lock()
@@ -1418,6 +1479,9 @@ impl BlocklistAIController {
return;
}
// Loop detection: record failures and check for repeated patterns
let loop_warning = self.check_and_record_loop_detection(conversation_id, &finished_results);
// Check whether any result will trigger a server-side subagent (e.g. CLI
// subagent for LRC), or if one is already active. If so, we must not
// piggyback orchestration events because the subagent cannot interpret
@@ -1447,6 +1511,34 @@ impl BlocklistAIController {
ctx,
);
// If a loop was detected, inject a corrective instruction alongside
// the action results so the model avoids repeating the same failure.
if let Some(warning_msg) = loop_warning {
log::warn!(
"[loop-detection] Injecting corrective instruction for conversation {:?}: {}",
conversation_id,
warning_msg
);
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
{
let root_task_id = conversation.get_root_task_id().clone();
request_input
.input_messages
.entry(root_task_id)
.or_default()
.push(AIAgentInput::UserQuery {
query: warning_msg,
context: Arc::from([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
});
}
}
// Include any pending orchestration events in this follow-up rather
// than waiting for a separate idle injection turn. Skip when a server
// subagent is or will be active — events will be delivered via the idle
@@ -1495,6 +1587,67 @@ impl BlocklistAIController {
self.pending_passive_follow_ups.remove(&conversation_id);
}
/// Records failed actions into the loop detection state and returns a
/// corrective instruction if a loop is detected.
fn check_and_record_loop_detection(
&mut self,
conversation_id: AIConversationId,
results: &[AIAgentActionResult],
) -> Option<String> {
use std::hash::{Hash, Hasher};
let state = self.loop_detection.entry(conversation_id).or_default();
let mut has_success = false;
for result in results {
if result.result.is_failed() {
let discriminant = std::mem::discriminant(&result.result);
// Use a stable description that includes the tool type and the *input*
// (command, file paths, etc.) but NOT the variable output, so the same
// failing command with different output is still recognized as a loop.
let description = result.result.loop_description();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
discriminant.hash(&mut hasher);
description.hash(&mut hasher);
let input_hash = hasher.finish();
state.record_failure(LoopDetectionEntry {
tool_discriminant: discriminant,
input_hash,
description: description.clone(),
});
} else if result.result.is_successful() {
has_success = true;
}
}
// If we had at least one success in this batch, clear loop state —
// the agent is making progress.
if has_success {
state.clear();
return None;
}
// Check for loops
if let Some(looping_entry) = state.detect_loop() {
let warning = format!(
"[SYSTEM] Loop detected: the same action has failed {} or more times consecutively. \
Do NOT repeat this action or any similar approach.\n\n\
Failing action: {}\n\n\
Take a completely different approach to accomplish the goal. \
If you cannot find an alternative, explain to the user what is failing and why.",
LOOP_DETECTION_THRESHOLD,
looping_entry.description
);
// Clear the state so we don't keep injecting on every subsequent turn
state.clear();
Some(warning)
} else {
None
}
}
/// Handles the EventsReady signal. Checks readiness, drains
/// pending events from the service, and injects them into the conversation.
fn handle_pending_events_ready(
@@ -1566,9 +1719,8 @@ impl BlocklistAIController {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let events = OrchestrationEventService::handle(ctx).update(ctx, |svc, _ctx| {
svc.drain_subagent_events(&conversation_id)
});
let events = OrchestrationEventService::handle(ctx)
.update(ctx, |svc, _ctx| svc.drain_subagent_events(&conversation_id));
for event in events {
match event.detail {
@@ -1577,17 +1729,16 @@ impl BlocklistAIController {
question_text,
options,
} => {
let answer = options.first().cloned().unwrap_or_else(|| {
format!("Proceed with: {}", question_text)
});
let answer = options
.first()
.cloned()
.unwrap_or_else(|| format!("Proceed with: {}", question_text));
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.route_answer_to_subagent(source_conversation_id, answer, ctx);
});
}
PendingEventDetail::SubagentAnswer {
answer_text,
} => {
PendingEventDetail::SubagentAnswer { answer_text } => {
self.complete_ask_user_question_with_answer(answer_text, ctx);
}
PendingEventDetail::SubagentCompletionSummary => {}
@@ -1604,7 +1755,10 @@ impl BlocklistAIController {
) {
use ai::agent::action_result::AskUserQuestionAnswerItem;
let executor = self.action_model.as_ref(ctx).ask_user_question_executor(ctx);
let executor = self
.action_model
.as_ref(ctx)
.ask_user_question_executor(ctx);
let answer_item = AskUserQuestionAnswerItem::Answered {
question_id: String::new(),
selected_options: vec![answer_text.clone()],
@@ -1963,7 +2117,8 @@ impl BlocklistAIController {
parent_agent_id,
agent_name,
bedrock_history,
bedrock_compact_summary,
bedrock_tool_result_archive,
bedrock_progressive_summary,
) = {
let Some(conversation) = history_model
.as_ref(ctx)
@@ -1987,7 +2142,8 @@ impl BlocklistAIController {
conversation.parent_agent_id().map(str::to_string),
conversation.agent_name().map(str::to_string),
conversation.bedrock_message_history().to_vec(),
conversation.compact_summary().map(str::to_string),
conversation.tool_result_archive().to_vec(),
conversation.progressive_summary().map(str::to_string),
)
};
@@ -2055,11 +2211,8 @@ impl BlocklistAIController {
request_params.parent_agent_id = parent_agent_id;
request_params.agent_name = agent_name;
request_params.bedrock_message_history = bedrock_history;
request_params.bedrock_compact_summary = bedrock_compact_summary;
request_params.is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
request_params.bedrock_tool_result_archive = bedrock_tool_result_archive;
request_params.bedrock_progressive_summary = bedrock_progressive_summary;
let server_conversation_token_for_identifiers =
conversation_data.server_conversation_token.clone();
@@ -2084,13 +2237,9 @@ impl BlocklistAIController {
let input_contains_user_query = request_input
.all_inputs()
.any(|input| input.is_user_query());
let input_is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
ctx.subscribe_to_model(&response_stream, move |me, event, ctx| {
me.handle_response_stream_event(
input_contains_user_query,
input_is_summarization,
event,
&response_stream_clone,
ctx,
@@ -2280,7 +2429,6 @@ impl BlocklistAIController {
fn handle_response_stream_event(
&mut self,
did_input_contain_user_query: bool,
is_summarization_request: bool,
event: &ResponseStreamEvent,
response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>,
@@ -2384,69 +2532,32 @@ impl BlocklistAIController {
Some(sent.clone())
}
});
if let Some(new_history) = new_history {
if let Some(mut new_history) = new_history {
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
// If this was a summarization request, compact the
// history to just the summary instead of keeping
// the full message list. This is what actually
// frees up context window space.
let is_summarization = is_summarization_request;
if is_summarization {
// Extract the assistant's summary from the last
// message in the history (the response).
let summary_text = new_history
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
let skip = conversation.messages_summarized_up_to();
if skip > 0 && skip <= new_history.len() {
let drained: Vec<_> = new_history
.iter()
.rev()
.find_map(|msg| {
use crate::ai::bedrock::convert::{
MessageContent, MessageRole,
};
if msg.role == MessageRole::Assistant {
if let MessageContent::Text(text) =
&msg.content
{
Some(text.clone())
} else {
None
}
} else {
None
}
});
if let Some(summary) = summary_text {
log::info!(
"[bedrock] Compacted conversation history from {} messages to system-level summary",
new_history.len()
);
conversation.set_compact_summary(Some(summary.clone()));
*conversation.bedrock_message_history_mut() = Vec::new();
let estimated_tokens = (summary.len() / 4) as u32;
let max_context = crate::ai::bedrock::response_translator::context_window_for_model("claude-opus-4-6-20250514[1m]");
let new_usage = estimated_tokens as f32 / max_context as f32;
conversation.set_context_window_usage(new_usage);
conversation.set_current_context_tokens(estimated_tokens);
log::info!(
"[bedrock] Post-compact context estimate: ~{} tokens ({:.1}% of context window)",
estimated_tokens,
new_usage * 100.0
);
} else {
*conversation.bedrock_message_history_mut() =
new_history;
}
.take(skip)
.cloned()
.collect();
conversation.archive_tool_results(drained);
let reconciled = new_history.split_off(skip);
conversation.reset_messages_summarized_up_to();
*conversation.bedrock_message_history_mut() =
reconciled;
} else {
*conversation.bedrock_message_history_mut() =
new_history;
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
}
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
}
});
}
@@ -2488,22 +2599,24 @@ impl BlocklistAIController {
});
}
let mut renderable_error: RenderableAIError =
if let AIApiError::Stream { stream_type, source } = e.as_ref() {
if *stream_type == "bedrock_converse"
&& is_bedrock_credentials_error(&source.to_string())
{
let model_name =
response_stream.as_ref(ctx).model_id().to_string();
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name,
}
} else {
e.as_ref().into()
let mut renderable_error: RenderableAIError = if let AIApiError::Stream {
stream_type,
source,
} = e.as_ref()
{
if *stream_type == "bedrock_converse"
&& is_bedrock_credentials_error(&source.to_string())
{
let model_name = response_stream.as_ref(ctx).model_id().to_string();
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name,
}
} else {
e.as_ref().into()
};
}
} else {
e.as_ref().into()
};
if let RenderableAIError::Other {
will_attempt_resume,
waiting_for_network,
@@ -2555,7 +2668,9 @@ impl BlocklistAIController {
log::warn!("Conversation not found.");
return;
};
let new_exchange_ids: Vec<_> = conversation.new_exchange_ids_for_response(&stream_id).collect();
let new_exchange_ids: Vec<_> = conversation
.new_exchange_ids_for_response(&stream_id)
.collect();
log::info!(
"[bedrock-debug] AfterStreamFinished: stream_id={:?}, conversation_id={:?}, new_exchange_ids count={}",
stream_id, conversation_id, new_exchange_ids.len()
@@ -2963,42 +3078,311 @@ impl BlocklistAIController {
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
}
// Auto-compact: trigger summarization when context window usage >= 85%.
let should_auto_compact = {
// Progressive summarization: when context window usage >= 85% and we have
// more than 100 messages, summarize the oldest messages while keeping the
// most recent 100 verbatim. This runs as a background Bedrock call — no UI,
// no exchange created, no tool execution shown.
let should_progressive_summarize = {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
history_model
.conversation(&conversation_id)
.is_some_and(|conversation| {
let is_summarization_request = conversation
.latest_exchange()
.is_some_and(|exchange| {
let is_summarization_request =
conversation.latest_exchange().is_some_and(|exchange| {
exchange
.input
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }))
});
conversation.context_window_usage() >= 0.85
&& !conversation.has_pending_auto_compact()
&& !conversation.has_pending_progressive_summary()
&& !is_summarization_request
&& conversation.bedrock_message_history().len() > 100
})
};
if should_auto_compact {
log::info!(
"[auto-compact] Context window usage >= 85% for conversation {:?}, triggering summarization",
conversation_id
);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
conversation.set_has_pending_auto_compact(true);
}
});
self.send_slash_command_request(
SlashCommandRequest::Summarize { prompt: None },
ctx,
);
if should_progressive_summarize {
self.trigger_progressive_summarization(conversation_id, ctx);
}
}
fn trigger_progressive_summarization(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
use crate::ai::bedrock::convert::{ConversationMessage, MessageContent, MessageRole};
use crate::ai::bedrock::response_translator::{
context_window_for_model, estimate_cost_cents,
};
use crate::settings::ai::AISettings;
use settings::Setting;
let settings = AISettings::as_ref(ctx);
if !*settings.bedrock_enabled.value() {
return;
}
let config = BedrockClientConfig {
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
.with_external_fallbacks();
let cross_region = config.cross_region_inference;
// Use Sonnet for summarization — cheaper and fast enough for this task
let model_id = "us.anthropic.claude-sonnet-4-6-20250514-v1:0".to_string();
let history_model = BlocklistAIHistoryModel::handle(ctx);
// Extract the messages to summarize and set the guard flag
let (messages_to_summarize, existing_summary, messages_count) = {
let history = history_model.as_ref(ctx);
let Some(conversation) = history.conversation(&conversation_id) else {
return;
};
let history_len = conversation.bedrock_message_history().len();
let split_point = history_len.saturating_sub(100);
if split_point == 0 {
return;
}
let msgs: Vec<ConversationMessage> =
conversation.bedrock_message_history()[..split_point].to_vec();
let existing = conversation.progressive_summary().map(str::to_string);
(msgs, existing, split_point)
};
history_model.update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
conversation.set_has_pending_progressive_summary(true);
}
});
log::info!(
"[progressive-summary] Triggering for conversation {:?}: summarizing {} messages, keeping last 100",
conversation_id,
messages_count
);
// Build the summarization input
let mut summarize_content = String::new();
if let Some(ref prior) = existing_summary {
summarize_content.push_str("<prior-summary>\n");
summarize_content.push_str(prior);
summarize_content.push_str("\n</prior-summary>\n\n");
}
summarize_content.push_str("<messages-to-summarize>\n");
fn safe_truncate(s: &str, max_chars: usize) -> String {
if s.len() <= max_chars {
s.to_string()
} else {
let trunc = s.chars().take(max_chars).collect::<String>();
format!("{trunc}... [truncated, {len} total chars]", len = s.len())
}
}
for msg in &messages_to_summarize {
let role_str = match msg.role {
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
};
let content_str = match &msg.content {
MessageContent::Text(t) => t.clone(),
MessageContent::ToolUse { name, input, .. } => {
format!("[Tool Call: {}] {}", name, input)
}
MessageContent::ToolResult { content, .. } => safe_truncate(content, 2000),
MessageContent::MultiPart(parts) => {
use crate::ai::bedrock::convert::ContentPart;
parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.clone(),
ContentPart::ToolUse { name, input, .. } => {
format!("[Tool: {}] {}", name, input)
}
ContentPart::ToolResult { content, .. } => safe_truncate(content, 2000),
})
.collect::<Vec<_>>()
.join("\n")
}
};
summarize_content.push_str(&format!("[{}]: {}\n", role_str, content_str));
}
summarize_content.push_str("</messages-to-summarize>");
let summarize_prompt = "Summarize the following conversation history. Preserve:\n\
- All decisions made and their rationale\n\
- All file paths modified and what was changed\n\
- All tool calls with their significant results (commands run, files read, errors encountered)\n\
- Current task state and any pending work\n\
- Technical details, code patterns, and architecture discussed\n\n\
Be comprehensive. This summary will be the only record of these exchanges.";
let summarize_messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!("{}\n\n{}", summarize_prompt, summarize_content)),
}];
// Spawn the background Bedrock call
let model_id_clone = model_id.clone();
ctx.spawn(
async move {
let client = BedrockClient::from_config(config).await?;
client
.converse_collect(
&model_id_clone,
summarize_messages,
None,
16000,
cross_region,
)
.await
},
move |me, result, ctx| {
let history_model = BlocklistAIHistoryModel::handle(ctx);
match result {
Ok((summary_text, input_tokens, output_tokens)) => {
log::info!(
"[progressive-summary] Completed for {:?}: {} chars, input={} output={} tokens",
conversation_id,
summary_text.len(),
input_tokens,
output_tokens,
);
let cost_cents = estimate_cost_cents(
input_tokens,
output_tokens,
0,
0,
&model_id,
);
// Use the conversation's active model for context window sizing,
// not the summarizer model.
let active_model_id = crate::ai::llms::LLMPreferences::as_ref(ctx)
.get_active_base_model(ctx, Some(me.terminal_view_id))
.id
.to_string();
history_model.update(ctx, |history_model, _| {
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
// Drain the summarized messages from history
let drain_count =
messages_count.min(conversation.bedrock_message_history().len());
let drained: Vec<_> = conversation
.bedrock_message_history()
.iter()
.take(drain_count)
.cloned()
.collect();
conversation.archive_tool_results(drained);
conversation
.bedrock_message_history_mut()
.drain(0..drain_count);
conversation
.set_progressive_summary(Some(summary_text.clone()), drain_count);
conversation.set_has_pending_progressive_summary(false);
// Estimate new context window usage
let summary_tokens = (summary_text.len() / 4) as u32;
let remaining_msgs_tokens: u32 = conversation
.bedrock_message_history()
.iter()
.map(|m| match &m.content {
MessageContent::Text(t) => (t.len() / 4) as u32,
MessageContent::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32 + 20
}
MessageContent::ToolResult { content, .. } => {
(content.len() / 4) as u32
}
MessageContent::MultiPart(parts) => {
use crate::ai::bedrock::convert::ContentPart;
parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => (t.len() / 4) as u32,
ContentPart::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32
}
ContentPart::ToolResult { content, .. } => {
(content.len() / 4) as u32
}
})
.sum()
}
})
.sum();
let max_ctx = context_window_for_model(&active_model_id);
let new_usage =
(summary_tokens + remaining_msgs_tokens) as f32 / max_ctx as f32;
conversation.set_context_window_usage(new_usage);
conversation
.set_current_context_tokens(summary_tokens + remaining_msgs_tokens);
log::info!(
"[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained",
summary_tokens + remaining_msgs_tokens,
new_usage * 100.0,
active_model_id,
conversation.bedrock_message_history().len()
);
}
});
// Update cost tracking
history_model.update(ctx, |history_model, _| {
use warp_multi_agent_api::response_event::stream_finished;
let token_usage = vec![stream_finished::TokenUsage {
model_id: "bedrock".to_string(),
total_input: input_tokens,
output: output_tokens,
input_cache_read: 0,
input_cache_write: 0,
cost_in_cents: cost_cents,
}];
history_model.update_conversation_cost_and_usage_for_request(
conversation_id,
None,
token_usage,
None,
false,
);
});
}
Err(e) => {
log::error!(
"[progressive-summary] Failed for {:?}: {:?}",
conversation_id,
e
);
history_model.update(ctx, |history_model, _| {
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
conversation.set_has_pending_progressive_summary(false);
}
});
}
}
let _ = me;
},
);
}
}
impl Entity for BlocklistAIController {
@@ -3028,7 +3412,9 @@ fn is_bedrock_credentials_error(msg: &str) -> bool {
|| (lower.contains("sso/cache") && lower.contains("notfound"))
|| (lower.contains("sso/cache") && lower.contains("no such file"))
|| (lower.contains("accessdenied")
&& (lower.contains("token") || lower.contains("credential") || lower.contains("security")))
&& (lower.contains("token")
|| lower.contains("credential")
|| lower.contains("security")))
}
#[allow(clippy::too_many_arguments)]
@@ -92,14 +92,17 @@ impl ResponseStream {
return None;
}
let auth_method = *settings.bedrock_auth_method.value();
Some(BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}.with_external_fallbacks())
Some(
BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
.with_external_fallbacks(),
)
}
pub fn new(
@@ -190,14 +193,15 @@ impl ResponseStream {
self.current_request_id = Some(request_id);
let params = self.params.clone();
let bedrock_config = Self::bedrock_config_if_applicable(params.model.as_str(), ctx);
let _ = ctx.spawn(
async move {
generate_multi_agent_output(bedrock_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(bedrock_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
/// Cancels the stream. The conversation_id is preserved in the emitted event for async handling.
@@ -11,7 +11,6 @@ use crate::{
},
blocklist::agent_view::AgentViewEntryOrigin,
},
search::slash_command_menu::static_commands::commands,
terminal::input::slash_commands::SlashCommandTrigger,
BlocklistAIHistoryModel,
};
@@ -33,9 +32,6 @@ pub enum SlashCommandRequest {
repos: Vec<String>,
use_current_dir: bool,
},
Summarize {
prompt: Option<String>,
},
FetchReviewComments {
repo_path: String,
},
@@ -55,13 +51,6 @@ impl SlashCommandRequest {
return Some(Self::InitProjectRules);
}
// Check if query starts with /compact and route to summarize conversation
if let Some(prompt) = query.strip_prefix(commands::COMPACT.name) {
return Some(Self::Summarize {
prompt: prompt.strip_prefix(' ').map(String::from),
});
}
None
}
@@ -85,7 +74,6 @@ impl SlashCommandRequest {
ctx,
);
let entrypoint = self.entrypoint();
let is_summarize = matches!(self, Self::Summarize { .. });
let inputs = self.input(context, controller.context_model.as_ref(ctx), ctx);
if inputs.is_empty() {
return;
@@ -155,14 +143,12 @@ impl SlashCommandRequest {
});
}
// Emit SentRequest event to trigger buffer clearing
if is_summarize {
ctx.emit(BlocklistAIControllerEvent::SentRequest {
contains_user_query: true,
is_queued_prompt,
model_id,
stream_id,
});
}
ctx.emit(BlocklistAIControllerEvent::SentRequest {
contains_user_query: true,
is_queued_prompt,
model_id,
stream_id,
});
}
Err(e) => log::error!("Failed to send agent slash command request: {e:?}"),
}
@@ -174,8 +160,7 @@ impl SlashCommandRequest {
app: &AppContext,
) -> Option<AIConversationId> {
match self {
Self::Summarize { .. }
| Self::CreateEnvironment { .. }
Self::CreateEnvironment { .. }
| Self::InvokeSkill { .. }
| Self::FetchReviewComments { .. } => controller
.context_model
@@ -226,9 +211,6 @@ impl SlashCommandRequest {
repo_paths: repos,
}]
}
SlashCommandRequest::Summarize { prompt, .. } => {
vec![AIAgentInput::SummarizeConversation { prompt }]
}
SlashCommandRequest::FetchReviewComments { repo_path } => {
vec![AIAgentInput::FetchReviewComments { repo_path, context }]
}
@@ -263,7 +245,6 @@ impl SlashCommandRequest {
SlashCommandRequest::InitProjectRules => EntrypointType::InitProjectRules,
SlashCommandRequest::CreateNewProject { .. }
| SlashCommandRequest::CreateEnvironment { .. }
| SlashCommandRequest::Summarize { .. }
| SlashCommandRequest::FetchReviewComments { .. }
| SlashCommandRequest::InvokeSkill { .. } => EntrypointType::UserInitiated,
}
+4
View File
@@ -1095,6 +1095,8 @@ impl BlocklistAIHistoryModel {
// The event cursor belongs to the source conversation's run; the
// forked conversation will establish its own cursor.
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let forked_conversation_id = AIConversationId::new();
if let Err(e) = sqlite_sender.send(ModelEvent::UpdateMultiAgentConversation {
@@ -1250,6 +1252,8 @@ impl BlocklistAIHistoryModel {
// The event cursor belongs to the source conversation's run; the
// forked conversation will establish its own cursor.
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let forked_conversation_id = AIConversationId::new();
@@ -328,6 +328,10 @@ fn create_server_metadata(
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_cache_miss_tokens: 0,
total_cost_cents: 0.0,
};
ServerAIConversationMetadata {
@@ -1133,6 +1137,8 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let tasks = vec![warp_multi_agent_api::Task {
id: "root-task".to_string(),
+1 -1
View File
@@ -11,7 +11,7 @@ pub(crate) mod requested_command_attribution;
pub(crate) mod requested_script;
pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(super) mod summarization;
pub(crate) mod suggested_unit_tests;
pub(super) mod summarization;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -1,8 +1,10 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle};
use galaxyui::elements::shimmering_text::{
ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle,
};
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex,
MainAxisAlignment, ParentElement, Radius, Shrinkable, Text,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
@@ -73,20 +75,14 @@ impl SummarizationView {
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Clock loader icon (magenta, matches InProgress convention)
let icon_element = galaxyui::elements::Icon::new(
Icon::ClockLoader.into(),
theme.ansi_fg_magenta(),
)
.finish();
let icon_element =
galaxyui::elements::Icon::new(Icon::ClockLoader.into(), theme.ansi_fg_magenta())
.finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
header_row.add_child(Container::new(icon_box).with_margin_right(8.).finish());
// Shimmering "Summarizing conversation..." text
let base_color = theme.disabled_text_color(header_background).into_solid();
@@ -133,20 +129,13 @@ impl SummarizationView {
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Checkmark-style icon for completed
let icon_element = galaxyui::elements::Icon::new(
Icon::Check.into(),
theme.ansi_fg_green(),
)
.finish();
let icon_element =
galaxyui::elements::Icon::new(Icon::Check.into(), theme.ansi_fg_green()).finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
header_row.add_child(Container::new(icon_box).with_margin_right(8.).finish());
let elapsed = self.start_time.elapsed();
let elapsed_text = format_elapsed(elapsed);
@@ -145,6 +145,8 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() {
run_id: None,
autoexecute_override: None,
last_event_sequence: Some(42),
progressive_summary: None,
messages_summarized_up_to: 0,
};
let conversation =
AIConversation::new_restored(AIConversationId::new(), vec![task], Some(data))
+11 -11
View File
@@ -578,9 +578,15 @@ impl OrchestrationEventService {
if let Some(parent_id) = parent_id {
// Merge child's token usage and costs into parent
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
let child_usage: Option<(HashMap<String, api::response_event::stream_finished::TokenUsage>, crate::ai::agent::RequestCost)> = history_model
.conversation(&conversation_id)
.map(|c| (c.total_token_usage_by_model().clone(), c.total_request_cost()));
let child_usage: Option<(
HashMap<String, api::response_event::stream_finished::TokenUsage>,
crate::ai::agent::RequestCost,
)> = history_model.conversation(&conversation_id).map(|c| {
(
c.total_token_usage_by_model().clone(),
c.total_request_cost(),
)
});
if let Some((token_usage, request_cost)) = child_usage {
if let Some(parent) = history_model.conversation_mut(&parent_id) {
parent.merge_child_usage_raw(&token_usage, request_cost);
@@ -589,11 +595,7 @@ impl OrchestrationEventService {
});
if summary.is_some() {
self.route_subagent_completion_summary(
conversation_id,
parent_id,
ctx,
);
self.route_subagent_completion_summary(conversation_id, parent_id, ctx);
}
}
}
@@ -1271,9 +1273,7 @@ impl OrchestrationEventService {
event_id: Uuid::new_v4().to_string(),
source_agent_id: "parent".to_string(),
attempt_count: 0,
detail: PendingEventDetail::SubagentAnswer {
answer_text,
},
detail: PendingEventDetail::SubagentAnswer { answer_text },
};
self.pending_events
@@ -317,11 +317,7 @@ impl PassiveSuggestionsModel {
.lock()
.block_list()
.block_at(block_completed.index)
.and_then(|block| {
block
.agent_view_visibility()
.agent_view_conversation_id()
});
.and_then(|block| block.agent_view_visibility().agent_view_conversation_id());
let prompt = format!(
"The command `{}` failed. Diagnose the error and suggest a fix.",
@@ -436,6 +432,7 @@ impl PassiveSuggestionsModel {
);
}
}
}
impl Entity for PassiveSuggestionsModel {
@@ -2,9 +2,7 @@ use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageConte
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,
};
@@ -60,13 +58,9 @@ impl View for ContextWindowView {
format_tokens(estimated_tokens as u32),
);
column = column.with_child(
Text::new(
header_text,
appearance.ui_font_family(),
font_size + 1.0,
)
.with_color(label_color)
.finish(),
Text::new(header_text, appearance.ui_font_family(), font_size + 1.0)
.with_color(label_color)
.finish(),
);
// Messages — show full content, no truncation.
@@ -89,7 +83,11 @@ impl View for ContextWindowView {
// Full content
let content_text = match &msg.content {
MessageContent::Text(t) => t.clone(),
MessageContent::ToolUse { name, tool_use_id, input } => {
MessageContent::ToolUse {
name,
tool_use_id,
input,
} => {
format!(
"[ToolUse] name={}, id={}\ninput={}",
name, tool_use_id, input
@@ -112,13 +110,21 @@ impl View for ContextWindowView {
ContentPart::Text(t) => {
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
}
ContentPart::ToolUse { name, tool_use_id, input } => {
ContentPart::ToolUse {
name,
tool_use_id,
input,
} => {
out.push_str(&format!(
"[Part {} ToolUse] name={}, id={}, input={}\n",
pi, name, tool_use_id, input
));
}
ContentPart::ToolResult { tool_use_id, content, is_error } => {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
out.push_str(&format!(
"[Part {} ToolResult] id={}, error={}\n{}\n",
pi, tool_use_id, is_error, content
@@ -261,8 +261,8 @@ impl ConversationUsageView {
}
// Cache usage (cumulative session totals)
let total_cache = self.usage_info.total_cache_read_tokens
+ self.usage_info.total_cache_write_tokens;
let total_cache =
self.usage_info.total_cache_read_tokens + self.usage_info.total_cache_write_tokens;
if total_cache > 0 {
if self.usage_info.total_cache_read_tokens > 0 {
labels.push(render_label_text("Cache read", appearance));
@@ -290,10 +290,7 @@ impl ConversationUsageView {
/ total_cache_ops as f32)
* 100.0;
labels.push(render_label_text("Cache hit rate", appearance));
values.push(render_value_text(
format!("{:.1}%", hit_rate),
appearance,
));
values.push(render_value_text(format!("{:.1}%", hit_rate), appearance));
}
}
@@ -133,6 +133,8 @@ fn test_from_task_includes_linked_directory_when_run_id_matches() {
run_id: Some(task_id.to_string()),
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
@@ -248,6 +250,8 @@ fn test_from_task_includes_linked_directory_when_server_token_matches() {
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
},
);
+1 -2
View File
@@ -6,8 +6,7 @@ pub struct PredefinedRule {
pub const SYSTEM_DEFINED_RULE_PREFIX: &str = "System Defined Rule";
pub fn is_predefined_rule(name: &str) -> bool {
PREDEFINED_RULES.iter().any(|r| r.name == name)
|| name.starts_with(SYSTEM_DEFINED_RULE_PREFIX)
PREDEFINED_RULES.iter().any(|r| r.name == name) || name.starts_with(SYSTEM_DEFINED_RULE_PREFIX)
}
pub fn predefined_rule_index(name: &str) -> Option<usize> {
+27 -48
View File
@@ -298,12 +298,7 @@ impl RuleView {
content: rule.content.to_string(),
suggested_logging_id: None,
});
update_manager.create_ai_fact(
ai_fact,
ClientId::default(),
owner,
ctx,
);
update_manager.create_ai_fact(ai_fact, ClientId::default(), owner, ctx);
}
});
}
@@ -517,22 +512,10 @@ impl RuleView {
suggested_logging_id: None,
});
if let Some((sync_id, revision)) =
existing_system_rules.get(rule.name)
{
update_manager.update_ai_fact(
ai_fact,
*sync_id,
revision.clone(),
ctx,
);
if let Some((sync_id, revision)) = existing_system_rules.get(rule.name) {
update_manager.update_ai_fact(ai_fact, *sync_id, revision.clone(), ctx);
} else {
update_manager.create_ai_fact(
ai_fact,
ClientId::default(),
owner,
ctx,
);
update_manager.create_ai_fact(ai_fact, ClientId::default(), owner, ctx);
}
}
});
@@ -913,34 +896,30 @@ impl RuleView {
if is_delete_allowed(ai_row.fact.clone(), app) {
let delete_sync_id = ai_row.fact.sync_id();
let delete_button = Hoverable::new(
ai_row.mouse_states.delete_hover.clone(),
|state| {
let mut container = Container::new(
ConstrainedBox::new(
Icon::Trash
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_width(16.)
.with_height(16.)
.finish(),
let delete_button = Hoverable::new(ai_row.mouse_states.delete_hover.clone(), |state| {
let mut container = Container::new(
ConstrainedBox::new(
Icon::Trash
.to_galaxyui_icon(
appearance
.theme()
.sub_text_color(appearance.theme().background()),
)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
if state.is_hovered() {
container =
container.with_background(appearance.theme().surface_2());
}
container.finish()
},
)
.with_width(16.)
.with_height(16.)
.finish(),
)
.with_uniform_padding(4.)
.with_corner_radius(CornerRadius::with_all(
galaxyui::elements::Radius::Pixels(4.),
));
if state.is_hovered() {
container = container.with_background(appearance.theme().surface_2());
}
container.finish()
})
.with_cursor(Cursor::PointingHand)
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(RuleViewAction::Delete(delete_sync_id));
+34 -45
View File
@@ -424,11 +424,11 @@ impl Default for ModelsByFeature {
fn default() -> Self {
Self {
agent_mode: AvailableLLMs {
default_id: "anthropic.claude-opus-4-6".to_owned().into(),
default_id: "anthropic.claude-opus-4-6[1m]".to_owned().into(),
choices: vec![LLMInfo {
display_name: "Claude Opus 4.6".to_owned(),
base_model_name: "Claude Opus 4.6".to_owned(),
id: "anthropic.claude-opus-4-6".to_owned().into(),
id: "anthropic.claude-opus-4-6[1m]".to_owned().into(),
reasoning_level: None,
usage_metadata: LLMUsageMetadata {
request_multiplier: 1,
@@ -445,11 +445,11 @@ impl Default for ModelsByFeature {
preferred_codex_model_id: None,
},
coding: AvailableLLMs {
default_id: "anthropic.claude-sonnet-4-6".to_owned().into(),
default_id: "anthropic.claude-sonnet-4-6[1m]".to_owned().into(),
choices: vec![LLMInfo {
display_name: "Claude Sonnet 4.6".to_owned(),
base_model_name: "Claude Sonnet 4.6".to_owned(),
id: "anthropic.claude-sonnet-4-6".to_owned().into(),
id: "anthropic.claude-sonnet-4-6[1m]".to_owned().into(),
reasoning_level: None,
usage_metadata: LLMUsageMetadata {
request_multiplier: 1,
@@ -508,8 +508,6 @@ pub struct LLMPreferences {
models_by_feature: ModelsByFeature,
last_update: Option<AvailableLLMsUpdate>,
base_llm_for_terminal_view: HashMap<EntityId, LLMId>,
#[cfg(not(target_family = "wasm"))]
bedrock_models_fetched: bool,
}
impl LLMPreferences {
@@ -550,12 +548,6 @@ impl LLMPreferences {
| AISettingsChangedEvent::BedrockCrossRegionInference { .. }
| AISettingsChangedEvent::BedrockRegion { .. }
) {
if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. }) {
let enabled = *AISettings::as_ref(ctx).bedrock_enabled.value();
if enabled && !me.bedrock_models_fetched {
me.trigger_bedrock_discovery(ctx);
}
}
me.inject_bedrock_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
@@ -567,8 +559,6 @@ impl LLMPreferences {
models_by_feature,
last_update: None,
base_llm_for_terminal_view,
#[cfg(not(target_family = "wasm"))]
bedrock_models_fetched: false,
};
// In agent mode eval builds, eagerly kick off a fetch of the model list from the server
@@ -580,47 +570,46 @@ impl LLMPreferences {
#[cfg(not(target_family = "wasm"))]
{
Self::ensure_default_models_in_settings(ctx);
me.inject_bedrock_models(ctx);
if *AISettings::as_ref(ctx).bedrock_enabled.value() {
me.trigger_bedrock_discovery(ctx);
}
}
me
}
#[cfg(not(target_family = "wasm"))]
fn trigger_bedrock_discovery(&mut self, ctx: &mut ModelContext<Self>) {
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::bedrock::discovery::discover_inference_profiles;
self.bedrock_models_fetched = true;
fn ensure_default_models_in_settings(ctx: &mut ModelContext<Self>) {
use crate::ai::bedrock::models::DEFAULT_BEDROCK_MODELS;
let settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig {
auth_method: settings.bedrock_auth_method.value().clone(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}.with_external_fallbacks();
let mut current_models: Vec<BedrockModelConfig> =
settings.bedrock_models.value().clone();
ctx.spawn(
async move { discover_inference_profiles(&config).await },
|me, result, ctx| match result {
Ok(models) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.bedrock_models.set_value(models, ctx);
});
me.inject_bedrock_models(ctx);
ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs);
}
Err(e) => {
log::error!("Failed to discover Bedrock inference profiles: {e}");
}
},
);
let existing_ids: std::collections::HashSet<String> =
current_models.iter().map(|m| m.model_id.clone()).collect();
let mut added = false;
for default in DEFAULT_BEDROCK_MODELS {
if !existing_ids.contains(default.model_id as &str) {
current_models.push(BedrockModelConfig {
model_id: default.model_id.to_string(),
display_name: default.display_name.to_string(),
vision_supported: default.vision_supported,
context_size: default.context_size,
});
added = true;
}
}
if added {
log::info!(
"[bedrock] Added missing default models to settings — now {} total",
current_models.len()
);
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.bedrock_models.set_value(current_models, ctx);
});
}
}
#[cfg(not(target_family = "wasm"))]
+1 -1
View File
@@ -13,8 +13,8 @@ use crate::{
templatable_installation::TemplatableMCPServerInstallation,
ParsedTemplatableMCPServerResult,
},
settings::{ai::AISettings, AISettingsChangedEvent},
galaxy_managed_paths_watcher::galaxy_data_dir,
settings::{ai::AISettings, AISettingsChangedEvent},
};
/// Singleton model to manage file-based MCP servers.
+1 -1
View File
@@ -2,8 +2,8 @@ use super::{FileBasedMCPManager, FileBasedMCPManagerEvent, MCPProvider};
use crate::ai::mcp::FileMCPWatcher;
use crate::ai::mcp::ParsedTemplatableMCPServerResult;
use crate::auth::AuthStateProvider;
use crate::settings::{AISettings, FocusedTerminalInfo};
use crate::galaxy_managed_paths_watcher::{galaxy_data_dir, GalaxyManagedPathsWatcher};
use crate::settings::{AISettings, FocusedTerminalInfo};
use crate::workspaces::user_workspaces::UserWorkspaces;
use galaxy_core::features::FeatureFlag;
use galaxyui::{App, Entity, ModelHandle, SingletonEntity as _};
+2 -2
View File
@@ -17,8 +17,6 @@ pub(crate) mod attachment_utils;
pub mod aws_credentials;
#[cfg(not(target_family = "wasm"))]
pub mod bedrock;
#[allow(dead_code)]
pub mod prompt_builder;
pub(crate) mod block_context;
pub(crate) mod blocklist;
pub mod control_code_parser;
@@ -33,6 +31,8 @@ pub(crate) mod llms;
pub mod onboarding;
pub(crate) mod persisted_workspace;
pub(crate) mod predict;
#[allow(dead_code)]
pub mod prompt_builder;
pub mod request_usage_model;
pub(crate) mod restored_conversations;
pub(crate) mod skills;
+1 -1
View File
@@ -45,4 +45,4 @@ impl PromptContext {
self.current_time = time.into();
self
}
}
}
+1 -1
View File
@@ -187,4 +187,4 @@ impl PromptBuilder {
}
#[cfg(test)]
mod tests;
mod tests;
+1 -1
View File
@@ -63,4 +63,4 @@ impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
}
@@ -157,4 +157,4 @@ You have access to tools for examining the code under review:
- `search_codebase` Semantic search for related implementations
- `run_shell_command` For read-only commands (git diff, git log, etc.)
Use these tools to gather context needed for a thorough review. You should read the files being changed and their surrounding context before providing feedback."#;
Use these tools to gather context needed for a thorough review. You should read the files being changed and their surrounding context before providing feedback."#;
+1 -1
View File
@@ -37,4 +37,4 @@ pub fn tool_usage_guidelines(mode: &Mode, provider: &Provider) -> String {
match provider {
Provider::Anthropic => anthropic::tool_usage_guidelines(mode),
}
}
}
@@ -25,4 +25,4 @@ impl std::fmt::Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
}
+6 -3
View File
@@ -1,6 +1,6 @@
#[cfg(test)]
mod prompt_builder_tests {
use super::*;
use crate::ai::prompt_builder::*;
#[test]
fn test_code_mode_builds_successfully() {
@@ -87,7 +87,10 @@ mod prompt_builder_tests {
.with_mcp_tools(vec![mcp_tool.clone()])
.build();
assert!(prompt.tools.iter().any(|t| t.name == "mcp__github__create_pr"));
assert!(prompt
.tools
.iter()
.any(|t| t.name == "mcp__github__create_pr"));
}
#[test]
@@ -128,4 +131,4 @@ mod prompt_builder_tests {
assert!(prompt.system_prompt.contains("/projects/myapp"));
assert!(prompt.system_prompt.contains("feature/new-thing"));
}
}
}
+10 -5
View File
@@ -218,7 +218,8 @@ fn read_mcp_resource() -> ToolDefinition {
fn read_documents() -> ToolDefinition {
ToolDefinition {
name: "read_documents".to_string(),
description: "Read the contents of one or more Galaxy notebook documents by their IDs.".to_string(),
description: "Read the contents of one or more Galaxy notebook documents by their IDs."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -232,7 +233,8 @@ fn read_documents() -> ToolDefinition {
fn create_documents() -> ToolDefinition {
ToolDefinition {
name: "create_documents".to_string(),
description: "Create new Galaxy notebook documents with the specified title and content.".to_string(),
description: "Create new Galaxy notebook documents with the specified title and content."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -246,7 +248,8 @@ fn create_documents() -> ToolDefinition {
fn edit_documents() -> ToolDefinition {
ToolDefinition {
name: "edit_documents".to_string(),
description: "Edit existing Galaxy notebook documents using search/replace diffs.".to_string(),
description: "Edit existing Galaxy notebook documents using search/replace diffs."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -305,7 +308,9 @@ fn ask_user_question() -> ToolDefinition {
fn read_skill() -> ToolDefinition {
ToolDefinition {
name: "read_skill".to_string(),
description: "Read a skill definition to understand available capabilities and how to use them.".to_string(),
description:
"Read a skill definition to understand available capabilities and how to use them."
.to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
@@ -328,4 +333,4 @@ fn fetch_conversation() -> ToolDefinition {
"required": ["conversation_id"]
}),
}
}
}
@@ -14,11 +14,11 @@ use super::{
};
use watcher::{BulkFilesystemWatcherEvent, HomeDirectoryWatcher, HomeDirectoryWatcherEvent};
use crate::server::datetime_ext::DateTimeExt;
use crate::galaxy_managed_paths_watcher::{
filter_repository_update_by_prefix, galaxy_managed_skill_dirs, GalaxyManagedPathsWatcher,
GalaxyManagedPathsWatcherEvent,
};
use crate::server::datetime_ext::DateTimeExt;
use ai::skills::{
home_skills_path, parse_skill, ParsedSkill, SkillProvider, SKILL_PROVIDER_DEFINITIONS,
};
+3 -2
View File
@@ -25,7 +25,7 @@ use crate::{
active_theme_kind, FontSettings, FontSettingsChangedEvent, MonospaceFontSize, Settings,
ThemeSettings,
},
themes::theme::{ThemeKind, GalaxyTheme},
themes::theme::{GalaxyTheme, ThemeKind},
ASSETS,
};
@@ -246,7 +246,8 @@ impl AppearanceManager {
return;
};
let ns_data: id = msg_send![class!(NSData), dataWithBytes:icon_data.as_ptr() length:icon_data.len()];
let ns_data: id =
msg_send![class!(NSData), dataWithBytes:icon_data.as_ptr() length:icon_data.len()];
let image: id = msg_send![class!(NSImage), alloc];
let image: id = msg_send![image, initWithData:ns_data];
+2
View File
@@ -29,6 +29,8 @@ fn main() -> Result<()> {
state = state.with_additional_features(galaxy_core::features::DEBUG_FLAGS);
}
state = state.with_additional_features(&[
FeatureFlag::AgentMode,
FeatureFlag::AgentView,
FeatureFlag::LspCompletion,
FeatureFlag::LspCodeActions,
FeatureFlag::LspRename,
+8 -5
View File
@@ -23,7 +23,9 @@ const MAX_VISIBLE_ACTIONS: usize = 12;
pub(super) enum CodeActionsState {
Idle,
Requesting { abort_handle: AbortHandle },
Requesting {
abort_handle: AbortHandle,
},
Available {
actions: Vec<CodeActionData>,
anchor_offset: CharOffset,
@@ -118,10 +120,11 @@ impl LocalCodeEditorView {
})
.collect();
let future = match lsp_server
.as_ref(ctx)
.code_actions(file_path.to_path_buf(), lsp_range, diagnostics_at_cursor)
{
let future = match lsp_server.as_ref(ctx).code_actions(
file_path.to_path_buf(),
lsp_range,
diagnostics_at_cursor,
) {
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.code_actions: {e}");
+29 -28
View File
@@ -6,12 +6,14 @@ use galaxy_core::ui::appearance::Appearance;
use galaxy_core::ui::theme::color::internal_colors;
use galaxyui::elements::{
Border, ChildAnchor, ClippedScrollStateHandle, ClippedScrollable, ConstrainedBox, Container,
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink,
Hoverable, MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
CornerRadius, CrossAxisAlignment, Flex, FormattedTextElement, HighlightedHyperlink, Hoverable,
MainAxisSize, MouseStateHandle, OffsetPositioning, ParentAnchor, ParentElement,
ParentOffsetBounds, Radius, ScrollbarWidth, Shrinkable, Text,
};
use galaxyui::{AppContext, Element, SingletonEntity, ViewContext};
use lsp::{CompletionItem, CompletionItemData, CompletionKind, CompletionResult, CompletionTrigger};
use lsp::{
CompletionItem, CompletionItemData, CompletionKind, CompletionResult, CompletionTrigger,
};
use markdown_parser::FormattedText;
use pathfinder_geometry::vector::Vector2F;
use string_offset::CharOffset;
@@ -38,7 +40,9 @@ pub(super) struct ResolvedDocumentation {
pub(super) enum CompletionState {
Idle,
Requesting { abort_handle: AbortHandle },
Requesting {
abort_handle: AbortHandle,
},
Showing {
items: Vec<CompletionItemData>,
filtered_indices: Vec<usize>,
@@ -281,16 +285,17 @@ impl LocalCodeEditorView {
.as_ref(ctx)
.offset_to_lsp_position(trigger_offset, ctx);
let future = match lsp_server
.as_ref(ctx)
.completion(file_path.to_path_buf(), lsp_position, trigger)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.completion: {e}");
return;
}
};
let future =
match lsp_server
.as_ref(ctx)
.completion(file_path.to_path_buf(), lsp_position, trigger)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.completion: {e}");
return;
}
};
self.completion_state.dismiss();
@@ -588,9 +593,7 @@ impl LocalCodeEditorView {
let mut content_column = Flex::column();
for (display_idx, &item_idx) in
filtered_indices.iter().enumerate().take(visible_count)
{
for (display_idx, &item_idx) in filtered_indices.iter().enumerate().take(visible_count) {
let item = &items[item_idx];
let is_selected = display_idx == *selected_index;
let mouse_state = item_mouse_states
@@ -602,9 +605,9 @@ impl LocalCodeEditorView {
let hoverable_item = Hoverable::new(mouse_state, move |_| item_element)
.on_hover(move |is_hovered, ctx, _, _| {
if is_hovered {
ctx.dispatch_typed_action(
LocalCodeEditorAction::CompletionHoverItem(display_idx),
);
ctx.dispatch_typed_action(LocalCodeEditorAction::CompletionHoverItem(
display_idx,
));
}
})
.on_click(move |ctx, _, _| {
@@ -677,15 +680,13 @@ impl LocalCodeEditorView {
let row = Flex::row()
.with_cross_axis_alignment(CrossAxisAlignment::Start)
.with_child(menu_box)
.with_child(
Container::new(docs_element)
.with_padding_left(2.)
.finish(),
)
.with_child(Container::new(docs_element).with_padding_left(2.).finish())
.finish();
Some(ConstrainedBox::new(row)
.with_max_height(COMPLETION_MENU_MAX_HEIGHT)
.finish())
Some(
ConstrainedBox::new(row)
.with_max_height(COMPLETION_MENU_MAX_HEIGHT)
.finish(),
)
} else {
Some(menu_box)
}
+4 -4
View File
@@ -85,18 +85,18 @@ const DROP_SHADOW_COLOR: ColorU = ColorU {
const HOVER_DEBOUNCE_PERIOD: Duration = Duration::from_millis(500);
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
use super::diff_viewer::DiffViewer;
use super::editor::{
scroll::{ScrollPosition, ScrollTrigger},
view::{CodeEditorEvent, CodeEditorView},
};
use super::code_actions::{CodeActionsState, CODE_ACTIONS_DEBOUNCE_PERIOD};
use super::completion::{CompletionState, COMPLETION_DEBOUNCE_PERIOD};
use super::rename::RenameState;
use super::signature_help::SignatureHelpState;
use super::find_references_view::{FindReferencesView, FindReferencesViewEvent};
use super::language_server_extension::ProcessedDiagnostic;
use super::lsp_telemetry::LspTelemetryEvent;
use super::rename::RenameState;
use super::signature_help::SignatureHelpState;
use super::ImmediateSaveError;
use galaxy_core::send_telemetry_from_ctx;
+4 -4
View File
@@ -13,15 +13,15 @@ pub mod completion;
#[cfg(not(target_family = "wasm"))]
pub mod find_references_view;
#[cfg(not(target_family = "wasm"))]
pub mod rename;
#[cfg(not(target_family = "wasm"))]
pub mod signature_help;
#[cfg(not(target_family = "wasm"))]
pub mod language_server_extension;
#[cfg_attr(not(target_family = "wasm"), path = "local_code_editor.rs")]
#[cfg_attr(target_family = "wasm", path = "local_code_editor_wasm.rs")]
pub mod local_code_editor;
#[cfg(not(target_family = "wasm"))]
pub mod rename;
#[cfg(not(target_family = "wasm"))]
pub mod signature_help;
#[cfg(not(target_family = "wasm"))]
pub use local_code_editor::ShowFindReferencesCard;
pub mod diff_viewer;
pub mod editor;
+22 -23
View File
@@ -12,12 +12,16 @@ use super::local_code_editor::LocalCodeEditorView;
pub(super) enum RenameState {
Idle,
Preparing { abort_handle: AbortHandle },
Preparing {
abort_handle: AbortHandle,
},
InputActive {
editor: ViewHandle<EditorView>,
anchor_offset: CharOffset,
},
Applying { abort_handle: AbortHandle },
Applying {
abort_handle: AbortHandle,
},
}
impl Default for RenameState {
@@ -141,17 +145,11 @@ impl LocalCodeEditorView {
ctx.notify();
}
fn handle_rename_editor_event(
&mut self,
event: &EditorEvent,
ctx: &mut ViewContext<Self>,
) {
fn handle_rename_editor_event(&mut self, event: &EditorEvent, ctx: &mut ViewContext<Self>) {
match event {
EditorEvent::Enter => {
let new_name = match &self.rename_state {
RenameState::InputActive { editor, .. } => {
editor.as_ref(ctx).buffer_text(ctx)
}
RenameState::InputActive { editor, .. } => editor.as_ref(ctx).buffer_text(ctx),
_ => return,
};
self.confirm_rename(new_name, ctx);
@@ -191,19 +189,20 @@ impl LocalCodeEditorView {
.as_ref(ctx)
.offset_to_lsp_position(anchor_offset, ctx);
let future = match lsp_server
.as_ref(ctx)
.rename(file_path.to_path_buf(), lsp_position, new_name)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.rename: {e}");
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
ctx.notify();
return;
}
};
let future =
match lsp_server
.as_ref(ctx)
.rename(file_path.to_path_buf(), lsp_position, new_name)
{
Ok(future) => future,
Err(e) => {
log::warn!("Failed to call lsp.rename: {e}");
self.rename_state = RenameState::Idle;
ctx.focus(self.editor());
ctx.notify();
return;
}
};
let abort_handle = ctx
.spawn(future, move |me, result, ctx| {
+3 -13
View File
@@ -50,10 +50,7 @@ impl LocalCodeEditorView {
}
/// Check if a trigger character was typed and request signature help.
pub(super) fn on_content_changed_for_signature_help(
&mut self,
ctx: &mut ViewContext<Self>,
) {
pub(super) fn on_content_changed_for_signature_help(&mut self, ctx: &mut ViewContext<Self>) {
if !Self::is_signature_help_enabled() {
return;
}
@@ -85,11 +82,7 @@ impl LocalCodeEditorView {
}
}
fn request_signature_help(
&mut self,
trigger_offset: CharOffset,
ctx: &mut ViewContext<Self>,
) {
fn request_signature_help(&mut self, trigger_offset: CharOffset, ctx: &mut ViewContext<Self>) {
let Some(file_path) = self.file_path() else {
return;
};
@@ -187,10 +180,7 @@ impl LocalCodeEditorView {
}
/// Position the signature help tooltip above the cursor.
pub(super) fn signature_help_positioning(
&self,
app: &AppContext,
) -> Option<OffsetPositioning> {
pub(super) fn signature_help_positioning(&self, app: &AppContext) -> Option<OffsetPositioning> {
let anchor_offset = match &self.signature_help_state {
SignatureHelpState::Showing { anchor_offset, .. } => *anchor_offset,
_ => return None,
+1 -3
View File
@@ -4773,9 +4773,7 @@ impl DriveIndex {
}
}
if can_trash
&& (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash())
{
if can_trash && (!FeatureFlag::SharedWithMe.is_enabled() || access_level.can_trash()) {
menu_items.push(
MenuItemFields::new("Trash")
.with_on_select_action(DriveIndexAction::TrashObject {
+6 -3
View File
@@ -8367,9 +8367,12 @@ impl TypedActionView for EditorView {
ctx: &mut ViewContext<Self>,
) -> ActionAccessibilityContent {
match action {
EditorAction::UserInsert(text) => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help(text.to_string(), GalaxyA11yRole::UserAction),
),
EditorAction::UserInsert(text) => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
text.to_string(),
GalaxyA11yRole::UserAction,
))
}
EditorAction::SelectLeft
| EditorAction::SelectToLineEnd
| EditorAction::SelectLine(_)
+2 -2
View File
@@ -365,8 +365,8 @@ mod tests {
use repo_metadata::{RepositoryUpdate, TargetFile};
use super::{
filter_repository_update_by_prefix, galaxy_home_mcp_config_file_path, galaxy_home_skills_dir,
galaxy_managed_mcp_config_path, galaxy_managed_skill_dirs,
filter_repository_update_by_prefix, galaxy_home_mcp_config_file_path,
galaxy_home_skills_dir, galaxy_managed_mcp_config_path, galaxy_managed_skill_dirs,
};
#[test]
+6 -2
View File
@@ -41,11 +41,14 @@ mod experiments;
mod external_secrets;
#[cfg(target_family = "wasm")]
mod font_fallback;
mod galaxy_managed_paths_watcher;
mod global_resource_handles;
mod gpu_state;
mod input_classifier;
mod interval_timer;
mod linear;
#[cfg(feature = "local_ai")]
mod local_inference;
#[cfg(any(target_os = "macos", target_os = "windows"))]
mod login_item;
mod menu;
@@ -93,7 +96,6 @@ mod view_components;
mod vim_registers;
mod voice;
mod voltron;
mod galaxy_managed_paths_watcher;
#[cfg(target_family = "wasm")]
mod wasm_nux_dialog;
mod window_settings;
@@ -216,6 +218,9 @@ use crate::context_chips::prompt::Prompt;
use crate::default_terminal::DefaultTerminal;
use crate::drive::export::ExportManager;
use crate::env_vars::manager::EnvVarCollectionManager;
use crate::galaxy_managed_paths_watcher::{
ensure_galaxy_watch_roots_exist, GalaxyManagedPathsWatcher,
};
use crate::gpu_state::GPUState;
use crate::network::NetworkStatus;
use crate::notebooks::editor::keys::NotebookKeybindings;
@@ -243,7 +248,6 @@ use crate::terminal::{AudibleBell, History};
use crate::undo_close::UndoCloseStack;
use crate::user_config::GalaxyConfig;
use crate::vim_registers::VimRegisters;
use crate::galaxy_managed_paths_watcher::{ensure_galaxy_watch_roots_exist, GalaxyManagedPathsWatcher};
use crate::workflows::aliases::WorkflowAliases;
use crate::workflows::local_workflows::LocalWorkflows;
use crate::workspace::{ActiveSession, OneTimeModalModel, ToastStack};
+3
View File
@@ -0,0 +1,3 @@
// Local inference engine module — currently unused.
// The local_inference crate provides the engine and task definitions;
// this module is reserved for future app-layer integration.
+18 -9
View File
@@ -3089,15 +3089,21 @@ impl TypedActionView for RichTextEditorView {
EditorViewAction::CutLineLeft => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help("Cut line left", GalaxyA11yRole::UserAction),
),
EditorViewAction::CutLineRight => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help("Cut line right", GalaxyA11yRole::UserAction),
),
EditorViewAction::CutLineRight => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
"Cut line right",
GalaxyA11yRole::UserAction,
))
}
EditorViewAction::CutWordLeft => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help("Cut word left", GalaxyA11yRole::UserAction),
),
EditorViewAction::CutWordRight => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help("Cut word right", GalaxyA11yRole::UserAction),
),
EditorViewAction::CutWordRight => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
"Cut word right",
GalaxyA11yRole::UserAction,
))
}
EditorViewAction::ShowCharacterPalette => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
@@ -3159,9 +3165,12 @@ impl TypedActionView for RichTextEditorView {
format!("Change code block language to {code_block_type}"),
GalaxyA11yRole::UserAction,
)),
EditorViewAction::CopyTextToClipboard { .. } => ActionAccessibilityContent::Custom(
AccessibilityContent::new_without_help("Copy code block", GalaxyA11yRole::UserAction),
),
EditorViewAction::CopyTextToClipboard { .. } => {
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
"Copy code block",
GalaxyA11yRole::UserAction,
))
}
EditorViewAction::ToggleTaskList(_) => {
// TODO(ben): Is it useful to include the text and/or on/off state here?
ActionAccessibilityContent::Custom(AccessibilityContent::new_without_help(
+1 -1
View File
@@ -21,6 +21,7 @@ use crate::{
cloud_object::model::persistence::CloudModel,
context_chips::prompt::Prompt,
experiments,
galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher,
network::NetworkStatus,
notebooks::{
editor::keys::NotebookKeybindings, manager::NotebookManager, notebook::NotebookView,
@@ -49,7 +50,6 @@ use crate::{
},
test_util::settings::initialize_settings_for_tests,
undo_close::UndoCloseStack,
galaxy_managed_paths_watcher::GalaxyManagedPathsWatcher,
workflows::local_workflows::LocalWorkflows,
workspace::{
sync_inputs::SyncedInputState, ActiveSession, OneTimeModalModel, WorkspaceRegistry,
+4 -12
View File
@@ -418,9 +418,7 @@ impl PaneContent for TerminalPane {
if ambient_model.is_ambient_agent() {
let task_id = ambient_model.task_id();
log::info!(
"[session-save] pane=viewer/ambient task_id={task_id:?}"
);
log::info!("[session-save] pane=viewer/ambient task_id={task_id:?}");
return LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
uuid: self.uuid.clone(),
task_id,
@@ -428,9 +426,7 @@ impl PaneContent for TerminalPane {
}
let cwd = view.pwd_if_local(app);
log::info!(
"[session-save] pane=viewer cwd={cwd:?} is_active={is_active}"
);
log::info!("[session-save] pane=viewer cwd={cwd:?} is_active={is_active}");
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: self.uuid.clone(),
cwd,
@@ -448,18 +444,14 @@ impl PaneContent for TerminalPane {
// can be restored via the ambient agent task if one exists.
let task_id = view.model.lock().ambient_agent_task_id();
if task_id.is_some() {
log::info!(
"[session-save] pane=transcript/ambient task_id={task_id:?}"
);
log::info!("[session-save] pane=transcript/ambient task_id={task_id:?}");
LeafContents::AmbientAgent(AmbientAgentPaneSnapshot {
uuid: self.uuid.clone(),
task_id,
})
} else {
let cwd = view.pwd_if_local(app);
log::info!(
"[session-save] pane=transcript cwd={cwd:?} is_active={is_active}"
);
log::info!("[session-save] pane=transcript cwd={cwd:?} is_active={is_active}");
LeafContents::Terminal(TerminalPaneSnapshot {
uuid: self.uuid.clone(),
cwd,
+4 -2
View File
@@ -14,7 +14,9 @@ use crate::cloud_object::model::persistence::CloudModel;
use crate::cloud_object::{GenericStringObjectFormat, JsonObjectType, ObjectType};
use crate::drive::export::ExportManager;
use crate::drive::items::WarpDriveItemId;
use crate::drive::{CloudObjectTypeAndId, OpenGalaxyDriveObjectArgs, OpenGalaxyDriveObjectSettings};
use crate::drive::{
CloudObjectTypeAndId, OpenGalaxyDriveObjectArgs, OpenGalaxyDriveObjectSettings,
};
use crate::experiments::{BlockOnboarding, Experiment};
use crate::interval_timer::IntervalTimer;
use crate::launch_configs::launch_config;
@@ -49,7 +51,7 @@ use crate::terminal::keys_settings::KeysSettings;
use crate::terminal::shell::ShellType;
use crate::terminal::view::{cell_size_and_padding, TerminalAction};
use crate::themes::onboarding_theme_picker_themes;
use crate::themes::theme::{AnsiColorIdentifier, Blend, Fill, ThemeKind, GalaxyThemeConfig};
use crate::themes::theme::{AnsiColorIdentifier, Blend, Fill, GalaxyThemeConfig, ThemeKind};
use crate::uri::OpenMCPSettingsArgs;
use crate::util::bindings::{self, is_binding_pty_compliant};
use crate::util::traffic_lights::{traffic_light_data, TrafficLightData, TrafficLightMouseStates};
@@ -46,7 +46,11 @@ impl DataSource {
Self { searcher }
}
fn handle_config_event(&mut self, event: &GalaxyConfigUpdateEvent, ctx: &mut ModelContext<Self>) {
fn handle_config_event(
&mut self,
event: &GalaxyConfigUpdateEvent,
ctx: &mut ModelContext<Self>,
) {
if matches!(event, GalaxyConfigUpdateEvent::LaunchConfigs) {
self.searcher.refresh_search_index(ctx);
}
@@ -565,11 +565,6 @@ fn all_commands() -> Vec<StaticCommand> {
commands.push(CREATE_NEW_PROJECT.clone());
}
if FeatureFlag::SummarizationConversationCommand.is_enabled() {
commands.push(COMPACT.clone());
commands.push(COMPACT_AND.clone());
}
if FeatureFlag::QueueSlashCommand.is_enabled() {
commands.push(QUEUE.clone());
}
-1
View File
@@ -10,7 +10,6 @@ pub use collector::*;
pub use context::telemetry_context;
pub use events::*;
/// No-op stub. Telemetry has been removed from Galaxy.
pub struct TelemetryApi {
pub client: http_client::Client,
+23
View File
@@ -451,6 +451,13 @@ pub struct BedrockModelConfig {
#[serde(default)]
#[schemars(description = "Whether the model supports image/vision input.")]
pub vision_supported: bool,
#[serde(default = "default_context_size")]
#[schemars(description = "Maximum context window size in tokens.")]
pub context_size: u32,
}
fn default_context_size() -> u32 {
200_000
}
impl settings_value::SettingsValue for BedrockModelConfig {}
@@ -823,6 +830,18 @@ define_settings_group!(AISettings, settings: [
private: false,
toml_path: "agents.warp_agent.input.ai_command_denylist",
description: "Commands to exclude from AI natural language autodetection.",
}
// Whether to use the local AI model for input classification and suggestions
// instead of making Bedrock API calls. When enabled, SmolLM2-135M handles
// autodetection, prompt suggestions, and code banners locally.
use_local_model: UseLocalModel {
type: bool,
default: true,
supported_platforms: SupportedPlatforms::DESKTOP,
sync_to_cloud: SyncToCloud::Globally(RespectUserSyncSetting::Yes),
private: false,
toml_path: "agents.warp_agent.input.use_local_model",
description: "Use a locally downloaded AI model for command recognition and suggestions instead of Bedrock.",
},
// This field should not be referenced directly to lookup intelligent autosuggestion enablement
// -- use the `is_intelligent_autosuggestions_enabled()` getter.
@@ -1642,6 +1661,10 @@ impl AISettings {
self.is_active_ai_enabled(app) && *self.code_suggestions_enabled_internal
}
pub fn should_use_local_model(&self) -> bool {
*self.use_local_model
}
pub fn is_natural_language_autosuggestions_enabled(&self, app: &galaxyui::AppContext) -> bool {
self.is_active_ai_enabled(app) && *self.natural_language_autosuggestions_enabled_internal
}
+1 -1
View File
@@ -3,7 +3,7 @@ use async_recursion::async_recursion;
use async_trait::async_trait;
use galaxy_core::ui::{
color::hex_color::coloru_from_hex_string,
theme::{AnsiColor, AnsiColors, TerminalColors, GalaxyTheme},
theme::{AnsiColor, AnsiColors, GalaxyTheme, TerminalColors},
};
use galaxyui::fonts::FontInfo;
use serde::Deserialize;
+4 -1
View File
@@ -29,7 +29,10 @@ use super::iterm_parser::ITermProfile;
#[derive(Debug)]
pub enum ThemeType {
LightAndDark { light: GalaxyTheme, dark: GalaxyTheme },
LightAndDark {
light: GalaxyTheme,
dark: GalaxyTheme,
},
Single(GalaxyTheme),
}
+1 -1
View File
@@ -2,7 +2,7 @@ use std::path::PathBuf;
use async_trait::async_trait;
use bitflags::bitflags;
use galaxy_core::ui::theme::{AnsiColors, TerminalColors, GalaxyTheme};
use galaxy_core::ui::theme::{AnsiColors, GalaxyTheme, TerminalColors};
use galaxyui::{
fonts::FontInfo, keymap::Keystroke, platform::mac::utils::unicode_char_to_key, DisplayIdx,
};
+1 -1
View File
@@ -118,7 +118,7 @@ impl SettingsFileError {
use crate::{
root_view::QuakeModePinPosition,
terminal::{BlockListSettings, BlockPadding},
themes::theme::{ThemeKind, GalaxyTheme},
themes::theme::{GalaxyTheme, ThemeKind},
user_config::GalaxyConfig,
};
use galaxy_core::features::FeatureFlag;
+2 -1
View File
@@ -66,7 +66,8 @@ impl SettingsWidget for AboutPageWidget {
) -> Box<dyn Element> {
let ui_builder = appearance.ui_builder();
let icon_file = AppIconSettings::get_base_icon_file_name(*AppIconSettings::as_ref(_app).app_icon);
let icon_file =
AppIconSettings::get_base_icon_file_name(*AppIconSettings::as_ref(_app).app_icon);
let image_path = match icon_file {
"galaxy" => "bundled/png/galaxy.png",
"galaxy_dotmatrix" => "bundled/png/galaxy_dotmatrix.png",
+38 -87
View File
@@ -24,11 +24,10 @@ use crate::settings::{
AIAutoDetectionEnabled, AICommandDenylist, AISettingsChangedEvent,
AgentModeCodingPermissionsType, AgentModeCommandExecutionDenylist,
AgentModeCommandExecutionPredicate, AgentModeQuerySuggestionsEnabled, BedrockAuthMethod,
BedrockAutoLogin, BedrockCrossRegionInference, BedrockEnabled,
CodeSettings, CodebaseContextEnabled, FileBasedMcpEnabled, GitOperationsAutogenEnabled,
IncludeAgentCommandsInHistory, IntelligentAutosuggestionsEnabled, MemoryEnabled,
NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled, RuleSuggestionsEnabled,
SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar,
BedrockAutoLogin, BedrockEnabled, CodeSettings, CodebaseContextEnabled, FileBasedMcpEnabled,
GitOperationsAutogenEnabled, IncludeAgentCommandsInHistory, IntelligentAutosuggestionsEnabled,
MemoryEnabled, NLDInTerminalEnabled, NaturalLanguageAutosuggestionsEnabled,
RuleSuggestionsEnabled, SharedBlockTitleGenerationEnabled, ShouldRenderCLIAgentToolbar,
ShouldRenderUseAgentToolbarForUserCommands, ShowAgentTips, ShowConversationHistory,
ShowHintText, ThinkingDisplayMode, VoiceInputEnabled, WarpDriveContextEnabled,
};
@@ -1326,8 +1325,9 @@ impl AISettingsPageView {
dropdown
});
let (page, _) = Self::build_page(None, ctx);
Self {
page: Self::build_page(None, ctx),
page,
active_subpage: None,
voice_input_toggle_key_dropdown,
autodetection_denylist_editor,
@@ -1387,12 +1387,16 @@ impl AISettingsPageView {
pub fn set_active_subpage(&mut self, subpage: Option<AISubpage>, ctx: &mut ViewContext<Self>) {
if self.active_subpage != subpage {
self.active_subpage = subpage;
self.page = Self::build_page(subpage, ctx);
let (page, _) = Self::build_page(subpage, ctx);
self.page = page;
ctx.notify();
}
}
fn build_page(subpage: Option<AISubpage>, ctx: &mut ViewContext<Self>) -> PageType<Self> {
fn build_page(
subpage: Option<AISubpage>,
ctx: &mut ViewContext<Self>,
) -> (PageType<Self>, Option<ViewHandle<ActionButton>>) {
let ai_settings = AISettings::as_ref(ctx);
let mut widgets: Vec<Box<dyn SettingsWidget<View = AISettingsPageView>>> = Vec::new();
@@ -1496,14 +1500,17 @@ impl AISettingsPageView {
widgets.push(Box::new(CLIAgentWidget::default()));
}
Some(AISubpage::Bedrock) => {
widgets.push(Box::new(BedrockSettingsWidget::new(ctx)));
let widget = BedrockSettingsWidget::new(ctx);
widgets.push(Box::new(widget));
let title: Option<&str> = None;
return (PageType::new_uncategorized(widgets, title), None);
}
}
// Subpage widgets render their own subheader-sized titles internally,
// so we don't pass a page-level title to PageType.
let title: Option<&str> = None;
PageType::new_uncategorized(widgets, title)
(PageType::new_uncategorized(widgets, title), None)
}
fn handle_detection_denylist_editor_event(
@@ -2727,37 +2734,7 @@ impl TypedActionView for AISettingsPageView {
ctx.notify();
}
AISettingsPageAction::RefreshAwsBedrock => {
#[cfg(not(target_family = "wasm"))]
{
use crate::ai::bedrock::client::BedrockClientConfig;
use crate::ai::bedrock::discovery::discover_inference_profiles;
use settings::Setting;
let ai_settings = AISettings::as_ref(ctx);
let config = BedrockClientConfig {
auth_method: ai_settings.bedrock_auth_method.value().clone(),
profile: ai_settings.bedrock_profile.value().clone(),
region: ai_settings.bedrock_region.value().clone(),
access_key_id: ai_settings.bedrock_access_key_id.value().clone(),
secret_access_key: ai_settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *ai_settings.bedrock_cross_region_inference.value(),
}.with_external_fallbacks();
ctx.spawn(
async move { discover_inference_profiles(&config).await },
|_me, result, ctx| match result {
Ok(models) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
let _ = settings.bedrock_models.set_value(models, ctx);
});
}
Err(e) => {
log::error!("Failed to discover Bedrock inference profiles: {e}");
}
},
);
}
ctx.notify();
// Discovery removed — models are configured via settings.toml
}
AISettingsPageAction::SetBedrockAuthMethod(method) => {
AISettings::handle(ctx).update(ctx, |settings, ctx| {
@@ -3754,6 +3731,7 @@ impl SettingsWidget for ActiveAIWidget {
.finish(),
);
if self.is_next_command_toggleable(app) {
column.add_child(self.render_next_command_section(view, app));
}
@@ -5828,7 +5806,6 @@ mod tests;
struct BedrockSettingsWidget {
enabled_toggle: SwitchStateHandle,
cross_region_toggle: SwitchStateHandle,
auto_login_toggle: SwitchStateHandle,
auth_method_dropdown: ViewHandle<Dropdown<AISettingsPageAction>>,
profile_dropdown: ViewHandle<Dropdown<AISettingsPageAction>>,
@@ -5836,13 +5813,12 @@ struct BedrockSettingsWidget {
auth_refresh_command_editor: ViewHandle<EditorView>,
access_key_editor: ViewHandle<EditorView>,
secret_key_editor: ViewHandle<EditorView>,
refresh_button: ViewHandle<ActionButton>,
}
impl BedrockSettingsWidget {
fn new(ctx: &mut ViewContext<<Self as SettingsWidget>::View>) -> Self {
let ai_settings = AISettings::as_ref(ctx);
let is_enabled = *ai_settings.bedrock_enabled.value();
let _is_enabled = *ai_settings.bedrock_enabled.value();
let region_val = ai_settings.bedrock_region.value().clone();
let auth_cmd_val = ai_settings.bedrock_auth_refresh_command.value().clone();
@@ -5856,7 +5832,7 @@ impl BedrockSettingsWidget {
BedrockAuthMethod::StaticKeys,
BedrockAuthMethod::Sso,
];
let current = AISettings::as_ref(ctx).bedrock_auth_method.value().clone();
let current = *AISettings::as_ref(ctx).bedrock_auth_method.value();
let selected_index = methods.iter().position(|m| *m == current).unwrap_or(0);
dropdown.add_items(
methods
@@ -5875,7 +5851,7 @@ impl BedrockSettingsWidget {
});
let profile_dropdown = ctx.add_typed_action_view(|ctx| {
use crate::ai::bedrock::discovery::list_aws_profiles;
use crate::ai::bedrock::external_config::list_aws_profiles;
let mut dropdown = Dropdown::new(ctx);
let profiles = list_aws_profiles();
@@ -6025,24 +6001,11 @@ impl BedrockSettingsWidget {
}
});
let refresh_button = ctx.add_typed_action_view(|_| {
ActionButton::new("Refresh AWS Bedrock", SecondaryTheme)
.with_icon(Icon::RefreshCw04)
.with_size(ButtonSize::Small)
.on_click(|ctx| {
ctx.dispatch_typed_action(AISettingsPageAction::RefreshAwsBedrock);
})
});
refresh_button.update(ctx, |button, ctx| {
button.set_disabled(!is_enabled, ctx);
});
let profile_dropdown_clone = profile_dropdown.clone();
let region_editor_clone = region_editor.clone();
let auth_refresh_command_editor_clone = auth_refresh_command_editor.clone();
let access_key_editor_clone = access_key_editor.clone();
let secret_key_editor_clone = secret_key_editor.clone();
let refresh_button_clone = refresh_button.clone();
ctx.subscribe_to_model(&AISettings::handle(ctx), move |_, _, event, ctx| {
if matches!(event, AISettingsChangedEvent::BedrockEnabled { .. }) {
let is_enabled = *AISettings::as_ref(ctx).bedrock_enabled.value();
@@ -6073,16 +6036,12 @@ impl BedrockSettingsWidget {
is_enabled,
ctx,
);
refresh_button_clone.update(ctx, |button, ctx| {
button.set_disabled(!is_enabled, ctx);
});
ctx.notify();
}
});
Self {
enabled_toggle: SwitchStateHandle::default(),
cross_region_toggle: SwitchStateHandle::default(),
auto_login_toggle: SwitchStateHandle::default(),
auth_method_dropdown,
profile_dropdown,
@@ -6090,7 +6049,6 @@ impl BedrockSettingsWidget {
auth_refresh_command_editor,
access_key_editor,
secret_key_editor,
refresh_button,
}
}
@@ -6151,8 +6109,7 @@ impl SettingsWidget for BedrockSettingsWidget {
) -> Box<dyn Element> {
let ai_settings = AISettings::as_ref(app);
let is_enabled = *ai_settings.bedrock_enabled.value();
let auth_method = ai_settings.bedrock_auth_method.value().clone();
let cross_region = *ai_settings.bedrock_cross_region_inference.value();
let auth_method = *ai_settings.bedrock_auth_method.value();
let auto_login = *ai_settings.bedrock_auto_login.value();
let mut column = Flex::column().with_spacing(16.);
@@ -6267,29 +6224,23 @@ impl SettingsWidget for BedrockSettingsWidget {
app,
));
column.add_child(
Flex::column()
.with_child(render_ai_setting_toggle::<BedrockCrossRegionInference>(
"Cross-region inference",
AISettingsPageAction::ToggleBedrockCrossRegionInference,
cross_region,
is_enabled,
self.cross_region_toggle.clone(),
&RefCell::new(HashMap::new()),
app,
))
.with_child(render_ai_setting_description(
"Automatically add geographic prefixes to model IDs for higher availability.",
is_enabled,
app,
))
.finish(),
);
column.add_child(render_separator(appearance));
column.add_child(ChildView::new(&self.refresh_button).finish());
let configured_models: Vec<_> = ai_settings.bedrock_models.value().clone();
if !configured_models.is_empty() {
let description = format!(
"{} model{} configured via settings.toml.",
configured_models.len(),
if configured_models.len() == 1 { "" } else { "s" }
);
column.add_child(render_ai_setting_description(description, is_enabled, app));
} else {
column.add_child(render_ai_setting_description(
"No models configured. Add models to ~/.galaxy/settings.toml under [ai.bedrock].",
is_enabled,
app,
));
}
column.finish()
}
+3 -1
View File
@@ -45,7 +45,9 @@ use crate::terminal::settings::{
};
use crate::terminal::{BlockListSettings, ShowBlockDividers};
use crate::terminal::{ShowJumpToBottomOfBlockButton, SizeInfo};
use crate::themes::theme::{self, RespectSystemTheme, SelectedSystemThemes, ThemeKind, GalaxyTheme};
use crate::themes::theme::{
self, GalaxyTheme, RespectSystemTheme, SelectedSystemThemes, ThemeKind,
};
use crate::user_config::GalaxyConfig;
use crate::util::bindings;
use crate::window_settings::{
@@ -3063,7 +3063,8 @@ impl UpdateEnvironmentForm {
"Suggest image"
};
let tooltip_text = "Galaxy will suggest a Docker image based on your selected repositories.";
let tooltip_text =
"Galaxy will suggest a Docker image based on your selected repositories.";
let button = Hoverable::new(
self.suggest_image_button_mouse_state.clone(),

Some files were not shown because too many files have changed in this diff Show More