Bump version to 1.6.3

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ryan Ward
2026-06-12 14:17:06 -05:00
co-authored by Claude Opus 4.6
parent 4ba9706e35
commit 59cfd0e2f5
152 changed files with 8276 additions and 1664 deletions
+3 -1
View File
@@ -482,7 +482,9 @@ impl BlocklistAIActionModel {
.and_then(|queue| queue.front())
.cloned()
else {
log::info!("[tool-debug] try_to_execute_available_actions: no more pending actions");
log::info!(
"[tool-debug] try_to_execute_available_actions: no more pending actions"
);
return;
};
+8 -2
View File
@@ -739,14 +739,20 @@ impl BlocklistAIActionExecutor {
);
match execution {
AnyActionExecution::NotReady => {
log::info!("[tool-debug] try_to_execute_action: NOT READY - action_id={:?}", action_id);
log::info!(
"[tool-debug] try_to_execute_action: NOT READY - action_id={:?}",
action_id
);
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
action: Box::new(action_clone),
}
}
AnyActionExecution::InvalidAction => {
log::error!("[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}", action_id);
log::error!(
"[tool-debug] try_to_execute_action: INVALID ACTION - action_id={:?}",
action_id
);
debug_assert!(false, "Tried to execute AIAgentAction with wrong executor.");
TryExecuteResult::NotExecuted {
reason: NotExecutedReason::NotReady,
@@ -55,7 +55,8 @@ impl AskUserQuestionExecutor {
// For child agent conversations, route the question to the parent
// for silent auto-answer instead of presenting UI to the user.
if let Some(parent_conversation_id) = self.parent_conversation_id(input.conversation_id, ctx)
if let Some(parent_conversation_id) =
self.parent_conversation_id(input.conversation_id, ctx)
{
let question_text = questions
.iter()
@@ -86,14 +87,12 @@ impl AskUserQuestionExecutor {
async move { receiver.recv().await },
|result, _ctx| match result {
Ok(AskUserQuestionDecision::Completed(answers)) => {
AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::Success { answers },
)
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Success {
answers,
})
}
Ok(AskUserQuestionDecision::Cancelled) | Err(_) => {
AIAgentActionResultType::AskUserQuestion(
AskUserQuestionResult::Cancelled,
)
AIAgentActionResultType::AskUserQuestion(AskUserQuestionResult::Cancelled)
}
},
);
@@ -141,12 +141,18 @@ impl CallMCPToolExecutor {
};
let Some(reconnecting_peer) = templatable_peer else {
log::error!("[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND", name_owned);
log::error!(
"[tool-debug] CallMCPToolExecutor: MCP server for tool '{}' NOT FOUND",
name_owned
);
return ActionExecution::Sync(AIAgentActionResultType::CallMCPTool(
CallMCPToolResult::Error("MCP server for tool not found".to_owned()),
));
};
log::info!("[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'", name_owned);
log::info!(
"[tool-debug] CallMCPToolExecutor: found MCP server peer for tool '{}'",
name_owned
);
let name_owned_inner = name_owned.clone();
ActionExecution::new_async(
@@ -111,7 +111,11 @@ impl FileGlobExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}", patterns, path);
log::info!(
"[tool-debug] FileGlobExecutor::execute: patterns={:?}, path={:?}",
patterns,
path
);
// If the path is not provided, use the current working directory.
let path = path.clone().unwrap_or_else(|| ".".to_string());
@@ -252,7 +252,11 @@ impl GrepExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}", queries, path);
log::info!(
"[tool-debug] GrepExecutor::execute: queries={:?}, path={:?}",
queries,
path
);
let shell_launch_data = self.active_session.as_ref(ctx).shell_launch_data(ctx);
let shell_type = self.active_session.as_ref(ctx).shell_type(ctx);
@@ -151,10 +151,16 @@ impl RequestFileEditsExecutor {
else {
return ActionExecution::InvalidAction;
};
log::info!("[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}", id);
log::info!(
"[tool-debug] RequestFileEditsExecutor::execute: action_id={:?}",
id
);
let Some(diff_view) = self.diff_views.get(id) else {
log::warn!("[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}", id);
log::warn!(
"[tool-debug] RequestFileEditsExecutor: no diff view found for action_id={:?}",
id
);
return ActionExecution::NotReady;
};
@@ -68,7 +68,9 @@ impl StartAgentExecutor {
let history_model = BlocklistAIHistoryModel::handle(ctx);
ctx.subscribe_to_model(&history_model, Self::handle_history_event);
Self { pending: Vec::new() }
Self {
pending: Vec::new(),
}
}
fn handle_history_event(
@@ -91,8 +93,7 @@ impl StartAgentExecutor {
let parent_id = conversation.parent_conversation_id();
// Find the first pending entry that matches this parent and hasn't been assigned a child yet.
if let Some(pending) = self.pending.iter_mut().find(|p| {
p.child_conversation_id.is_none()
&& parent_id == Some(p.parent_conversation_id)
p.child_conversation_id.is_none() && parent_id == Some(p.parent_conversation_id)
}) {
pending.child_conversation_id = Some(*new_conversation_id);
}
@@ -100,17 +101,19 @@ impl StartAgentExecutor {
BlocklistAIHistoryEvent::ConversationServerTokenAssigned {
conversation_id, ..
} => {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
return;
};
// Don't remove yet if we're waiting for completion — we need
// the entry to stay so UpdatedConversationStatus can find it.
if self.pending[idx].wait_for_completion {
// Just log and continue — we'll resolve on Success status.
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
let agent_id = conversation
.and_then(|c| c.orchestration_agent_id())
.or_else(|| {
@@ -132,8 +135,8 @@ impl StartAgentExecutor {
return;
}
let pending = self.pending.remove(idx);
let conversation = BlocklistAIHistoryModel::as_ref(ctx)
.conversation(conversation_id);
let conversation =
BlocklistAIHistoryModel::as_ref(ctx).conversation(conversation_id);
// orchestration_agent_id() uses run_id in v2 mode, which won't
// exist for locally-spawned Bedrock child agents. Fall back to
// the server conversation token (set by the stream Init event)
@@ -195,9 +198,11 @@ impl StartAgentExecutor {
BlocklistAIHistoryEvent::UpdatedConversationStatus {
conversation_id, ..
} => {
let Some(idx) = self.pending.iter().position(|p| {
p.child_conversation_id.as_ref() == Some(conversation_id)
}) else {
let Some(idx) = self
.pending
.iter()
.position(|p| p.child_conversation_id.as_ref() == Some(conversation_id))
else {
return;
};
let history = BlocklistAIHistoryModel::as_ref(ctx);
@@ -227,10 +232,9 @@ impl StartAgentExecutor {
.map(|t| t.as_str().to_string())
})
.unwrap_or_else(|| conversation_id.to_string());
let _ = pending.sender.try_send(StartAgentDecision::Completed {
agent_id,
output,
});
let _ = pending
.sender
.try_send(StartAgentDecision::Completed { agent_id, output });
}
status => {
let error_msg = start_agent_error_message_for_status(
@@ -72,7 +72,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
assert_eq!(
executor
.pending
.as_ref()
.first()
.and_then(|pending| pending.child_conversation_id),
Some(child_conversation_id)
);
@@ -102,7 +102,7 @@ fn execute_returns_error_when_child_startup_is_blocked_before_initialization() {
));
executor.read(&app, |executor, _| {
assert!(executor.pending.is_none());
assert!(executor.pending.is_empty());
});
});
}
@@ -60,10 +60,7 @@ pub fn render_subagent_inline_panel(
};
let status = conversation.status().clone();
let agent_name = conversation
.agent_name()
.unwrap_or("Subagent")
.to_string();
let agent_name = conversation.agent_name().unwrap_or("Subagent").to_string();
let panel_bg = blended_colors::neutral_2(theme);
@@ -76,13 +73,7 @@ pub fn render_subagent_inline_panel(
let header_expanded = state.is_expanded;
column.add_child(
Hoverable::new(header_mouse_state, move |_mouse_state| {
render_panel_header(
&agent_name,
&header_status,
header_expanded,
panel_bg,
app,
)
render_panel_header(&agent_name, &header_status, header_expanded, panel_bg, app)
})
.on_click(move |ctx, _, _| {
ctx.dispatch_typed_action(AIBlockAction::ToggleSubagentPanel {
@@ -137,12 +128,11 @@ fn render_panel_header(
let mut left_side = Flex::row().with_cross_axis_alignment(CrossAxisAlignment::Center);
let (icon, icon_color) = status.status_icon_and_color(theme);
let status_icon_element = ConstrainedBox::new(
galaxyui::elements::Icon::new(icon.into(), icon_color).finish(),
)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
let status_icon_element =
ConstrainedBox::new(galaxyui::elements::Icon::new(icon.into(), icon_color).finish())
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
left_side.add_child(
Container::new(status_icon_element)
@@ -192,11 +182,7 @@ fn render_panel_header(
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
right_side.add_child(
Container::new(chevron)
.with_margin_right(4.)
.finish(),
);
right_side.add_child(Container::new(chevron).with_margin_right(4.).finish());
header_row.add_child(right_side.finish());
@@ -208,10 +194,7 @@ fn render_panel_header(
.finish()
}
fn collect_mini_transcript(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Vec<String> {
fn collect_mini_transcript(conversation_id: &AIConversationId, app: &AppContext) -> Vec<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let Some(conversation) = history_model.conversation(conversation_id) else {
return vec![];
@@ -290,10 +273,7 @@ fn render_mini_transcript(
.finish()
}
fn get_completion_summary(
conversation_id: &AIConversationId,
app: &AppContext,
) -> Option<String> {
fn get_completion_summary(conversation_id: &AIConversationId, app: &AppContext) -> Option<String> {
let history_model = BlocklistAIHistoryModel::as_ref(app);
let conversation = history_model.conversation(conversation_id)?;
@@ -317,11 +297,7 @@ fn get_completion_summary(
None
}
fn render_summary_footer(
summary: &str,
_background: ColorU,
app: &AppContext,
) -> Box<dyn Element> {
fn render_summary_footer(summary: &str, _background: ColorU, app: &AppContext) -> Box<dyn Element> {
let appearance = Appearance::as_ref(app);
let theme = appearance.theme();
let surface = theme.surface_2();
+2 -2
View File
@@ -391,12 +391,12 @@ pub(super) struct AIBlockStateHandles {
/// Mouse state handle for the fork conversation button
fork_conversation_handle: MouseStateHandle,
/// Mouse state handles per citation.
/// A given citation should only appear once per block.
footer_citation_chip_handles: HashMap<AIAgentCitation, MouseStateHandle>,
orchestration_navigation_card_handles: HashMap<AIAgentActionId, MouseStateHandle>,
pub(super) subagent_panel_states: HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
pub(super) subagent_panel_states:
HashMap<AIAgentActionId, super::agent_view::subagent_inline_panel::SubagentPanelState>,
references_section_collapsible_handle: MouseStateHandle,
+2 -3
View File
@@ -4,10 +4,9 @@ use super::{
cli_controller::{CLISubagentController, CLISubagentEvent, UserTakeOverReason},
model::{AIBlockModel, AIBlockModelImpl, AIBlockOutputStatus},
view_impl::common::{
render_switch_control_to_user_button, render_warping_indicator,
random_load_output_message, render_switch_control_to_user_button, render_warping_indicator,
render_warping_indicator_base, ButtonProps, ForceRefreshButtonProps, MaybeShimmeringText,
WarpingIndicatorProps, WarpingProps, random_load_output_message,
WAITING_FOR_USER_INPUT_MESSAGE,
WarpingIndicatorProps, WarpingProps, WAITING_FOR_USER_INPUT_MESSAGE,
},
};
use crate::{
+53 -11
View File
@@ -474,8 +474,10 @@ pub fn render_warping_indicator<V: View>(
} else {
// Show elapsed timer alongside the random Galaxy status message.
if let Some(start_time) = props.warping_start_time {
non_shimmering_text =
Some(format!(" ({})", format_elapsed_compact(start_time.elapsed())));
non_shimmering_text = Some(format!(
" ({})",
format_elapsed_compact(start_time.elapsed())
));
}
props.default_warping_text.clone()
}
@@ -3430,19 +3432,59 @@ pub struct FindContext<'a> {
/// A palette of colors for the user avatar silhouette, randomly selected per session.
const USER_AVATAR_PALETTE: &[ColorU] = &[
ColorU { r: 99, g: 179, b: 237, a: 255 }, // blue
ColorU { r: 129, g: 230, b: 217, a: 255 }, // teal
ColorU { r: 183, g: 148, b: 244, a: 255 }, // purple
ColorU { r: 252, g: 165, b: 165, a: 255 }, // red/coral
ColorU { r: 251, g: 191, b: 36, a: 255 }, // amber
ColorU { r: 110, g: 231, b: 183, a: 255 }, // green
ColorU { r: 249, g: 168, b: 212, a: 255 }, // pink
ColorU { r: 253, g: 186, b: 116, a: 255 }, // orange
ColorU {
r: 99,
g: 179,
b: 237,
a: 255,
}, // blue
ColorU {
r: 129,
g: 230,
b: 217,
a: 255,
}, // teal
ColorU {
r: 183,
g: 148,
b: 244,
a: 255,
}, // purple
ColorU {
r: 252,
g: 165,
b: 165,
a: 255,
}, // red/coral
ColorU {
r: 251,
g: 191,
b: 36,
a: 255,
}, // amber
ColorU {
r: 110,
g: 231,
b: 183,
a: 255,
}, // green
ColorU {
r: 249,
g: 168,
b: 212,
a: 255,
}, // pink
ColorU {
r: 253,
g: 186,
b: 116,
a: 255,
}, // orange
];
fn session_avatar_color() -> ColorU {
use std::sync::OnceLock;
use rand::Rng;
use std::sync::OnceLock;
static COLOR: OnceLock<ColorU> = OnceLock::new();
*COLOR.get_or_init(|| {
let idx = rand::thread_rng().gen_range(0..USER_AVATAR_PALETTE.len());
@@ -11,9 +11,8 @@ use super::{blocklist_image_asset_source, ResolvedBlocklistImageSources};
use super::{
collect_visual_markdown_lightbox_collection, compute_visual_section_width,
format_elapsed_compact, inline_image_source_label, lightbox_trigger_for_section,
query_prefix_highlight_len, render_scrollable_collapsible_content,
text_sections_with_indices, CollapsibleElementState, CollapsibleExpansionState,
VisualMarkdownLightboxCollection,
query_prefix_highlight_len, render_scrollable_collapsible_content, text_sections_with_indices,
CollapsibleElementState, CollapsibleExpansionState, VisualMarkdownLightboxCollection,
};
use crate::{
ai::agent::{
@@ -308,7 +307,10 @@ fn format_elapsed_compact_shows_minutes_under_120_minutes() {
assert_eq!(format_elapsed_compact(Duration::from_secs(90)), "1m");
assert_eq!(format_elapsed_compact(Duration::from_secs(120)), "2m");
assert_eq!(format_elapsed_compact(Duration::from_secs(45 * 60)), "45m");
assert_eq!(format_elapsed_compact(Duration::from_secs(119 * 60 + 59)), "119m");
assert_eq!(
format_elapsed_compact(Duration::from_secs(119 * 60 + 59)),
"119m"
);
}
#[test]
@@ -433,9 +433,7 @@ pub(super) fn render_start_agent(
}
if let Some(card_data) = child_conversation_card_data {
// Render inline subagent panel instead of navigation card
if let Some(panel_state) =
props.state_handles.subagent_panel_states.get(action_id)
{
if let Some(panel_state) = props.state_handles.subagent_panel_states.get(action_id) {
column.add_child(
crate::ai::blocklist::agent_view::subagent_inline_panel::render_subagent_inline_panel(
panel_state,
+5 -10
View File
@@ -85,8 +85,8 @@ use crate::{
},
requested_command::RequestedCommand,
search_codebase::SearchCodebaseView,
summarization::SummarizationView,
suggested_unit_tests::SuggestedUnitTestsView,
summarization::SummarizationView,
web_fetch::WebFetchView,
web_search::WebSearchView,
},
@@ -127,8 +127,8 @@ use super::{
use galaxyui::{
elements::{
Align, Border, ChildView, ConstrainedBox, Container, CornerRadius, CrossAxisAlignment,
Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment,
MainAxisSize, ParentElement, Radius, Shrinkable, Text, Wrap,
Expanded, Fill, Flex, FormattedTextElement, Hoverable, MainAxisAlignment, MainAxisSize,
ParentElement, Radius, Shrinkable, Text, Wrap,
},
keymap::Keystroke,
platform::{Cursor, OperatingSystem},
@@ -217,9 +217,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }));
if is_summarize_input {
let key = crate::ai::agent::MessageId::new(
"__summarization_inline_view__".to_string(),
);
let key = crate::ai::agent::MessageId::new("__summarization_inline_view__".to_string());
if let Some(summarization_view) = props.summarization_views.get(&key) {
output_items.add_child(ChildView::new(summarization_view).finish());
}
@@ -827,8 +825,7 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
if let Some(summarization_view) =
props.summarization_views.get(&output_message.id)
{
output_items
.add_child(ChildView::new(summarization_view).finish());
output_items.add_child(ChildView::new(summarization_view).finish());
} else if !are_all_text_sections_empty(&text.sections) {
let header_text = "Conversation summarized".to_string();
if let Some(element) = render_collapsible_block(
@@ -3178,7 +3175,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
flex.add_child(fork_button);
}
// Review changes button.
if props.has_accepted_edits && !props.shared_session_status.is_viewer() {
// Only show Review Changes button if we're in a git repository
@@ -3209,7 +3205,6 @@ fn render_response_footer(props: Props, app: &AppContext) -> Option<Box<dyn Elem
Some(flex.finish().with_content_item_spacing().finish())
}
pub fn action_icon<V: View>(
action_id: &AIAgentActionId,
action_model: &ModelHandle<BlocklistAIActionModel>,
+500 -114
View File
@@ -73,7 +73,7 @@ use itertools::Itertools;
use parking_lot::FairMutex;
use pending_response_streams::PendingResponseStreams;
use session_sharing_protocol::common::ParticipantId;
use std::collections::{HashMap, HashSet};
use std::collections::{HashMap, HashSet, VecDeque};
use std::sync::Arc;
use std::time::Duration;
use warp_multi_agent_api::{message, Task, ToolType};
@@ -168,6 +168,60 @@ pub enum BlocklistAIControllerEvent {
FreeTierLimitCheckTriggered,
}
/// Tracks recent failed action signatures for loop detection.
/// When the same tool+input pattern fails repeatedly, we inject
/// corrective instructions to break the cycle.
#[derive(Debug, Clone)]
struct LoopDetectionEntry {
/// Discriminant of the action result type (e.g. RequestCommandOutput, ApplyFileDiffs)
tool_discriminant: std::mem::Discriminant<AIAgentActionResultType>,
/// Hash of the action's identifying input (command string, file paths, etc.)
input_hash: u64,
/// Human-readable description of what failed
description: String,
}
#[derive(Debug, Default, Clone)]
struct LoopDetectionState {
recent_failures: VecDeque<LoopDetectionEntry>,
}
const LOOP_DETECTION_WINDOW: usize = 10;
const LOOP_DETECTION_THRESHOLD: usize = 3;
impl LoopDetectionState {
fn record_failure(&mut self, entry: LoopDetectionEntry) {
self.recent_failures.push_back(entry);
if self.recent_failures.len() > LOOP_DETECTION_WINDOW {
self.recent_failures.pop_front();
}
}
fn detect_loop(&self) -> Option<&LoopDetectionEntry> {
use std::collections::HashMap as CountMap;
let mut counts: CountMap<
(std::mem::Discriminant<AIAgentActionResultType>, u64),
(usize, usize),
> = CountMap::new();
for (idx, entry) in self.recent_failures.iter().enumerate() {
let key = (entry.tool_discriminant, entry.input_hash);
let counter = counts.entry(key).or_insert((0, 0));
counter.0 += 1;
counter.1 = idx; // Track most recent occurrence
}
for ((_disc, _hash), (count, latest_idx)) in &counts {
if *count >= LOOP_DETECTION_THRESHOLD {
return self.recent_failures.get(*latest_idx);
}
}
None
}
fn clear(&mut self) {
self.recent_failures.clear();
}
}
#[derive(Debug)]
pub struct RequestInput {
pub conversation_id: AIConversationId,
@@ -317,6 +371,9 @@ pub struct BlocklistAIController {
pending_auto_resume_handles: HashMap<AIConversationId, SpawnedFutureHandle>,
/// Passive conversations explicitly requested to follow up after actions complete.
pending_passive_follow_ups: HashSet<AIConversationId>,
/// Per-conversation loop detection state for preventing recursive tool failures.
loop_detection: HashMap<AIConversationId, LoopDetectionState>,
/// Passive suggestion results that should be included with the next request
/// for a given conversation (e.g. accepted/iterated code diffs that weren't
/// auto-resumed).
@@ -555,6 +612,7 @@ impl BlocklistAIController {
pending_auto_resume_handles: HashMap::new(),
pending_passive_follow_ups: HashSet::new(),
pending_passive_suggestion_results: HashMap::new(),
loop_detection: HashMap::new(),
}
}
@@ -1026,6 +1084,9 @@ impl BlocklistAIController {
is_queued_prompt: bool,
ctx: &mut ModelContext<Self>,
) {
// User sending a new query resets loop detection — fresh context.
self.loop_detection.remove(&conversation_id);
let is_viewer = self
.terminal_model
.lock()
@@ -1418,6 +1479,9 @@ impl BlocklistAIController {
return;
}
// Loop detection: record failures and check for repeated patterns
let loop_warning = self.check_and_record_loop_detection(conversation_id, &finished_results);
// Check whether any result will trigger a server-side subagent (e.g. CLI
// subagent for LRC), or if one is already active. If so, we must not
// piggyback orchestration events because the subagent cannot interpret
@@ -1447,6 +1511,34 @@ impl BlocklistAIController {
ctx,
);
// If a loop was detected, inject a corrective instruction alongside
// the action results so the model avoids repeating the same failure.
if let Some(warning_msg) = loop_warning {
log::warn!(
"[loop-detection] Injecting corrective instruction for conversation {:?}: {}",
conversation_id,
warning_msg
);
if let Some(conversation) =
BlocklistAIHistoryModel::as_ref(ctx).conversation(&conversation_id)
{
let root_task_id = conversation.get_root_task_id().clone();
request_input
.input_messages
.entry(root_task_id)
.or_default()
.push(AIAgentInput::UserQuery {
query: warning_msg,
context: Arc::from([]),
static_query_type: None,
referenced_attachments: HashMap::new(),
user_query_mode: UserQueryMode::Normal,
running_command: None,
intended_agent: None,
});
}
}
// Include any pending orchestration events in this follow-up rather
// than waiting for a separate idle injection turn. Skip when a server
// subagent is or will be active — events will be delivered via the idle
@@ -1495,6 +1587,67 @@ impl BlocklistAIController {
self.pending_passive_follow_ups.remove(&conversation_id);
}
/// Records failed actions into the loop detection state and returns a
/// corrective instruction if a loop is detected.
fn check_and_record_loop_detection(
&mut self,
conversation_id: AIConversationId,
results: &[AIAgentActionResult],
) -> Option<String> {
use std::hash::{Hash, Hasher};
let state = self.loop_detection.entry(conversation_id).or_default();
let mut has_success = false;
for result in results {
if result.result.is_failed() {
let discriminant = std::mem::discriminant(&result.result);
// Use a stable description that includes the tool type and the *input*
// (command, file paths, etc.) but NOT the variable output, so the same
// failing command with different output is still recognized as a loop.
let description = result.result.loop_description();
let mut hasher = std::collections::hash_map::DefaultHasher::new();
discriminant.hash(&mut hasher);
description.hash(&mut hasher);
let input_hash = hasher.finish();
state.record_failure(LoopDetectionEntry {
tool_discriminant: discriminant,
input_hash,
description: description.clone(),
});
} else if result.result.is_successful() {
has_success = true;
}
}
// If we had at least one success in this batch, clear loop state —
// the agent is making progress.
if has_success {
state.clear();
return None;
}
// Check for loops
if let Some(looping_entry) = state.detect_loop() {
let warning = format!(
"[SYSTEM] Loop detected: the same action has failed {} or more times consecutively. \
Do NOT repeat this action or any similar approach.\n\n\
Failing action: {}\n\n\
Take a completely different approach to accomplish the goal. \
If you cannot find an alternative, explain to the user what is failing and why.",
LOOP_DETECTION_THRESHOLD,
looping_entry.description
);
// Clear the state so we don't keep injecting on every subsequent turn
state.clear();
Some(warning)
} else {
None
}
}
/// Handles the EventsReady signal. Checks readiness, drains
/// pending events from the service, and injects them into the conversation.
fn handle_pending_events_ready(
@@ -1566,9 +1719,8 @@ impl BlocklistAIController {
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
let events = OrchestrationEventService::handle(ctx).update(ctx, |svc, _ctx| {
svc.drain_subagent_events(&conversation_id)
});
let events = OrchestrationEventService::handle(ctx)
.update(ctx, |svc, _ctx| svc.drain_subagent_events(&conversation_id));
for event in events {
match event.detail {
@@ -1577,17 +1729,16 @@ impl BlocklistAIController {
question_text,
options,
} => {
let answer = options.first().cloned().unwrap_or_else(|| {
format!("Proceed with: {}", question_text)
});
let answer = options
.first()
.cloned()
.unwrap_or_else(|| format!("Proceed with: {}", question_text));
OrchestrationEventService::handle(ctx).update(ctx, |svc, ctx| {
svc.route_answer_to_subagent(source_conversation_id, answer, ctx);
});
}
PendingEventDetail::SubagentAnswer {
answer_text,
} => {
PendingEventDetail::SubagentAnswer { answer_text } => {
self.complete_ask_user_question_with_answer(answer_text, ctx);
}
PendingEventDetail::SubagentCompletionSummary => {}
@@ -1604,7 +1755,10 @@ impl BlocklistAIController {
) {
use ai::agent::action_result::AskUserQuestionAnswerItem;
let executor = self.action_model.as_ref(ctx).ask_user_question_executor(ctx);
let executor = self
.action_model
.as_ref(ctx)
.ask_user_question_executor(ctx);
let answer_item = AskUserQuestionAnswerItem::Answered {
question_id: String::new(),
selected_options: vec![answer_text.clone()],
@@ -1963,7 +2117,8 @@ impl BlocklistAIController {
parent_agent_id,
agent_name,
bedrock_history,
bedrock_compact_summary,
bedrock_tool_result_archive,
bedrock_progressive_summary,
) = {
let Some(conversation) = history_model
.as_ref(ctx)
@@ -1987,7 +2142,8 @@ impl BlocklistAIController {
conversation.parent_agent_id().map(str::to_string),
conversation.agent_name().map(str::to_string),
conversation.bedrock_message_history().to_vec(),
conversation.compact_summary().map(str::to_string),
conversation.tool_result_archive().to_vec(),
conversation.progressive_summary().map(str::to_string),
)
};
@@ -2055,11 +2211,8 @@ impl BlocklistAIController {
request_params.parent_agent_id = parent_agent_id;
request_params.agent_name = agent_name;
request_params.bedrock_message_history = bedrock_history;
request_params.bedrock_compact_summary = bedrock_compact_summary;
request_params.is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
request_params.bedrock_tool_result_archive = bedrock_tool_result_archive;
request_params.bedrock_progressive_summary = bedrock_progressive_summary;
let server_conversation_token_for_identifiers =
conversation_data.server_conversation_token.clone();
@@ -2084,13 +2237,9 @@ impl BlocklistAIController {
let input_contains_user_query = request_input
.all_inputs()
.any(|input| input.is_user_query());
let input_is_summarization = request_input
.all_inputs()
.any(|input| matches!(input, AIAgentInput::SummarizeConversation { .. }));
ctx.subscribe_to_model(&response_stream, move |me, event, ctx| {
me.handle_response_stream_event(
input_contains_user_query,
input_is_summarization,
event,
&response_stream_clone,
ctx,
@@ -2280,7 +2429,6 @@ impl BlocklistAIController {
fn handle_response_stream_event(
&mut self,
did_input_contain_user_query: bool,
is_summarization_request: bool,
event: &ResponseStreamEvent,
response_stream: &ModelHandle<ResponseStream>,
ctx: &mut ModelContext<Self>,
@@ -2384,69 +2532,32 @@ impl BlocklistAIController {
Some(sent.clone())
}
});
if let Some(new_history) = new_history {
if let Some(mut new_history) = new_history {
let history_model = BlocklistAIHistoryModel::handle(ctx);
history_model.update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
// If this was a summarization request, compact the
// history to just the summary instead of keeping
// the full message list. This is what actually
// frees up context window space.
let is_summarization = is_summarization_request;
if is_summarization {
// Extract the assistant's summary from the last
// message in the history (the response).
let summary_text = new_history
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
let skip = conversation.messages_summarized_up_to();
if skip > 0 && skip <= new_history.len() {
let drained: Vec<_> = new_history
.iter()
.rev()
.find_map(|msg| {
use crate::ai::bedrock::convert::{
MessageContent, MessageRole,
};
if msg.role == MessageRole::Assistant {
if let MessageContent::Text(text) =
&msg.content
{
Some(text.clone())
} else {
None
}
} else {
None
}
});
if let Some(summary) = summary_text {
log::info!(
"[bedrock] Compacted conversation history from {} messages to system-level summary",
new_history.len()
);
conversation.set_compact_summary(Some(summary.clone()));
*conversation.bedrock_message_history_mut() = Vec::new();
let estimated_tokens = (summary.len() / 4) as u32;
let max_context = crate::ai::bedrock::response_translator::context_window_for_model("claude-opus-4-6-20250514[1m]");
let new_usage = estimated_tokens as f32 / max_context as f32;
conversation.set_context_window_usage(new_usage);
conversation.set_current_context_tokens(estimated_tokens);
log::info!(
"[bedrock] Post-compact context estimate: ~{} tokens ({:.1}% of context window)",
estimated_tokens,
new_usage * 100.0
);
} else {
*conversation.bedrock_message_history_mut() =
new_history;
}
.take(skip)
.cloned()
.collect();
conversation.archive_tool_results(drained);
let reconciled = new_history.split_off(skip);
conversation.reset_messages_summarized_up_to();
*conversation.bedrock_message_history_mut() =
reconciled;
} else {
*conversation.bedrock_message_history_mut() =
new_history;
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
}
log::info!(
"[bedrock] Updated conversation bedrock history: {} messages",
conversation.bedrock_message_history().len()
);
}
});
}
@@ -2488,22 +2599,24 @@ impl BlocklistAIController {
});
}
let mut renderable_error: RenderableAIError =
if let AIApiError::Stream { stream_type, source } = e.as_ref() {
if *stream_type == "bedrock_converse"
&& is_bedrock_credentials_error(&source.to_string())
{
let model_name =
response_stream.as_ref(ctx).model_id().to_string();
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name,
}
} else {
e.as_ref().into()
let mut renderable_error: RenderableAIError = if let AIApiError::Stream {
stream_type,
source,
} = e.as_ref()
{
if *stream_type == "bedrock_converse"
&& is_bedrock_credentials_error(&source.to_string())
{
let model_name = response_stream.as_ref(ctx).model_id().to_string();
RenderableAIError::AwsBedrockCredentialsExpiredOrInvalid {
model_name,
}
} else {
e.as_ref().into()
};
}
} else {
e.as_ref().into()
};
if let RenderableAIError::Other {
will_attempt_resume,
waiting_for_network,
@@ -2555,7 +2668,9 @@ impl BlocklistAIController {
log::warn!("Conversation not found.");
return;
};
let new_exchange_ids: Vec<_> = conversation.new_exchange_ids_for_response(&stream_id).collect();
let new_exchange_ids: Vec<_> = conversation
.new_exchange_ids_for_response(&stream_id)
.collect();
log::info!(
"[bedrock-debug] AfterStreamFinished: stream_id={:?}, conversation_id={:?}, new_exchange_ids count={}",
stream_id, conversation_id, new_exchange_ids.len()
@@ -2963,42 +3078,311 @@ impl BlocklistAIController {
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
}
// Auto-compact: trigger summarization when context window usage >= 85%.
let should_auto_compact = {
// Progressive summarization: when context window usage >= 85% and we have
// more than 100 messages, summarize the oldest messages while keeping the
// most recent 100 verbatim. This runs as a background Bedrock call — no UI,
// no exchange created, no tool execution shown.
let should_progressive_summarize = {
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
history_model
.conversation(&conversation_id)
.is_some_and(|conversation| {
let is_summarization_request = conversation
.latest_exchange()
.is_some_and(|exchange| {
let is_summarization_request =
conversation.latest_exchange().is_some_and(|exchange| {
exchange
.input
.iter()
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }))
});
conversation.context_window_usage() >= 0.85
&& !conversation.has_pending_auto_compact()
&& !conversation.has_pending_progressive_summary()
&& !is_summarization_request
&& conversation.bedrock_message_history().len() > 100
})
};
if should_auto_compact {
log::info!(
"[auto-compact] Context window usage >= 85% for conversation {:?}, triggering summarization",
conversation_id
);
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
conversation.set_has_pending_auto_compact(true);
}
});
self.send_slash_command_request(
SlashCommandRequest::Summarize { prompt: None },
ctx,
);
if should_progressive_summarize {
self.trigger_progressive_summarization(conversation_id, ctx);
}
}
fn trigger_progressive_summarization(
&mut self,
conversation_id: AIConversationId,
ctx: &mut ModelContext<Self>,
) {
use crate::ai::bedrock::client::{BedrockClient, BedrockClientConfig};
use crate::ai::bedrock::convert::{ConversationMessage, MessageContent, MessageRole};
use crate::ai::bedrock::response_translator::{
context_window_for_model, estimate_cost_cents,
};
use crate::settings::ai::AISettings;
use settings::Setting;
let settings = AISettings::as_ref(ctx);
if !*settings.bedrock_enabled.value() {
return;
}
let config = BedrockClientConfig {
auth_method: *settings.bedrock_auth_method.value(),
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
.with_external_fallbacks();
let cross_region = config.cross_region_inference;
// Use Sonnet for summarization — cheaper and fast enough for this task
let model_id = "us.anthropic.claude-sonnet-4-6-20250514-v1:0".to_string();
let history_model = BlocklistAIHistoryModel::handle(ctx);
// Extract the messages to summarize and set the guard flag
let (messages_to_summarize, existing_summary, messages_count) = {
let history = history_model.as_ref(ctx);
let Some(conversation) = history.conversation(&conversation_id) else {
return;
};
let history_len = conversation.bedrock_message_history().len();
let split_point = history_len.saturating_sub(100);
if split_point == 0 {
return;
}
let msgs: Vec<ConversationMessage> =
conversation.bedrock_message_history()[..split_point].to_vec();
let existing = conversation.progressive_summary().map(str::to_string);
(msgs, existing, split_point)
};
history_model.update(ctx, |history_model, _| {
if let Some(conversation) = history_model.conversation_mut(&conversation_id) {
conversation.set_has_pending_progressive_summary(true);
}
});
log::info!(
"[progressive-summary] Triggering for conversation {:?}: summarizing {} messages, keeping last 100",
conversation_id,
messages_count
);
// Build the summarization input
let mut summarize_content = String::new();
if let Some(ref prior) = existing_summary {
summarize_content.push_str("<prior-summary>\n");
summarize_content.push_str(prior);
summarize_content.push_str("\n</prior-summary>\n\n");
}
summarize_content.push_str("<messages-to-summarize>\n");
fn safe_truncate(s: &str, max_chars: usize) -> String {
if s.len() <= max_chars {
s.to_string()
} else {
let trunc = s.chars().take(max_chars).collect::<String>();
format!("{trunc}... [truncated, {len} total chars]", len = s.len())
}
}
for msg in &messages_to_summarize {
let role_str = match msg.role {
MessageRole::User => "User",
MessageRole::Assistant => "Assistant",
};
let content_str = match &msg.content {
MessageContent::Text(t) => t.clone(),
MessageContent::ToolUse { name, input, .. } => {
format!("[Tool Call: {}] {}", name, input)
}
MessageContent::ToolResult { content, .. } => safe_truncate(content, 2000),
MessageContent::MultiPart(parts) => {
use crate::ai::bedrock::convert::ContentPart;
parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => t.clone(),
ContentPart::ToolUse { name, input, .. } => {
format!("[Tool: {}] {}", name, input)
}
ContentPart::ToolResult { content, .. } => safe_truncate(content, 2000),
})
.collect::<Vec<_>>()
.join("\n")
}
};
summarize_content.push_str(&format!("[{}]: {}\n", role_str, content_str));
}
summarize_content.push_str("</messages-to-summarize>");
let summarize_prompt = "Summarize the following conversation history. Preserve:\n\
- All decisions made and their rationale\n\
- All file paths modified and what was changed\n\
- All tool calls with their significant results (commands run, files read, errors encountered)\n\
- Current task state and any pending work\n\
- Technical details, code patterns, and architecture discussed\n\n\
Be comprehensive. This summary will be the only record of these exchanges.";
let summarize_messages = vec![ConversationMessage {
role: MessageRole::User,
content: MessageContent::Text(format!("{}\n\n{}", summarize_prompt, summarize_content)),
}];
// Spawn the background Bedrock call
let model_id_clone = model_id.clone();
ctx.spawn(
async move {
let client = BedrockClient::from_config(config).await?;
client
.converse_collect(
&model_id_clone,
summarize_messages,
None,
16000,
cross_region,
)
.await
},
move |me, result, ctx| {
let history_model = BlocklistAIHistoryModel::handle(ctx);
match result {
Ok((summary_text, input_tokens, output_tokens)) => {
log::info!(
"[progressive-summary] Completed for {:?}: {} chars, input={} output={} tokens",
conversation_id,
summary_text.len(),
input_tokens,
output_tokens,
);
let cost_cents = estimate_cost_cents(
input_tokens,
output_tokens,
0,
0,
&model_id,
);
// Use the conversation's active model for context window sizing,
// not the summarizer model.
let active_model_id = crate::ai::llms::LLMPreferences::as_ref(ctx)
.get_active_base_model(ctx, Some(me.terminal_view_id))
.id
.to_string();
history_model.update(ctx, |history_model, _| {
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
// Drain the summarized messages from history
let drain_count =
messages_count.min(conversation.bedrock_message_history().len());
let drained: Vec<_> = conversation
.bedrock_message_history()
.iter()
.take(drain_count)
.cloned()
.collect();
conversation.archive_tool_results(drained);
conversation
.bedrock_message_history_mut()
.drain(0..drain_count);
conversation
.set_progressive_summary(Some(summary_text.clone()), drain_count);
conversation.set_has_pending_progressive_summary(false);
// Estimate new context window usage
let summary_tokens = (summary_text.len() / 4) as u32;
let remaining_msgs_tokens: u32 = conversation
.bedrock_message_history()
.iter()
.map(|m| match &m.content {
MessageContent::Text(t) => (t.len() / 4) as u32,
MessageContent::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32 + 20
}
MessageContent::ToolResult { content, .. } => {
(content.len() / 4) as u32
}
MessageContent::MultiPart(parts) => {
use crate::ai::bedrock::convert::ContentPart;
parts
.iter()
.map(|p| match p {
ContentPart::Text(t) => (t.len() / 4) as u32,
ContentPart::ToolUse { input, .. } => {
(input.to_string().len() / 4) as u32
}
ContentPart::ToolResult { content, .. } => {
(content.len() / 4) as u32
}
})
.sum()
}
})
.sum();
let max_ctx = context_window_for_model(&active_model_id);
let new_usage =
(summary_tokens + remaining_msgs_tokens) as f32 / max_ctx as f32;
conversation.set_context_window_usage(new_usage);
conversation
.set_current_context_tokens(summary_tokens + remaining_msgs_tokens);
log::info!(
"[progressive-summary] Post-summary: ~{} tokens ({:.1}% of {} context), {} messages retained",
summary_tokens + remaining_msgs_tokens,
new_usage * 100.0,
active_model_id,
conversation.bedrock_message_history().len()
);
}
});
// Update cost tracking
history_model.update(ctx, |history_model, _| {
use warp_multi_agent_api::response_event::stream_finished;
let token_usage = vec![stream_finished::TokenUsage {
model_id: "bedrock".to_string(),
total_input: input_tokens,
output: output_tokens,
input_cache_read: 0,
input_cache_write: 0,
cost_in_cents: cost_cents,
}];
history_model.update_conversation_cost_and_usage_for_request(
conversation_id,
None,
token_usage,
None,
false,
);
});
}
Err(e) => {
log::error!(
"[progressive-summary] Failed for {:?}: {:?}",
conversation_id,
e
);
history_model.update(ctx, |history_model, _| {
if let Some(conversation) =
history_model.conversation_mut(&conversation_id)
{
conversation.set_has_pending_progressive_summary(false);
}
});
}
}
let _ = me;
},
);
}
}
impl Entity for BlocklistAIController {
@@ -3028,7 +3412,9 @@ fn is_bedrock_credentials_error(msg: &str) -> bool {
|| (lower.contains("sso/cache") && lower.contains("notfound"))
|| (lower.contains("sso/cache") && lower.contains("no such file"))
|| (lower.contains("accessdenied")
&& (lower.contains("token") || lower.contains("credential") || lower.contains("security")))
&& (lower.contains("token")
|| lower.contains("credential")
|| lower.contains("security")))
}
#[allow(clippy::too_many_arguments)]
@@ -92,14 +92,17 @@ impl ResponseStream {
return None;
}
let auth_method = *settings.bedrock_auth_method.value();
Some(BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}.with_external_fallbacks())
Some(
BedrockClientConfig {
auth_method,
profile: settings.bedrock_profile.value().clone(),
region: settings.bedrock_region.value().clone(),
access_key_id: settings.bedrock_access_key_id.value().clone(),
secret_access_key: settings.bedrock_secret_access_key.value().clone(),
cross_region_inference: *settings.bedrock_cross_region_inference.value(),
}
.with_external_fallbacks(),
)
}
pub fn new(
@@ -190,14 +193,15 @@ impl ResponseStream {
self.current_request_id = Some(request_id);
let params = self.params.clone();
let bedrock_config = Self::bedrock_config_if_applicable(params.model.as_str(), ctx);
let _ = ctx.spawn(
async move {
generate_multi_agent_output(bedrock_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
let _ =
ctx.spawn(
async move {
generate_multi_agent_output(bedrock_config, params, cancellation_rx).await
},
move |me, stream, ctx| {
me.handle_response_stream_result(request_id, stream, ctx);
},
);
}
/// Cancels the stream. The conversation_id is preserved in the emitted event for async handling.
@@ -11,7 +11,6 @@ use crate::{
},
blocklist::agent_view::AgentViewEntryOrigin,
},
search::slash_command_menu::static_commands::commands,
terminal::input::slash_commands::SlashCommandTrigger,
BlocklistAIHistoryModel,
};
@@ -33,9 +32,6 @@ pub enum SlashCommandRequest {
repos: Vec<String>,
use_current_dir: bool,
},
Summarize {
prompt: Option<String>,
},
FetchReviewComments {
repo_path: String,
},
@@ -55,13 +51,6 @@ impl SlashCommandRequest {
return Some(Self::InitProjectRules);
}
// Check if query starts with /compact and route to summarize conversation
if let Some(prompt) = query.strip_prefix(commands::COMPACT.name) {
return Some(Self::Summarize {
prompt: prompt.strip_prefix(' ').map(String::from),
});
}
None
}
@@ -85,7 +74,6 @@ impl SlashCommandRequest {
ctx,
);
let entrypoint = self.entrypoint();
let is_summarize = matches!(self, Self::Summarize { .. });
let inputs = self.input(context, controller.context_model.as_ref(ctx), ctx);
if inputs.is_empty() {
return;
@@ -155,14 +143,12 @@ impl SlashCommandRequest {
});
}
// Emit SentRequest event to trigger buffer clearing
if is_summarize {
ctx.emit(BlocklistAIControllerEvent::SentRequest {
contains_user_query: true,
is_queued_prompt,
model_id,
stream_id,
});
}
ctx.emit(BlocklistAIControllerEvent::SentRequest {
contains_user_query: true,
is_queued_prompt,
model_id,
stream_id,
});
}
Err(e) => log::error!("Failed to send agent slash command request: {e:?}"),
}
@@ -174,8 +160,7 @@ impl SlashCommandRequest {
app: &AppContext,
) -> Option<AIConversationId> {
match self {
Self::Summarize { .. }
| Self::CreateEnvironment { .. }
Self::CreateEnvironment { .. }
| Self::InvokeSkill { .. }
| Self::FetchReviewComments { .. } => controller
.context_model
@@ -226,9 +211,6 @@ impl SlashCommandRequest {
repo_paths: repos,
}]
}
SlashCommandRequest::Summarize { prompt, .. } => {
vec![AIAgentInput::SummarizeConversation { prompt }]
}
SlashCommandRequest::FetchReviewComments { repo_path } => {
vec![AIAgentInput::FetchReviewComments { repo_path, context }]
}
@@ -263,7 +245,6 @@ impl SlashCommandRequest {
SlashCommandRequest::InitProjectRules => EntrypointType::InitProjectRules,
SlashCommandRequest::CreateNewProject { .. }
| SlashCommandRequest::CreateEnvironment { .. }
| SlashCommandRequest::Summarize { .. }
| SlashCommandRequest::FetchReviewComments { .. }
| SlashCommandRequest::InvokeSkill { .. } => EntrypointType::UserInitiated,
}
+4
View File
@@ -1095,6 +1095,8 @@ impl BlocklistAIHistoryModel {
// The event cursor belongs to the source conversation's run; the
// forked conversation will establish its own cursor.
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let forked_conversation_id = AIConversationId::new();
if let Err(e) = sqlite_sender.send(ModelEvent::UpdateMultiAgentConversation {
@@ -1250,6 +1252,8 @@ impl BlocklistAIHistoryModel {
// The event cursor belongs to the source conversation's run; the
// forked conversation will establish its own cursor.
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let forked_conversation_id = AIConversationId::new();
@@ -328,6 +328,10 @@ fn create_server_metadata(
credits_spent_for_last_block: None,
token_usage: vec![],
tool_usage_metadata: Default::default(),
total_cache_read_tokens: 0,
total_cache_write_tokens: 0,
total_cache_miss_tokens: 0,
total_cost_cents: 0.0,
};
ServerAIConversationMetadata {
@@ -1133,6 +1137,8 @@ fn test_find_by_token_after_insert_forked_conversation_from_tasks() {
run_id: None,
autoexecute_override: None,
last_event_sequence: None,
progressive_summary: None,
messages_summarized_up_to: 0,
};
let tasks = vec![warp_multi_agent_api::Task {
id: "root-task".to_string(),
+1 -1
View File
@@ -11,7 +11,7 @@ pub(crate) mod requested_command_attribution;
pub(crate) mod requested_script;
pub(super) mod search_codebase;
pub(crate) mod search_results_common;
pub(super) mod summarization;
pub(crate) mod suggested_unit_tests;
pub(super) mod summarization;
pub(super) mod web_fetch;
pub(super) mod web_search;
@@ -1,8 +1,10 @@
use galaxy_core::ui::appearance::Appearance;
use galaxyui::elements::shimmering_text::{ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle};
use galaxyui::elements::shimmering_text::{
ShimmerConfig, ShimmeringTextElement, ShimmeringTextStateHandle,
};
use galaxyui::elements::{
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex,
MainAxisAlignment, ParentElement, Radius, Shrinkable, Text,
ConstrainedBox, Container, CornerRadius, CrossAxisAlignment, Element, Flex, MainAxisAlignment,
ParentElement, Radius, Shrinkable, Text,
};
use galaxyui::r#async::{SpawnedFutureHandle, Timer};
use galaxyui::{AppContext, Entity, SingletonEntity, View, ViewContext};
@@ -73,20 +75,14 @@ impl SummarizationView {
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Clock loader icon (magenta, matches InProgress convention)
let icon_element = galaxyui::elements::Icon::new(
Icon::ClockLoader.into(),
theme.ansi_fg_magenta(),
)
.finish();
let icon_element =
galaxyui::elements::Icon::new(Icon::ClockLoader.into(), theme.ansi_fg_magenta())
.finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
header_row.add_child(Container::new(icon_box).with_margin_right(8.).finish());
// Shimmering "Summarizing conversation..." text
let base_color = theme.disabled_text_color(header_background).into_solid();
@@ -133,20 +129,13 @@ impl SummarizationView {
.with_cross_axis_alignment(CrossAxisAlignment::Center);
// Checkmark-style icon for completed
let icon_element = galaxyui::elements::Icon::new(
Icon::Check.into(),
theme.ansi_fg_green(),
)
.finish();
let icon_element =
galaxyui::elements::Icon::new(Icon::Check.into(), theme.ansi_fg_green()).finish();
let icon_box = ConstrainedBox::new(icon_element)
.with_width(icon_size(app))
.with_height(icon_size(app))
.finish();
header_row.add_child(
Container::new(icon_box)
.with_margin_right(8.)
.finish(),
);
header_row.add_child(Container::new(icon_box).with_margin_right(8.).finish());
let elapsed = self.start_time.elapsed();
let elapsed_text = format_elapsed(elapsed);
@@ -145,6 +145,8 @@ fn ai_conversation_new_restored_preserves_last_event_sequence() {
run_id: None,
autoexecute_override: None,
last_event_sequence: Some(42),
progressive_summary: None,
messages_summarized_up_to: 0,
};
let conversation =
AIConversation::new_restored(AIConversationId::new(), vec![task], Some(data))
+11 -11
View File
@@ -578,9 +578,15 @@ impl OrchestrationEventService {
if let Some(parent_id) = parent_id {
// Merge child's token usage and costs into parent
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history_model, _ctx| {
let child_usage: Option<(HashMap<String, api::response_event::stream_finished::TokenUsage>, crate::ai::agent::RequestCost)> = history_model
.conversation(&conversation_id)
.map(|c| (c.total_token_usage_by_model().clone(), c.total_request_cost()));
let child_usage: Option<(
HashMap<String, api::response_event::stream_finished::TokenUsage>,
crate::ai::agent::RequestCost,
)> = history_model.conversation(&conversation_id).map(|c| {
(
c.total_token_usage_by_model().clone(),
c.total_request_cost(),
)
});
if let Some((token_usage, request_cost)) = child_usage {
if let Some(parent) = history_model.conversation_mut(&parent_id) {
parent.merge_child_usage_raw(&token_usage, request_cost);
@@ -589,11 +595,7 @@ impl OrchestrationEventService {
});
if summary.is_some() {
self.route_subagent_completion_summary(
conversation_id,
parent_id,
ctx,
);
self.route_subagent_completion_summary(conversation_id, parent_id, ctx);
}
}
}
@@ -1271,9 +1273,7 @@ impl OrchestrationEventService {
event_id: Uuid::new_v4().to_string(),
source_agent_id: "parent".to_string(),
attempt_count: 0,
detail: PendingEventDetail::SubagentAnswer {
answer_text,
},
detail: PendingEventDetail::SubagentAnswer { answer_text },
};
self.pending_events
@@ -317,11 +317,7 @@ impl PassiveSuggestionsModel {
.lock()
.block_list()
.block_at(block_completed.index)
.and_then(|block| {
block
.agent_view_visibility()
.agent_view_conversation_id()
});
.and_then(|block| block.agent_view_visibility().agent_view_conversation_id());
let prompt = format!(
"The command `{}` failed. Diagnose the error and suggest a fix.",
@@ -436,6 +432,7 @@ impl PassiveSuggestionsModel {
);
}
}
}
impl Entity for PassiveSuggestionsModel {
@@ -2,9 +2,7 @@ use crate::ai::bedrock::convert::{ContentPart, ConversationMessage, MessageConte
use crate::appearance::Appearance;
use crate::ui_components::blended_colors;
use galaxyui::{
elements::{
Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text,
},
elements::{Container, CornerRadius, CrossAxisAlignment, Flex, ParentElement, Radius, Text},
AppContext, Element, Entity, SingletonEntity, TypedActionView, View, ViewContext,
};
@@ -60,13 +58,9 @@ impl View for ContextWindowView {
format_tokens(estimated_tokens as u32),
);
column = column.with_child(
Text::new(
header_text,
appearance.ui_font_family(),
font_size + 1.0,
)
.with_color(label_color)
.finish(),
Text::new(header_text, appearance.ui_font_family(), font_size + 1.0)
.with_color(label_color)
.finish(),
);
// Messages — show full content, no truncation.
@@ -89,7 +83,11 @@ impl View for ContextWindowView {
// Full content
let content_text = match &msg.content {
MessageContent::Text(t) => t.clone(),
MessageContent::ToolUse { name, tool_use_id, input } => {
MessageContent::ToolUse {
name,
tool_use_id,
input,
} => {
format!(
"[ToolUse] name={}, id={}\ninput={}",
name, tool_use_id, input
@@ -112,13 +110,21 @@ impl View for ContextWindowView {
ContentPart::Text(t) => {
out.push_str(&format!("[Part {} Text] {}\n", pi, t));
}
ContentPart::ToolUse { name, tool_use_id, input } => {
ContentPart::ToolUse {
name,
tool_use_id,
input,
} => {
out.push_str(&format!(
"[Part {} ToolUse] name={}, id={}, input={}\n",
pi, name, tool_use_id, input
));
}
ContentPart::ToolResult { tool_use_id, content, is_error } => {
ContentPart::ToolResult {
tool_use_id,
content,
is_error,
} => {
out.push_str(&format!(
"[Part {} ToolResult] id={}, error={}\n{}\n",
pi, tool_use_id, is_error, content
@@ -261,8 +261,8 @@ impl ConversationUsageView {
}
// Cache usage (cumulative session totals)
let total_cache = self.usage_info.total_cache_read_tokens
+ self.usage_info.total_cache_write_tokens;
let total_cache =
self.usage_info.total_cache_read_tokens + self.usage_info.total_cache_write_tokens;
if total_cache > 0 {
if self.usage_info.total_cache_read_tokens > 0 {
labels.push(render_label_text("Cache read", appearance));
@@ -290,10 +290,7 @@ impl ConversationUsageView {
/ total_cache_ops as f32)
* 100.0;
labels.push(render_label_text("Cache hit rate", appearance));
values.push(render_value_text(
format!("{:.1}%", hit_rate),
appearance,
));
values.push(render_value_text(format!("{:.1}%", hit_rate), appearance));
}
}