Bump version to 1.6.3
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4ba9706e35
commit
59cfd0e2f5
+5
-1
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(¤t_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(¤t_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(¤t_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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
@@ -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"))]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -45,4 +45,4 @@ impl PromptContext {
|
||||
self.current_time = time.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,4 +187,4 @@ impl PromptBuilder {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod tests;
|
||||
|
||||
@@ -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."#;
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user