Improve agent retries and tool progress
This commit is contained in:
@@ -154,7 +154,7 @@ Key invariants:
|
|||||||
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
|
- Before progressive summarization drains messages, `ConversationMessage::archive_tool_results()` moves tool-use/result pairs into `tool_result_archive`
|
||||||
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
|
- Bedrock prompt caching uses three cache points: system prompt, second-to-last history message, and tool configuration
|
||||||
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
- `ensure_tool_results_paired()` enforces Bedrock's invariant that every `tool_use` has a matching `tool_result`
|
||||||
- Progressive summaries are prepended to provider requests as a user/assistant pair; background summarization remains independent of the active provider run
|
- Progressive summaries are prepended to provider requests as a user/assistant pair; direct-provider runs compact their live transcript at a checkpointed `ReadyToCallModel` boundary before another model call, while background summarization remains independent for session-owned runtimes
|
||||||
- Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
|
- Loop prevention in `controller.rs` detects repeated tool failures (3+ identical) and injects a corrective instruction
|
||||||
- Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
|
- Direct-provider long-running shell follow-ups retain CLI tasks as UI projections while command monitoring and completion stay owned by the same provider run
|
||||||
- A direct-provider command completion is only queued when the terminal reports it; the CLI task remains active until the provider run applies that completion at a safe boundary and deactivates it
|
- A direct-provider command completion is only queued when the terminal reports it; the CLI task remains active until the provider run applies that completion at a safe boundary and deactivates it
|
||||||
|
|||||||
@@ -2490,16 +2490,19 @@ impl AIConversation {
|
|||||||
|
|
||||||
// Update live context token count from this response's input tokens.
|
// Update live context token count from this response's input tokens.
|
||||||
// This represents the actual current context window size (not cumulative).
|
// This represents the actual current context window size (not cumulative).
|
||||||
let live_input = usage_metadata
|
let live_input = match usage_metadata.as_ref() {
|
||||||
.as_ref()
|
// When a failed follow-up has no usage of its own, the provider emits
|
||||||
.map(|metadata| metadata.total_input_tokens)
|
// zero here. Preserve the most recent live occupancy rather than
|
||||||
.filter(|tokens| *tokens > 0)
|
// falling back to the run's cumulative billing totals.
|
||||||
.unwrap_or_else(|| {
|
Some(metadata) => metadata.total_input_tokens,
|
||||||
token_usage
|
None if was_user_initiated_request => token_usage
|
||||||
.iter()
|
.iter()
|
||||||
.map(|u| u.total_input + u.input_cache_read + u.input_cache_write)
|
.map(|u| u.total_input + u.input_cache_read + u.input_cache_write)
|
||||||
.sum()
|
.sum(),
|
||||||
});
|
// Auxiliary requests such as progressive summarization count toward
|
||||||
|
// cumulative /usage totals but do not become the agent's live context.
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
if live_input > 0 {
|
if live_input > 0 {
|
||||||
self.current_context_tokens = live_input;
|
self.current_context_tokens = live_input;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ pub struct BlocklistAIStatusBar {
|
|||||||
/// The random loading message chosen for the current conversation run, stable across
|
/// The random loading message chosen for the current conversation run, stable across
|
||||||
/// re-renders and tool-call follow-up exchanges.
|
/// re-renders and tool-call follow-up exchanges.
|
||||||
warping_message: Option<&'static str>,
|
warping_message: Option<&'static str>,
|
||||||
warping_message_ticks: u8,
|
|
||||||
/// Handle for the periodic timer that updates the warping elapsed timer UI.
|
/// Handle for the periodic timer that updates the warping elapsed timer UI.
|
||||||
warping_timer_handle: Option<SpawnedFutureHandle>,
|
warping_timer_handle: Option<SpawnedFutureHandle>,
|
||||||
|
|
||||||
@@ -435,7 +434,6 @@ impl BlocklistAIStatusBar {
|
|||||||
last_read_refresh_handle: None,
|
last_read_refresh_handle: None,
|
||||||
warping_start_time: None,
|
warping_start_time: None,
|
||||||
warping_message: None,
|
warping_message: None,
|
||||||
warping_message_ticks: 0,
|
|
||||||
warping_timer_handle: None,
|
warping_timer_handle: None,
|
||||||
ambient_agent_view_model,
|
ambient_agent_view_model,
|
||||||
current_tip: None,
|
current_tip: None,
|
||||||
@@ -805,7 +803,6 @@ impl BlocklistAIStatusBar {
|
|||||||
self.warping_start_time.get_or_insert_with(Instant::now);
|
self.warping_start_time.get_or_insert_with(Instant::now);
|
||||||
self.warping_message
|
self.warping_message
|
||||||
.get_or_insert_with(random_load_output_message);
|
.get_or_insert_with(random_load_output_message);
|
||||||
self.warping_message_ticks = 0;
|
|
||||||
// Don't start a new timer if one is already running
|
// Don't start a new timer if one is already running
|
||||||
if self.warping_timer_handle.is_some() {
|
if self.warping_timer_handle.is_some() {
|
||||||
return;
|
return;
|
||||||
@@ -817,11 +814,6 @@ impl BlocklistAIStatusBar {
|
|||||||
|me, _, ctx| {
|
|me, _, ctx| {
|
||||||
me.warping_timer_handle = None;
|
me.warping_timer_handle = None;
|
||||||
if me.warping_start_time.is_some() {
|
if me.warping_start_time.is_some() {
|
||||||
me.warping_message_ticks = me.warping_message_ticks.saturating_add(1);
|
|
||||||
if me.warping_message_ticks >= 5 {
|
|
||||||
me.warping_message_ticks = 0;
|
|
||||||
me.warping_message = Some(random_load_output_message());
|
|
||||||
}
|
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
me.restart_warping_timer(ctx);
|
me.restart_warping_timer(ctx);
|
||||||
}
|
}
|
||||||
@@ -842,11 +834,6 @@ impl BlocklistAIStatusBar {
|
|||||||
|me, _, ctx| {
|
|me, _, ctx| {
|
||||||
me.warping_timer_handle = None;
|
me.warping_timer_handle = None;
|
||||||
if me.warping_start_time.is_some() {
|
if me.warping_start_time.is_some() {
|
||||||
me.warping_message_ticks = me.warping_message_ticks.saturating_add(1);
|
|
||||||
if me.warping_message_ticks >= 5 {
|
|
||||||
me.warping_message_ticks = 0;
|
|
||||||
me.warping_message = Some(random_load_output_message());
|
|
||||||
}
|
|
||||||
ctx.notify();
|
ctx.notify();
|
||||||
me.restart_warping_timer(ctx);
|
me.restart_warping_timer(ctx);
|
||||||
}
|
}
|
||||||
@@ -859,7 +846,6 @@ impl BlocklistAIStatusBar {
|
|||||||
fn stop_warping_timer(&mut self) {
|
fn stop_warping_timer(&mut self) {
|
||||||
self.warping_start_time = None;
|
self.warping_start_time = None;
|
||||||
self.warping_message = None;
|
self.warping_message = None;
|
||||||
self.warping_message_ticks = 0;
|
|
||||||
if let Some(handle) = self.warping_timer_handle.take() {
|
if let Some(handle) = self.warping_timer_handle.take() {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
}
|
}
|
||||||
@@ -1011,17 +997,13 @@ impl BlocklistAIStatusBar {
|
|||||||
let default_warping_text = fallback_warping_text
|
let default_warping_text = fallback_warping_text
|
||||||
.as_deref()
|
.as_deref()
|
||||||
.or(self.warping_message)
|
.or(self.warping_message)
|
||||||
.unwrap_or_else(|| random_load_output_message())
|
.unwrap_or("Exploring the Galaxy...")
|
||||||
.to_owned();
|
.to_owned();
|
||||||
let retry_status_text = self
|
let retry_status_text = self
|
||||||
.controller
|
.controller
|
||||||
.as_ref(app)
|
.as_ref(app)
|
||||||
.provider_retry_status(conversation.id())
|
.provider_retry_status(conversation.id())
|
||||||
.map(|status| status.label());
|
.map(|status| status.label());
|
||||||
let model_progress_text = self
|
|
||||||
.controller
|
|
||||||
.as_ref(app)
|
|
||||||
.provider_tool_call_progress_label(conversation.id());
|
|
||||||
let secondary_element = if fallback_warping_text.is_some() {
|
let secondary_element = if fallback_warping_text.is_some() {
|
||||||
Some(render_fallback_explanation(model.as_ref(), app))
|
Some(render_fallback_explanation(model.as_ref(), app))
|
||||||
} else {
|
} else {
|
||||||
@@ -1081,7 +1063,6 @@ impl BlocklistAIStatusBar {
|
|||||||
force_refresh_button,
|
force_refresh_button,
|
||||||
default_warping_text,
|
default_warping_text,
|
||||||
retry_status_text,
|
retry_status_text,
|
||||||
model_progress_text,
|
|
||||||
secondary_element,
|
secondary_element,
|
||||||
last_snapshot_at,
|
last_snapshot_at,
|
||||||
warping_start_time: self.warping_start_time,
|
warping_start_time: self.warping_start_time,
|
||||||
|
|||||||
@@ -1077,6 +1077,14 @@ impl View for AIBlock {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|model| model.as_ref(app).is_ambient_agent());
|
.is_some_and(|model| model.as_ref(app).is_ambient_agent());
|
||||||
|
|
||||||
|
let streaming_tool_calls = if self.model.is_latest_visible_exchange_in_root_task(app) {
|
||||||
|
self.controller
|
||||||
|
.as_ref(app)
|
||||||
|
.provider_tool_call_progress(self.client_ids.conversation_id)
|
||||||
|
} else {
|
||||||
|
Vec::new()
|
||||||
|
};
|
||||||
|
|
||||||
contents.add_child(output::render(
|
contents.add_child(output::render(
|
||||||
output::Props {
|
output::Props {
|
||||||
conversation_id: self.client_ids.conversation_id,
|
conversation_id: self.client_ids.conversation_id,
|
||||||
@@ -1150,6 +1158,7 @@ impl View for AIBlock {
|
|||||||
&& self.has_imported_comments_in_current_thread(app),
|
&& self.has_imported_comments_in_current_thread(app),
|
||||||
ask_user_question_view: self.ask_user_question_view.as_ref(),
|
ask_user_question_view: self.ask_user_question_view.as_ref(),
|
||||||
is_cloud_agent_pre_first_exchange,
|
is_cloud_agent_pre_first_exchange,
|
||||||
|
streaming_tool_calls: &streaming_tool_calls,
|
||||||
},
|
},
|
||||||
app,
|
app,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -219,7 +219,6 @@ pub struct WarpingProps<'a, V> {
|
|||||||
pub terminal_model: &'a TerminalModel,
|
pub terminal_model: &'a TerminalModel,
|
||||||
pub default_warping_text: String,
|
pub default_warping_text: String,
|
||||||
pub retry_status_text: Option<String>,
|
pub retry_status_text: Option<String>,
|
||||||
pub model_progress_text: Option<String>,
|
|
||||||
pub secondary_element: Option<Box<dyn Element>>,
|
pub secondary_element: Option<Box<dyn Element>>,
|
||||||
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
|
/// When an LRC subagent has sent at least one snapshot, the timestamp of the most recent snapshot.
|
||||||
pub last_snapshot_at: Option<instant::Instant>,
|
pub last_snapshot_at: Option<instant::Instant>,
|
||||||
@@ -480,13 +479,6 @@ pub fn render_warping_indicator<V: View>(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(model_progress_text) = props.model_progress_text.as_deref() {
|
|
||||||
non_shimmering_text = Some(match non_shimmering_text {
|
|
||||||
Some(text) if !text.is_empty() => format!("{text} · {model_progress_text}"),
|
|
||||||
Some(_) | None => format!(" · {model_progress_text}"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(retry_status_text) = props.retry_status_text.as_deref() {
|
if let Some(retry_status_text) = props.retry_status_text.as_deref() {
|
||||||
non_shimmering_text = Some(match non_shimmering_text {
|
non_shimmering_text = Some(match non_shimmering_text {
|
||||||
Some(text) if !text.is_empty() => format!("{text} {retry_status_text}"),
|
Some(text) if !text.is_empty() => format!("{text} {retry_status_text}"),
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ use crate::ai::blocklist::block::{
|
|||||||
CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView, FinishReason,
|
CollapsibleElementState, CollapsibleExpansionState, EmbeddedCodeEditorView, FinishReason,
|
||||||
ImportedCommentGroup, RequestedEdit, TextLocation, TodoListElementState,
|
ImportedCommentGroup, RequestedEdit, TextLocation, TodoListElementState,
|
||||||
};
|
};
|
||||||
|
use crate::ai::blocklist::controller::ProviderToolCallProgressStatus;
|
||||||
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
use crate::ai::blocklist::history_model::BlocklistAIHistoryModel;
|
||||||
use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestionView;
|
use crate::ai::blocklist::inline_action::ask_user_question_view::AskUserQuestionView;
|
||||||
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
|
use crate::ai::blocklist::inline_action::aws_bedrock_credentials_error::AwsBedrockCredentialsErrorView;
|
||||||
@@ -212,6 +213,9 @@ pub(crate) struct Props<'a> {
|
|||||||
/// `true` when this block belongs to a cloud agent pane that is still in its setup phase
|
/// `true` when this block belongs to a cloud agent pane that is still in its setup phase
|
||||||
/// (running environment startup commands before the first agent turn).
|
/// (running environment startup commands before the first agent turn).
|
||||||
pub(super) is_cloud_agent_pre_first_exchange: bool,
|
pub(super) is_cloud_agent_pre_first_exchange: bool,
|
||||||
|
/// Tool calls whose arguments are still arriving from the model. These are
|
||||||
|
/// rendered as provisional panes until the complete calls replace them.
|
||||||
|
pub(super) streaming_tool_calls: &'a [ProviderToolCallProgressStatus],
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
||||||
@@ -1211,6 +1215,10 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for tool_call in props.streaming_tool_calls {
|
||||||
|
output_items.add_child(render_streaming_tool_call(tool_call, app));
|
||||||
|
}
|
||||||
|
|
||||||
if request_type.is_active() {
|
if request_type.is_active() {
|
||||||
if let AIBlockOutputStatus::Failed { error, .. } = &status {
|
if let AIBlockOutputStatus::Failed { error, .. } = &status {
|
||||||
// While an automatic resume is still in flight, keep the failed exchange
|
// While an automatic resume is still in flight, keep the failed exchange
|
||||||
@@ -1287,6 +1295,22 @@ pub(super) fn render(props: Props, app: &AppContext) -> Box<dyn Element> {
|
|||||||
output_items.finish()
|
output_items.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn render_streaming_tool_call(
|
||||||
|
progress: &ProviderToolCallProgressStatus,
|
||||||
|
app: &AppContext,
|
||||||
|
) -> Box<dyn Element> {
|
||||||
|
let appearance = Appearance::as_ref(app);
|
||||||
|
let loading_icon = Icon::Loading.to_galaxyui_icon(galaxy_core::ui::theme::Fill::Solid(
|
||||||
|
internal_colors::neutral_6(appearance.theme()),
|
||||||
|
));
|
||||||
|
let header = HeaderConfig::new(progress.label(), app)
|
||||||
|
.with_icon(loading_icon)
|
||||||
|
.with_corner_radius_override(CornerRadius::with_all(Radius::Pixels(8.)))
|
||||||
|
.render(app);
|
||||||
|
|
||||||
|
render_tool_pane_shell(Clipped::new(header).finish(), false, false, false, app)
|
||||||
|
}
|
||||||
|
|
||||||
fn render_runtime_activity(
|
fn render_runtime_activity(
|
||||||
output_message: &AIAgentOutputMessage,
|
output_message: &AIAgentOutputMessage,
|
||||||
activity: &RuntimeActivity,
|
activity: &RuntimeActivity,
|
||||||
|
|||||||
@@ -116,6 +116,13 @@ const PROGRESSIVE_SUMMARY_OUTPUT_TOKENS: u32 = 8_000;
|
|||||||
const PROGRESSIVE_SUMMARY_CONTEXT_SAFETY_TOKENS: u32 = 2_000;
|
const PROGRESSIVE_SUMMARY_CONTEXT_SAFETY_TOKENS: u32 = 2_000;
|
||||||
const PROGRESSIVE_SUMMARY_START_TIMEOUT: Duration = Duration::from_secs(120);
|
const PROGRESSIVE_SUMMARY_START_TIMEOUT: Duration = Duration::from_secs(120);
|
||||||
const PROGRESSIVE_SUMMARY_EVENT_TIMEOUT: Duration = Duration::from_secs(300);
|
const PROGRESSIVE_SUMMARY_EVENT_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
const PROGRESSIVE_SUMMARY_PROMPT: &str = "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.";
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct ProgressiveSummaryCandidate {
|
struct ProgressiveSummaryCandidate {
|
||||||
@@ -124,6 +131,21 @@ struct ProgressiveSummaryCandidate {
|
|||||||
provider_config: ProviderConfig,
|
provider_config: ProviderConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct ActiveProgressiveSummaryPlan {
|
||||||
|
split_point: usize,
|
||||||
|
retained_messages_count: usize,
|
||||||
|
active_context_limit: u32,
|
||||||
|
persistent_context_tokens: u32,
|
||||||
|
candidates: Vec<ProgressiveSummaryCandidate>,
|
||||||
|
summarize_messages: Vec<ConversationMessage>,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveProviderRunIdentity {
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
stream_id: ResponseStreamId,
|
||||||
|
run_id: ProviderRunId,
|
||||||
|
}
|
||||||
|
|
||||||
fn configured_llm_context_limit(info: &LLMInfo) -> u32 {
|
fn configured_llm_context_limit(info: &LLMInfo) -> u32 {
|
||||||
[
|
[
|
||||||
info.context_window.default_max,
|
info.context_window.default_max,
|
||||||
@@ -218,6 +240,79 @@ fn progressive_summary_split_point(
|
|||||||
split_point
|
split_point
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn progressive_summary_content(
|
||||||
|
messages: &[ConversationMessage],
|
||||||
|
existing_summary: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
fn safe_truncate(text: &str, max_chars: usize) -> String {
|
||||||
|
if text.len() <= max_chars {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
let truncated = text.chars().take(max_chars).collect::<String>();
|
||||||
|
format!(
|
||||||
|
"{truncated}... [truncated, {total_chars} total chars]",
|
||||||
|
total_chars = text.len()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut content = String::new();
|
||||||
|
if let Some(prior) = existing_summary {
|
||||||
|
content.push_str("<prior-summary>\n");
|
||||||
|
content.push_str(prior);
|
||||||
|
content.push_str("\n</prior-summary>\n\n");
|
||||||
|
}
|
||||||
|
content.push_str("<messages-to-summarize>\n");
|
||||||
|
for message in messages {
|
||||||
|
let role = match message.role {
|
||||||
|
MessageRole::User => "User",
|
||||||
|
MessageRole::Assistant => "Assistant",
|
||||||
|
};
|
||||||
|
let message_content = match &message.content {
|
||||||
|
MessageContent::Text(text) => text.clone(),
|
||||||
|
MessageContent::ToolUse { name, input, .. } => {
|
||||||
|
format!("[Tool Call: {name}] {input}")
|
||||||
|
}
|
||||||
|
MessageContent::ToolResult { content, .. } => safe_truncate(content, 2_000),
|
||||||
|
MessageContent::MultiPart(parts) => parts
|
||||||
|
.iter()
|
||||||
|
.map(|part| match part {
|
||||||
|
ContentPart::Text(text) => text.clone(),
|
||||||
|
ContentPart::Reasoning { text, .. } => format!("[Reasoning] {text}"),
|
||||||
|
ContentPart::Image { .. } => "[Image attachment]".to_string(),
|
||||||
|
ContentPart::ToolUse { name, input, .. } => {
|
||||||
|
format!("[Tool: {name}] {input}")
|
||||||
|
}
|
||||||
|
ContentPart::ToolResult { content, .. } => safe_truncate(content, 2_000),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n"),
|
||||||
|
};
|
||||||
|
content.push_str(&format!("[{role}]: {message_content}\n"));
|
||||||
|
}
|
||||||
|
content.push_str("</messages-to-summarize>");
|
||||||
|
content
|
||||||
|
}
|
||||||
|
|
||||||
|
fn progressive_summary_messages(summary: String) -> Vec<ConversationMessage> {
|
||||||
|
vec![
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text(format!(
|
||||||
|
"<conversation-history-summary>\n{summary}\n</conversation-history-summary>\n\n\
|
||||||
|
The above summarizes earlier conversation history. The detailed messages below are the most recent exchanges."
|
||||||
|
)),
|
||||||
|
},
|
||||||
|
ConversationMessage {
|
||||||
|
role: MessageRole::Assistant,
|
||||||
|
content: MessageContent::Text(
|
||||||
|
"Understood, I have the prior context. Continuing with the recent conversation."
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
async fn collect_progressive_summary(
|
async fn collect_progressive_summary(
|
||||||
provider_config: ProviderConfig,
|
provider_config: ProviderConfig,
|
||||||
request: TurnRequest,
|
request: TurnRequest,
|
||||||
@@ -850,16 +945,19 @@ impl ProviderRetryStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
struct ProviderToolCallProgressStatus {
|
pub(crate) struct ProviderToolCallProgressStatus {
|
||||||
call_id: String,
|
call_id: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
arguments_bytes: u64,
|
arguments_bytes: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderToolCallProgressStatus {
|
impl ProviderToolCallProgressStatus {
|
||||||
fn label(&self) -> String {
|
pub(crate) fn label(&self) -> String {
|
||||||
let activity = match self.name.as_deref() {
|
let activity = match self.name.as_deref() {
|
||||||
Some("apply_file_diffs") => "Preparing file edit",
|
Some("apply_file_diffs") => "Preparing file edit",
|
||||||
|
Some("run_shell_command") => "Preparing shell command",
|
||||||
|
Some("read_files") => "Preparing file read",
|
||||||
|
Some("grep" | "file_glob") => "Preparing file search",
|
||||||
Some(name) if name.starts_with("mcp__") => "Preparing MCP tool call",
|
Some(name) if name.starts_with("mcp__") => "Preparing MCP tool call",
|
||||||
Some(_) | None => "Preparing tool call",
|
Some(_) | None => "Preparing tool call",
|
||||||
};
|
};
|
||||||
@@ -895,10 +993,10 @@ fn provider_tool_call_progress_update(
|
|||||||
arguments_bytes: *arguments_bytes,
|
arguments_bytes: *arguments_bytes,
|
||||||
}),
|
}),
|
||||||
ProviderRunProjection::ModelTurnRequested { .. }
|
ProviderRunProjection::ModelTurnRequested { .. }
|
||||||
| ProviderRunProjection::ModelTurnFinished { .. }
|
|
||||||
| ProviderRunProjection::ModelRetry { .. }
|
| ProviderRunProjection::ModelRetry { .. }
|
||||||
| ProviderRunProjection::ToolBatchReady { .. } => ProviderToolCallProgressUpdate::Clear,
|
| ProviderRunProjection::ToolBatchReady { .. } => ProviderToolCallProgressUpdate::Clear,
|
||||||
ProviderRunProjection::ModelTurnStarted { .. }
|
ProviderRunProjection::ModelTurnStarted { .. }
|
||||||
|
| ProviderRunProjection::ModelTurnFinished { .. }
|
||||||
| ProviderRunProjection::ModelEvent { .. } => ProviderToolCallProgressUpdate::Unchanged,
|
| ProviderRunProjection::ModelEvent { .. } => ProviderToolCallProgressUpdate::Unchanged,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -922,7 +1020,7 @@ struct ActiveProviderRunSlot {
|
|||||||
pending_command_completion: Option<PendingProviderCommandCompletion>,
|
pending_command_completion: Option<PendingProviderCommandCompletion>,
|
||||||
monitor_prose_continuations: usize,
|
monitor_prose_continuations: usize,
|
||||||
retry_status: Option<ProviderRetryStatus>,
|
retry_status: Option<ProviderRetryStatus>,
|
||||||
tool_call_progress: Option<ProviderToolCallProgressStatus>,
|
tool_call_progress: Vec<ProviderToolCallProgressStatus>,
|
||||||
}
|
}
|
||||||
|
|
||||||
struct QueuedProviderRun {
|
struct QueuedProviderRun {
|
||||||
@@ -1512,15 +1610,22 @@ fn provider_command_completion_matches(
|
|||||||
.is_some_and(|execution_ref| execution_ref.run_id == *slot_run_id)
|
.is_some_and(|execution_ref| execution_ref.run_id == *slot_run_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum ProviderCompletionSnapshotMatch {
|
||||||
|
Absent,
|
||||||
|
Match,
|
||||||
|
Mismatch,
|
||||||
|
}
|
||||||
|
|
||||||
fn reconcile_provider_completion_with_snapshot(
|
fn reconcile_provider_completion_with_snapshot(
|
||||||
completion: Option<&mut PendingProviderCommandCompletion>,
|
completion: Option<&mut PendingProviderCommandCompletion>,
|
||||||
block_id: &BlockId,
|
block_id: &BlockId,
|
||||||
expected_initial_action_id: &AIAgentActionId,
|
expected_initial_action_id: &AIAgentActionId,
|
||||||
snapshot_command: Option<&str>,
|
snapshot_command: Option<&str>,
|
||||||
fallback_command: Option<&str>,
|
fallback_command: Option<&str>,
|
||||||
) -> Result<bool, String> {
|
) -> ProviderCompletionSnapshotMatch {
|
||||||
let Some(completion) = completion else {
|
let Some(completion) = completion else {
|
||||||
return Ok(false);
|
return ProviderCompletionSnapshotMatch::Absent;
|
||||||
};
|
};
|
||||||
if completion.block_id != *block_id
|
if completion.block_id != *block_id
|
||||||
|| completion
|
|| completion
|
||||||
@@ -1528,7 +1633,7 @@ fn reconcile_provider_completion_with_snapshot(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|action_id| action_id != expected_initial_action_id)
|
.is_some_and(|action_id| action_id != expected_initial_action_id)
|
||||||
{
|
{
|
||||||
return Err("provider command completion did not match committed snapshot".to_owned());
|
return ProviderCompletionSnapshotMatch::Mismatch;
|
||||||
}
|
}
|
||||||
if completion.command.is_empty() {
|
if completion.command.is_empty() {
|
||||||
completion.command = snapshot_command
|
completion.command = snapshot_command
|
||||||
@@ -1536,7 +1641,7 @@ fn reconcile_provider_completion_with_snapshot(
|
|||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.to_owned();
|
.to_owned();
|
||||||
}
|
}
|
||||||
Ok(true)
|
ProviderCompletionSnapshotMatch::Match
|
||||||
}
|
}
|
||||||
|
|
||||||
fn classify_provider_command_result(
|
fn classify_provider_command_result(
|
||||||
@@ -2155,14 +2260,14 @@ impl BlocklistAIController {
|
|||||||
.and_then(|slot| slot.retry_status)
|
.and_then(|slot| slot.retry_status)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn provider_tool_call_progress_label(
|
pub(crate) fn provider_tool_call_progress(
|
||||||
&self,
|
&self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
) -> Option<String> {
|
) -> Vec<ProviderToolCallProgressStatus> {
|
||||||
self.active_provider_runs
|
self.active_provider_runs
|
||||||
.get(&conversation_id)
|
.get(&conversation_id)
|
||||||
.and_then(|slot| slot.tool_call_progress.as_ref())
|
.map(|slot| slot.tool_call_progress.clone())
|
||||||
.map(ProviderToolCallProgressStatus::label)
|
.unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn has_unresolved_ask_user_question(
|
fn has_unresolved_ask_user_question(
|
||||||
@@ -5204,7 +5309,7 @@ impl BlocklistAIController {
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
};
|
};
|
||||||
match self.active_provider_runs.entry(conversation_data.id) {
|
match self.active_provider_runs.entry(conversation_data.id) {
|
||||||
Entry::Occupied(_) => {
|
Entry::Occupied(_) => {
|
||||||
@@ -5827,7 +5932,7 @@ impl BlocklistAIController {
|
|||||||
pending_command_completion,
|
pending_command_completion,
|
||||||
monitor_prose_continuations,
|
monitor_prose_continuations,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if let Err(error) =
|
if let Err(error) =
|
||||||
@@ -6032,7 +6137,7 @@ impl BlocklistAIController {
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
base_provider_config,
|
base_provider_config,
|
||||||
cli_provider_config,
|
cli_provider_config,
|
||||||
@@ -6341,11 +6446,396 @@ impl BlocklistAIController {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn progressive_summary_candidates(
|
||||||
|
estimated_summary_input_tokens: u32,
|
||||||
|
terminal_surface_id: EntityId,
|
||||||
|
ctx: &AppContext,
|
||||||
|
) -> Vec<ProgressiveSummaryCandidate> {
|
||||||
|
let (terminal_model_id, terminal_context_limit, base_model_id, base_context_limit) = {
|
||||||
|
let preferences = LLMPreferences::as_ref(ctx);
|
||||||
|
let terminal_model =
|
||||||
|
preferences.get_active_cli_agent_model(ctx, Some(terminal_surface_id));
|
||||||
|
let base_model = preferences.get_active_base_model(ctx, Some(terminal_surface_id));
|
||||||
|
(
|
||||||
|
terminal_model.id.to_string(),
|
||||||
|
configured_llm_context_limit(terminal_model),
|
||||||
|
base_model.id.to_string(),
|
||||||
|
configured_llm_context_limit(base_model),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let terminal_provider_config =
|
||||||
|
ResponseStream::resolve_provider_config(&terminal_model_id, ctx);
|
||||||
|
let base_provider_config = ResponseStream::resolve_provider_config(&base_model_id, ctx);
|
||||||
|
let candidate_fits = |context_limit: u32| {
|
||||||
|
estimated_summary_input_tokens
|
||||||
|
.saturating_add(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS)
|
||||||
|
.saturating_add(PROGRESSIVE_SUMMARY_CONTEXT_SAFETY_TOKENS)
|
||||||
|
<= context_limit
|
||||||
|
};
|
||||||
|
let model_is_usable = |model_id: &str, provider_config: &ProviderConfig| {
|
||||||
|
!model_id.trim().is_empty()
|
||||||
|
&& !model_id.eq_ignore_ascii_case("placeholder")
|
||||||
|
&& !matches!(provider_config, ProviderConfig::None)
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut candidates = Vec::new();
|
||||||
|
if model_is_usable(&terminal_model_id, &terminal_provider_config)
|
||||||
|
&& candidate_fits(terminal_context_limit)
|
||||||
|
{
|
||||||
|
candidates.push(ProgressiveSummaryCandidate {
|
||||||
|
model_id: terminal_model_id,
|
||||||
|
context_limit: terminal_context_limit,
|
||||||
|
provider_config: terminal_provider_config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if model_is_usable(&base_model_id, &base_provider_config)
|
||||||
|
&& candidate_fits(base_context_limit)
|
||||||
|
&& !candidates
|
||||||
|
.iter()
|
||||||
|
.any(|candidate| candidate.model_id == base_model_id)
|
||||||
|
{
|
||||||
|
candidates.push(ProgressiveSummaryCandidate {
|
||||||
|
model_id: base_model_id,
|
||||||
|
context_limit: base_context_limit,
|
||||||
|
provider_config: base_provider_config,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
candidates
|
||||||
|
}
|
||||||
|
|
||||||
|
fn begin_active_provider_progressive_summary(
|
||||||
|
&mut self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) -> bool {
|
||||||
|
let Some(active_run) = self
|
||||||
|
.active_provider_runs
|
||||||
|
.get(&conversation_id)
|
||||||
|
.and_then(|slot| slot.run.as_ref())
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if !matches!(
|
||||||
|
active_run.coordinator.run().state(),
|
||||||
|
ProviderRunState::ReadyToCallModel
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active_context_limit = active_run
|
||||||
|
.response_config
|
||||||
|
.max_context_tokens
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let preferences = LLMPreferences::as_ref(ctx);
|
||||||
|
configured_llm_context_limit(
|
||||||
|
preferences.get_active_base_model(ctx, Some(self.terminal_surface_id)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.max(1);
|
||||||
|
let (current_context_tokens, summary_pending) = {
|
||||||
|
let history = BlocklistAIHistoryModel::as_ref(ctx);
|
||||||
|
let Some(conversation) = history.conversation(&conversation_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
(
|
||||||
|
conversation
|
||||||
|
.current_context_tokens()
|
||||||
|
.min(active_context_limit),
|
||||||
|
conversation.has_pending_progressive_summary(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
if summary_pending
|
||||||
|
|| current_context_tokens as f32 / (active_context_limit as f32)
|
||||||
|
< PROGRESSIVE_SUMMARY_TRIGGER_USAGE
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let transcript = active_run.coordinator.run().transcript();
|
||||||
|
let transcript_tokens = estimated_history_tokens(transcript);
|
||||||
|
let persistent_context_tokens = current_context_tokens.saturating_sub(transcript_tokens);
|
||||||
|
let retained_total_budget =
|
||||||
|
(active_context_limit as f32 * PROGRESSIVE_SUMMARY_RETAINED_USAGE) as u32;
|
||||||
|
let retained_history_budget = retained_total_budget
|
||||||
|
.saturating_sub(persistent_context_tokens)
|
||||||
|
.saturating_sub(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS);
|
||||||
|
let split_point = progressive_summary_split_point(transcript, retained_history_budget);
|
||||||
|
if split_point == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
let summarize_content = progressive_summary_content(&transcript[..split_point], None);
|
||||||
|
let estimated_summary_input_tokens = estimated_text_tokens(PROGRESSIVE_SUMMARY_PROMPT)
|
||||||
|
.saturating_add(estimated_text_tokens(&summarize_content));
|
||||||
|
let candidates = Self::progressive_summary_candidates(
|
||||||
|
estimated_summary_input_tokens,
|
||||||
|
self.terminal_surface_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
if candidates.is_empty() {
|
||||||
|
log::warn!(
|
||||||
|
"[progressive-summary] No configured model can fit ~{} input tokens for active conversation {:?}",
|
||||||
|
estimated_summary_input_tokens,
|
||||||
|
conversation_id,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let plan = ActiveProgressiveSummaryPlan {
|
||||||
|
split_point,
|
||||||
|
retained_messages_count: transcript.len().saturating_sub(split_point),
|
||||||
|
active_context_limit,
|
||||||
|
persistent_context_tokens,
|
||||||
|
candidates,
|
||||||
|
summarize_messages: vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text(summarize_content),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(run) = slot.run.take() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let checkpoint = match ActiveProviderRunCheckpoint::from_active_run(&run) {
|
||||||
|
Ok(checkpoint) => checkpoint,
|
||||||
|
Err(error) => {
|
||||||
|
slot.run = Some(run);
|
||||||
|
self.fail_active_provider_run(conversation_id, error, ctx);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
slot.checkpoint = Some(checkpoint);
|
||||||
|
let stream_id = slot.stream_id.clone();
|
||||||
|
let run_id = slot.run_id.clone();
|
||||||
|
let identity = ActiveProviderRunIdentity {
|
||||||
|
conversation_id,
|
||||||
|
stream_id,
|
||||||
|
run_id,
|
||||||
|
};
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||||
|
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||||
|
conversation.set_has_pending_progressive_summary(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
|
||||||
|
if let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) {
|
||||||
|
slot.run = Some(run);
|
||||||
|
}
|
||||||
|
self.fail_active_provider_run(
|
||||||
|
conversation_id,
|
||||||
|
format!("failed to checkpoint provider run before summarization: {error}"),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let candidate_names = plan
|
||||||
|
.candidates
|
||||||
|
.iter()
|
||||||
|
.map(|candidate| format!("{}({})", candidate.model_id, candidate.context_limit))
|
||||||
|
.join(", ");
|
||||||
|
log::info!(
|
||||||
|
"[progressive-summary] Pausing active run {:?}: summarizing {} messages, retaining {}, candidates=[{}]",
|
||||||
|
conversation_id,
|
||||||
|
plan.split_point,
|
||||||
|
plan.retained_messages_count,
|
||||||
|
candidate_names,
|
||||||
|
);
|
||||||
|
ctx.spawn(
|
||||||
|
async move {
|
||||||
|
let mut errors = Vec::new();
|
||||||
|
for candidate in plan.candidates.clone() {
|
||||||
|
let mut request = TurnRequest::new(
|
||||||
|
candidate.model_id.clone(),
|
||||||
|
plan.summarize_messages.clone(),
|
||||||
|
);
|
||||||
|
request.system_prompt = Some(PROGRESSIVE_SUMMARY_PROMPT.to_string());
|
||||||
|
request.max_output_tokens = Some(u64::from(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS));
|
||||||
|
match collect_progressive_summary(candidate.provider_config, request).await {
|
||||||
|
Ok((summary, usage)) => {
|
||||||
|
return (run, plan, Ok((summary, usage, candidate.model_id)));
|
||||||
|
}
|
||||||
|
Err(error) => errors.push(format!("{}: {error}", candidate.model_id)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(
|
||||||
|
run,
|
||||||
|
plan,
|
||||||
|
Err(anyhow!(
|
||||||
|
"all progressive summary candidates failed: {}",
|
||||||
|
errors.join("; ")
|
||||||
|
)),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
move |me, (run, plan, result), ctx| {
|
||||||
|
me.finish_active_provider_progressive_summary(identity, run, plan, result, ctx);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn finish_active_provider_progressive_summary(
|
||||||
|
&mut self,
|
||||||
|
identity: ActiveProviderRunIdentity,
|
||||||
|
mut run: ActiveProviderRun,
|
||||||
|
plan: ActiveProgressiveSummaryPlan,
|
||||||
|
result: anyhow::Result<(String, Usage, String)>,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let ActiveProviderRunIdentity {
|
||||||
|
conversation_id,
|
||||||
|
stream_id,
|
||||||
|
run_id,
|
||||||
|
} = identity;
|
||||||
|
let Some(slot) = self.active_provider_runs.get(&conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if slot.stream_id != stream_id || slot.run_id != run_id {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||||
|
if let Some(conversation) = history.conversation_mut(&conversation_id) {
|
||||||
|
conversation.set_has_pending_progressive_summary(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok((summary, usage, summarizer_model_id)) => {
|
||||||
|
let transcript = run.coordinator.run().transcript();
|
||||||
|
if plan.split_point > transcript.len() {
|
||||||
|
if let Err(error) = run.coordinator.run_mut().fail(
|
||||||
|
ProviderRunFailureKind::Protocol,
|
||||||
|
"provider transcript changed while progressive summarization was running",
|
||||||
|
) {
|
||||||
|
log::error!("Failed to record progressive summary protocol error: {error}");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let drained = transcript[..plan.split_point].to_vec();
|
||||||
|
let retained = transcript[plan.split_point..].to_vec();
|
||||||
|
let summary_prefix = progressive_summary_messages(summary.clone());
|
||||||
|
let summary_prefix_tokens = estimated_history_tokens(&summary_prefix);
|
||||||
|
if let Err(error) = run
|
||||||
|
.coordinator
|
||||||
|
.run_mut()
|
||||||
|
.compact_transcript_at_model_boundary(plan.split_point, summary_prefix)
|
||||||
|
{
|
||||||
|
let _ = run.coordinator.run_mut().fail(
|
||||||
|
ProviderRunFailureKind::Protocol,
|
||||||
|
format!("failed to compact provider transcript: {error}"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
run.persistence_offset = 2;
|
||||||
|
if let Ok(mut messages_sent) = run.messages_sent.lock() {
|
||||||
|
*messages_sent = retained.clone();
|
||||||
|
}
|
||||||
|
let new_context_tokens = plan
|
||||||
|
.persistent_context_tokens
|
||||||
|
.saturating_add(summary_prefix_tokens)
|
||||||
|
.saturating_add(estimated_history_tokens(&retained));
|
||||||
|
let new_usage =
|
||||||
|
new_context_tokens as f32 / plan.active_context_limit.max(1) as f32;
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| {
|
||||||
|
let Some(conversation) = history.conversation_mut(&conversation_id)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
conversation.archive_tool_results(drained);
|
||||||
|
*conversation.bedrock_message_history_mut() = retained;
|
||||||
|
conversation.set_progressive_summary(Some(summary.clone()), 0);
|
||||||
|
conversation.set_current_context_tokens(new_context_tokens);
|
||||||
|
conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0));
|
||||||
|
});
|
||||||
|
log::info!(
|
||||||
|
"[progressive-summary] Resumed active run {:?} with {}: ~{} tokens ({:.1}% of {} context), {} messages retained",
|
||||||
|
conversation_id,
|
||||||
|
summarizer_model_id,
|
||||||
|
new_context_tokens,
|
||||||
|
new_usage * 100.0,
|
||||||
|
plan.active_context_limit,
|
||||||
|
plan.retained_messages_count,
|
||||||
|
);
|
||||||
|
self.record_progressive_summary_usage(
|
||||||
|
conversation_id,
|
||||||
|
usage,
|
||||||
|
summarizer_model_id,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
log::error!(
|
||||||
|
"[progressive-summary] Failed for active conversation {:?}: {error:#}",
|
||||||
|
conversation_id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
slot.run = Some(run);
|
||||||
|
slot.checkpoint = slot
|
||||||
|
.run
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|run| ActiveProviderRunCheckpoint::from_active_run(run).ok());
|
||||||
|
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
|
||||||
|
self.fail_active_provider_run(
|
||||||
|
conversation_id,
|
||||||
|
format!("failed to persist provider run after summarization: {error}"),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.drive_active_provider_run(conversation_id, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_progressive_summary_usage(
|
||||||
|
&self,
|
||||||
|
conversation_id: AIConversationId,
|
||||||
|
usage: Usage,
|
||||||
|
summarizer_model_id: String,
|
||||||
|
ctx: &mut ModelContext<Self>,
|
||||||
|
) {
|
||||||
|
let usage_u32 = |tokens: u64| u32::try_from(tokens).unwrap_or(u32::MAX);
|
||||||
|
let cost_cents = crate::ai::bedrock::response_translator::estimate_cost_cents(
|
||||||
|
usage_u32(usage.input_tokens),
|
||||||
|
usage_u32(usage.output_tokens),
|
||||||
|
usage_u32(usage.cached_input_tokens),
|
||||||
|
usage_u32(usage.cache_creation_input_tokens),
|
||||||
|
&summarizer_model_id,
|
||||||
|
);
|
||||||
|
BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| {
|
||||||
|
use warp_multi_agent_api::response_event::stream_finished;
|
||||||
|
history.update_conversation_cost_and_usage_for_request(
|
||||||
|
conversation_id,
|
||||||
|
None,
|
||||||
|
vec![stream_finished::TokenUsage {
|
||||||
|
model_id: summarizer_model_id,
|
||||||
|
total_input: usage_u32(usage.input_tokens),
|
||||||
|
output: usage_u32(usage.output_tokens),
|
||||||
|
input_cache_read: usage_u32(usage.cached_input_tokens),
|
||||||
|
input_cache_write: usage_u32(usage.cache_creation_input_tokens),
|
||||||
|
cost_in_cents: cost_cents,
|
||||||
|
}],
|
||||||
|
None,
|
||||||
|
false,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn drive_active_provider_run(
|
fn drive_active_provider_run(
|
||||||
&mut self,
|
&mut self,
|
||||||
conversation_id: AIConversationId,
|
conversation_id: AIConversationId,
|
||||||
ctx: &mut ModelContext<Self>,
|
ctx: &mut ModelContext<Self>,
|
||||||
) {
|
) {
|
||||||
|
if self.begin_active_provider_progressive_summary(conversation_id, ctx) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -6503,15 +6993,23 @@ impl BlocklistAIController {
|
|||||||
match tool_call_progress {
|
match tool_call_progress {
|
||||||
ProviderToolCallProgressUpdate::Unchanged => false,
|
ProviderToolCallProgressUpdate::Unchanged => false,
|
||||||
ProviderToolCallProgressUpdate::Set(progress) => {
|
ProviderToolCallProgressUpdate::Set(progress) => {
|
||||||
let changed = slot
|
if let Some(current) = slot
|
||||||
.tool_call_progress
|
.tool_call_progress
|
||||||
.as_ref()
|
.iter_mut()
|
||||||
.is_none_or(|current| current.label() != progress.label());
|
.find(|current| current.call_id == progress.call_id)
|
||||||
slot.tool_call_progress = Some(progress);
|
{
|
||||||
changed
|
let changed = current.label() != progress.label();
|
||||||
|
*current = progress;
|
||||||
|
changed
|
||||||
|
} else {
|
||||||
|
slot.tool_call_progress.push(progress);
|
||||||
|
true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ProviderToolCallProgressUpdate::Clear => {
|
ProviderToolCallProgressUpdate::Clear => {
|
||||||
slot.tool_call_progress.take().is_some()
|
let changed = !slot.tool_call_progress.is_empty();
|
||||||
|
slot.tool_call_progress.clear();
|
||||||
|
changed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -6793,6 +7291,18 @@ impl BlocklistAIController {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
match block {
|
match block {
|
||||||
|
ProviderRunBlock::ReadyToCallModel => {
|
||||||
|
slot.run = Some(run);
|
||||||
|
if let Err(error) = self.persist_active_provider_run(conversation_id, ctx) {
|
||||||
|
self.fail_active_provider_run(
|
||||||
|
conversation_id,
|
||||||
|
format!("failed to persist provider model boundary: {error}"),
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.drive_active_provider_run(conversation_id, ctx);
|
||||||
|
}
|
||||||
ProviderRunBlock::Tools(batch) => {
|
ProviderRunBlock::Tools(batch) => {
|
||||||
slot.committed_provider_batch = None;
|
slot.committed_provider_batch = None;
|
||||||
slot.finished_provider_batch = None;
|
slot.finished_provider_batch = None;
|
||||||
@@ -7098,7 +7608,7 @@ impl BlocklistAIController {
|
|||||||
|
|
||||||
match command_result {
|
match command_result {
|
||||||
ProviderCommandResult::Snapshot { block_id, command } => {
|
ProviderCommandResult::Snapshot { block_id, command } => {
|
||||||
let completion_already_pending = {
|
let (completion_match, monitored_block_id) = {
|
||||||
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
};
|
};
|
||||||
@@ -7111,15 +7621,33 @@ impl BlocklistAIController {
|
|||||||
.unwrap_or_else(|| result.id.clone());
|
.unwrap_or_else(|| result.id.clone());
|
||||||
let fallback_command =
|
let fallback_command =
|
||||||
existing_monitor.map(|monitor| monitor.command.clone());
|
existing_monitor.map(|monitor| monitor.command.clone());
|
||||||
reconcile_provider_completion_with_snapshot(
|
(
|
||||||
slot.pending_command_completion.as_mut(),
|
reconcile_provider_completion_with_snapshot(
|
||||||
&block_id,
|
slot.pending_command_completion.as_mut(),
|
||||||
&expected_initial_action_id,
|
&block_id,
|
||||||
command.as_deref(),
|
&expected_initial_action_id,
|
||||||
fallback_command.as_deref(),
|
command.as_deref(),
|
||||||
)?
|
fallback_command.as_deref(),
|
||||||
|
),
|
||||||
|
slot.command_monitor
|
||||||
|
.as_ref()
|
||||||
|
.map(|monitor| monitor.block_id.clone()),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
if completion_already_pending {
|
match completion_match {
|
||||||
|
ProviderCompletionSnapshotMatch::Match => continue,
|
||||||
|
ProviderCompletionSnapshotMatch::Mismatch => {
|
||||||
|
log::debug!(
|
||||||
|
"Ignoring provider command snapshot for block {block_id:?}; another command completion already owns the continuation boundary"
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
ProviderCompletionSnapshotMatch::Absent => {}
|
||||||
|
}
|
||||||
|
if let Some(monitored_block_id) = monitored_block_id {
|
||||||
|
log::debug!(
|
||||||
|
"Ignoring provider command snapshot for block {block_id:?}; block {monitored_block_id:?} already owns automatic monitoring"
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let cli_task_id =
|
let cli_task_id =
|
||||||
@@ -8222,17 +8750,8 @@ impl BlocklistAIController {
|
|||||||
warp_multi_agent_api::response_event::Type::Finished(
|
warp_multi_agent_api::response_event::Type::Finished(
|
||||||
finished_event,
|
finished_event,
|
||||||
) => {
|
) => {
|
||||||
self.handle_response_stream_finished(
|
// Persist provider-owned history before finalization so any
|
||||||
&stream_id,
|
// end-of-stream compaction decision sees the completed turn.
|
||||||
finished_event,
|
|
||||||
conversation_id,
|
|
||||||
did_input_contain_user_query,
|
|
||||||
ctx,
|
|
||||||
);
|
|
||||||
|
|
||||||
// After the stream finishes, persist the full message
|
|
||||||
// history (input + assistant response) from the Arc back
|
|
||||||
// into the conversation for the next request cycle.
|
|
||||||
let new_history = response_stream
|
let new_history = response_stream
|
||||||
.as_ref(ctx)
|
.as_ref(ctx)
|
||||||
.host_manages_history()
|
.host_manages_history()
|
||||||
@@ -8275,6 +8794,13 @@ impl BlocklistAIController {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
self.handle_response_stream_finished(
|
||||||
|
&stream_id,
|
||||||
|
finished_event,
|
||||||
|
conversation_id,
|
||||||
|
did_input_contain_user_query,
|
||||||
|
ctx,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
warp_multi_agent_api::response_event::Type::ClientActions(actions) => {
|
warp_multi_agent_api::response_event::Type::ClientActions(actions) => {
|
||||||
let client_actions = actions.actions;
|
let client_actions = actions.actions;
|
||||||
@@ -8977,27 +9503,29 @@ impl BlocklistAIController {
|
|||||||
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
|
ctx.emit(BlocklistAIControllerEvent::FreeTierLimitCheckTriggered);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progressive summarization runs in the background with no UI exchange or
|
// Session-owned runtimes summarize after the stream. Direct-provider runs
|
||||||
// tool execution. Token-budget planning below decides how much recent
|
// compact at their durable ReadyToCallModel boundary instead, including
|
||||||
// history to retain and leaves enough hysteresis before the next summary.
|
// immediately before the next user turn, so a background summary cannot
|
||||||
|
// race the live ProviderRun transcript.
|
||||||
|
let is_active_provider_run = self.active_provider_runs.contains_key(&conversation_id);
|
||||||
let should_progressive_summarize = {
|
let should_progressive_summarize = {
|
||||||
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
let history_model = BlocklistAIHistoryModel::as_ref(ctx);
|
||||||
history_model
|
!is_active_provider_run
|
||||||
.conversation(&conversation_id)
|
&& history_model
|
||||||
.is_some_and(|conversation| {
|
.conversation(&conversation_id)
|
||||||
let is_summarization_request =
|
.is_some_and(|conversation| {
|
||||||
conversation.latest_exchange().is_some_and(|exchange| {
|
let is_summarization_request =
|
||||||
exchange
|
conversation.latest_exchange().is_some_and(|exchange| {
|
||||||
.input
|
exchange.input.iter().any(|i| {
|
||||||
.iter()
|
matches!(i, AIAgentInput::SummarizeConversation { .. })
|
||||||
.any(|i| matches!(i, AIAgentInput::SummarizeConversation { .. }))
|
})
|
||||||
});
|
});
|
||||||
conversation.context_window_usage() >= PROGRESSIVE_SUMMARY_TRIGGER_USAGE
|
conversation.context_window_usage() >= PROGRESSIVE_SUMMARY_TRIGGER_USAGE
|
||||||
&& !conversation.has_pending_progressive_summary()
|
&& !conversation.has_pending_progressive_summary()
|
||||||
&& !is_summarization_request
|
&& !is_summarization_request
|
||||||
&& conversation.bedrock_message_history().len()
|
&& conversation.bedrock_message_history().len()
|
||||||
> PROGRESSIVE_SUMMARY_MIN_RECENT_MESSAGES
|
> PROGRESSIVE_SUMMARY_MIN_RECENT_MESSAGES
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
if should_progressive_summarize {
|
if should_progressive_summarize {
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
controller
|
controller
|
||||||
@@ -495,7 +495,7 @@ fn queued_provider_follow_up_persists_while_active_slot_is_unprepared() {
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
base_provider_config: crate::ai::provider::ProviderConfig::None,
|
base_provider_config: crate::ai::provider::ProviderConfig::None,
|
||||||
cli_provider_config: crate::ai::provider::ProviderConfig::None,
|
cli_provider_config: crate::ai::provider::ProviderConfig::None,
|
||||||
@@ -1046,7 +1046,7 @@ fn cancelling_provider_startup_keeps_slot_and_durable_cancellation() {
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1146,7 +1146,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
controller
|
controller
|
||||||
@@ -1179,7 +1179,7 @@ fn same_conversation_follow_up_waits_for_cancelled_provider_generation_cleanup()
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
base_provider_config: crate::ai::provider::ProviderConfig::None,
|
base_provider_config: crate::ai::provider::ProviderConfig::None,
|
||||||
cli_provider_config: crate::ai::provider::ProviderConfig::None,
|
cli_provider_config: crate::ai::provider::ProviderConfig::None,
|
||||||
@@ -1290,7 +1290,7 @@ fn non_follow_up_provider_cancellation_does_not_admit_an_overlapping_generation(
|
|||||||
pending_command_completion: None,
|
pending_command_completion: None,
|
||||||
monitor_prose_continuations: 0,
|
monitor_prose_continuations: 0,
|
||||||
retry_status: None,
|
retry_status: None,
|
||||||
tool_call_progress: None,
|
tool_call_progress: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2473,27 +2473,31 @@ fn pending_provider_completion_reconciles_with_its_committed_snapshot() {
|
|||||||
exit_code: 0,
|
exit_code: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(super::reconcile_provider_completion_with_snapshot(
|
assert_eq!(
|
||||||
Some(&mut completion),
|
super::reconcile_provider_completion_with_snapshot(
|
||||||
&block_id,
|
Some(&mut completion),
|
||||||
&action_id,
|
&block_id,
|
||||||
Some("sleep 10"),
|
&action_id,
|
||||||
None,
|
Some("sleep 10"),
|
||||||
)
|
None,
|
||||||
.unwrap());
|
),
|
||||||
|
super::ProviderCompletionSnapshotMatch::Match
|
||||||
|
);
|
||||||
assert_eq!(completion.command, "sleep 10");
|
assert_eq!(completion.command, "sleep 10");
|
||||||
assert!(super::reconcile_provider_completion_with_snapshot(
|
assert_eq!(
|
||||||
None,
|
super::reconcile_provider_completion_with_snapshot(
|
||||||
&block_id,
|
None,
|
||||||
&action_id,
|
&block_id,
|
||||||
Some("sleep 10"),
|
&action_id,
|
||||||
None,
|
Some("sleep 10"),
|
||||||
)
|
None,
|
||||||
.is_ok_and(|matched| !matched));
|
),
|
||||||
|
super::ProviderCompletionSnapshotMatch::Absent
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_provider_completion_rejects_a_different_snapshot() {
|
fn pending_provider_completion_defers_a_different_snapshot() {
|
||||||
let block_id = BlockId::new();
|
let block_id = BlockId::new();
|
||||||
let action_id = AIAgentActionId::from("command-1".to_owned());
|
let action_id = AIAgentActionId::from("command-1".to_owned());
|
||||||
let mut completion = super::PendingProviderCommandCompletion {
|
let mut completion = super::PendingProviderCommandCompletion {
|
||||||
@@ -2504,23 +2508,27 @@ fn pending_provider_completion_rejects_a_different_snapshot() {
|
|||||||
exit_code: 0,
|
exit_code: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
assert!(super::reconcile_provider_completion_with_snapshot(
|
assert_eq!(
|
||||||
Some(&mut completion),
|
super::reconcile_provider_completion_with_snapshot(
|
||||||
&BlockId::new(),
|
Some(&mut completion),
|
||||||
&action_id,
|
&BlockId::new(),
|
||||||
Some("sleep 10"),
|
&action_id,
|
||||||
None,
|
Some("sleep 10"),
|
||||||
)
|
None,
|
||||||
.is_err());
|
),
|
||||||
|
super::ProviderCompletionSnapshotMatch::Mismatch
|
||||||
|
);
|
||||||
let completion_block_id = completion.block_id.clone();
|
let completion_block_id = completion.block_id.clone();
|
||||||
assert!(super::reconcile_provider_completion_with_snapshot(
|
assert_eq!(
|
||||||
Some(&mut completion),
|
super::reconcile_provider_completion_with_snapshot(
|
||||||
&completion_block_id,
|
Some(&mut completion),
|
||||||
&AIAgentActionId::from("command-2".to_owned()),
|
&completion_block_id,
|
||||||
Some("sleep 10"),
|
&AIAgentActionId::from("command-2".to_owned()),
|
||||||
None,
|
Some("sleep 10"),
|
||||||
)
|
None,
|
||||||
.is_err());
|
),
|
||||||
|
super::ProviderCompletionSnapshotMatch::Mismatch
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use std::borrow::Cow;
|
use std::borrow::Cow;
|
||||||
|
use std::cell::Cell;
|
||||||
use std::cmp::{Ordering, PartialEq};
|
use std::cmp::{Ordering, PartialEq};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
@@ -16,6 +17,7 @@ use galaxyui::elements::{
|
|||||||
};
|
};
|
||||||
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
|
use galaxyui::keymap::{Context, EditableBinding, FixedBinding, Keystroke};
|
||||||
use galaxyui::ui_components::components::UiComponent as _;
|
use galaxyui::ui_components::components::UiComponent as _;
|
||||||
|
use galaxyui::units::IntoPixels;
|
||||||
use galaxyui::{
|
use galaxyui::{
|
||||||
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
|
AppContext, Element, Entity, EntityId, EventContext, ModelHandle, SingletonEntity,
|
||||||
TypedActionView, UpdateView, View, ViewContext, ViewHandle,
|
TypedActionView, UpdateView, View, ViewContext, ViewHandle,
|
||||||
@@ -79,8 +81,11 @@ const REQUESTED_COMMAND_EDIT_LABEL: &str = "Edit";
|
|||||||
const REQUESTED_COMMAND_MINIMIZE_LABEL: &str = "Done";
|
const REQUESTED_COMMAND_MINIMIZE_LABEL: &str = "Done";
|
||||||
|
|
||||||
const LOADING_MESSAGE: &str = "Generating command...";
|
const LOADING_MESSAGE: &str = "Generating command...";
|
||||||
const COMMAND_WAITING_FOR_USER_MESSAGE: &str = "OK if I run this command and read the output?";
|
const COMMAND_WAITING_FOR_USER_MESSAGE: &str = "Approve this command to continue";
|
||||||
const MCP_TOOL_WAITING_FOR_USER_MESSAGE: &str = "OK if I call this MCP tool?";
|
const MCP_TOOL_WAITING_FOR_USER_MESSAGE: &str = "Approve this MCP tool call to continue";
|
||||||
|
const APPROVAL_REQUIRED_BADGE: &str = "Approval required";
|
||||||
|
const WAITING_FOR_APPROVAL_BADGE: &str = "Waiting for approval above";
|
||||||
|
const QUEUED_TOOL_BADGE: &str = "Queued behind an earlier tool";
|
||||||
const MONITORING_COMMAND_MESSAGE: &str = "Agent is monitoring command...";
|
const MONITORING_COMMAND_MESSAGE: &str = "Agent is monitoring command...";
|
||||||
const AGENT_NEEDS_INPUT_MESSAGE: &str = "Agent needs your input to continue";
|
const AGENT_NEEDS_INPUT_MESSAGE: &str = "Agent needs your input to continue";
|
||||||
const USER_TOOK_CONTROL_COMMAND_MESSAGE: &str = "User is in control.";
|
const USER_TOOK_CONTROL_COMMAND_MESSAGE: &str = "User is in control.";
|
||||||
@@ -329,6 +334,9 @@ pub struct RequestedCommandView {
|
|||||||
is_user_expanded: bool,
|
is_user_expanded: bool,
|
||||||
header_mouse_state: MouseStateHandle,
|
header_mouse_state: MouseStateHandle,
|
||||||
output_scroll_state: ClippedScrollStateHandle,
|
output_scroll_state: ClippedScrollStateHandle,
|
||||||
|
follow_command_output: Cell<bool>,
|
||||||
|
last_command_output_len: Cell<usize>,
|
||||||
|
last_output_scroll_start: Cell<f32>,
|
||||||
is_editing: bool,
|
is_editing: bool,
|
||||||
|
|
||||||
// A requested command can either be copied directly off of one citation (such as a Warp Drive
|
// A requested command can either be copied directly off of one citation (such as a Warp Drive
|
||||||
@@ -588,6 +596,9 @@ impl RequestedCommandView {
|
|||||||
is_user_expanded: false,
|
is_user_expanded: false,
|
||||||
header_mouse_state: Default::default(),
|
header_mouse_state: Default::default(),
|
||||||
output_scroll_state: ClippedScrollStateHandle::new(),
|
output_scroll_state: ClippedScrollStateHandle::new(),
|
||||||
|
follow_command_output: Cell::new(true),
|
||||||
|
last_command_output_len: Cell::new(0),
|
||||||
|
last_output_scroll_start: Cell::new(0.),
|
||||||
copied_from_citation: None,
|
copied_from_citation: None,
|
||||||
derived_from_citations: Default::default(),
|
derived_from_citations: Default::default(),
|
||||||
citation_state_handles: Default::default(),
|
citation_state_handles: Default::default(),
|
||||||
@@ -701,6 +712,12 @@ impl RequestedCommandView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
self.is_header_expanded = value;
|
self.is_header_expanded = value;
|
||||||
|
if value && self.action_type.is_requested_command() {
|
||||||
|
self.follow_command_output.set(true);
|
||||||
|
self.last_command_output_len.set(0);
|
||||||
|
self.last_output_scroll_start
|
||||||
|
.set(self.output_scroll_state.scroll_start().as_f32());
|
||||||
|
}
|
||||||
if is_user_initiated {
|
if is_user_initiated {
|
||||||
self.is_user_expanded = value;
|
self.is_user_expanded = value;
|
||||||
}
|
}
|
||||||
@@ -1147,6 +1164,15 @@ impl RequestedCommandView {
|
|||||||
let mut title: Cow<'static, str>;
|
let mut title: Cow<'static, str>;
|
||||||
let mut font_override = None;
|
let mut font_override = None;
|
||||||
let mut font_color_override = None;
|
let mut font_color_override = None;
|
||||||
|
let mut badge = None;
|
||||||
|
|
||||||
|
let conversation_is_blocked =
|
||||||
|
self.block_model
|
||||||
|
.as_ref()
|
||||||
|
.conversation(app)
|
||||||
|
.is_some_and(|conversation| {
|
||||||
|
matches!(conversation.status(), ConversationStatus::Blocked { .. })
|
||||||
|
});
|
||||||
|
|
||||||
let terminal_model = self.terminal_model.lock();
|
let terminal_model = self.terminal_model.lock();
|
||||||
let requested_command_block = match &self.action_type {
|
let requested_command_block = match &self.action_type {
|
||||||
@@ -1177,12 +1203,21 @@ impl RequestedCommandView {
|
|||||||
appearance.theme(),
|
appearance.theme(),
|
||||||
appearance.theme().surface_2(),
|
appearance.theme().surface_2(),
|
||||||
));
|
));
|
||||||
|
badge = Some(
|
||||||
|
if conversation_is_blocked {
|
||||||
|
WAITING_FOR_APPROVAL_BADGE
|
||||||
|
} else {
|
||||||
|
QUEUED_TOOL_BADGE
|
||||||
|
}
|
||||||
|
.to_string(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
Some(AIActionStatus::Blocked) => {
|
Some(AIActionStatus::Blocked) => {
|
||||||
title = match &self.action_type {
|
title = match &self.action_type {
|
||||||
RequestedActionViewType::Command => COMMAND_WAITING_FOR_USER_MESSAGE.into(),
|
RequestedActionViewType::Command => COMMAND_WAITING_FOR_USER_MESSAGE.into(),
|
||||||
RequestedActionViewType::McpTool => MCP_TOOL_WAITING_FOR_USER_MESSAGE.into(),
|
RequestedActionViewType::McpTool => MCP_TOOL_WAITING_FOR_USER_MESSAGE.into(),
|
||||||
};
|
};
|
||||||
|
badge = Some(APPROVAL_REQUIRED_BADGE.to_string());
|
||||||
}
|
}
|
||||||
Some(AIActionStatus::RunningAsync) | Some(AIActionStatus::Finished(..))
|
Some(AIActionStatus::RunningAsync) | Some(AIActionStatus::Finished(..))
|
||||||
if self.is_header_expanded =>
|
if self.is_header_expanded =>
|
||||||
@@ -1329,6 +1364,9 @@ impl RequestedCommandView {
|
|||||||
if let Some(font_color_override) = font_color_override {
|
if let Some(font_color_override) = font_color_override {
|
||||||
config = config.with_font_color(font_color_override);
|
config = config.with_font_color(font_color_override);
|
||||||
}
|
}
|
||||||
|
if let Some(badge) = badge {
|
||||||
|
config = config.with_badge(badge);
|
||||||
|
}
|
||||||
|
|
||||||
match action_status {
|
match action_status {
|
||||||
Some(AIActionStatus::Blocked) => {
|
Some(AIActionStatus::Blocked) => {
|
||||||
@@ -1564,6 +1602,27 @@ impl View for RequestedCommandView {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if let Some(output) = command_output.as_ref() {
|
||||||
|
let scroll_start = self.output_scroll_state.scroll_start().as_f32();
|
||||||
|
let last_scroll_start = self.last_output_scroll_start.get();
|
||||||
|
|
||||||
|
// Keep following live command output until the user deliberately scrolls upward.
|
||||||
|
// Collapsing and reopening the pane resumes following from the bottom.
|
||||||
|
if self.follow_command_output.get() && scroll_start + 0.5 < last_scroll_start {
|
||||||
|
self.follow_command_output.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.follow_command_output.get()
|
||||||
|
&& output.len() != self.last_command_output_len.get()
|
||||||
|
{
|
||||||
|
// Clipped scrollables clamp this sentinel to their actual maximum during layout.
|
||||||
|
self.output_scroll_state.scroll_to(f32::MAX.into_pixels());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_command_output_len.set(output.len());
|
||||||
|
self.last_output_scroll_start.set(scroll_start);
|
||||||
|
}
|
||||||
let should_render_command_output = command_output.is_some();
|
let should_render_command_output = command_output.is_some();
|
||||||
|
|
||||||
let has_citations_footer =
|
let has_citations_footer =
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ pub(crate) enum ProviderRunProjection {
|
|||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) enum ProviderRunBlock {
|
pub(crate) enum ProviderRunBlock {
|
||||||
Tools(PendingToolBatch),
|
Tools(PendingToolBatch),
|
||||||
|
ReadyToCallModel,
|
||||||
AwaitingDriver {
|
AwaitingDriver {
|
||||||
work_id: ExternalWorkId,
|
work_id: ExternalWorkId,
|
||||||
stop_reason: StopReason,
|
stop_reason: StopReason,
|
||||||
@@ -383,7 +384,7 @@ impl ProviderRunCoordinator {
|
|||||||
}
|
}
|
||||||
if batch.is_complete() {
|
if batch.is_complete() {
|
||||||
self.run.commit_tool_batch(&batch.work_id)?;
|
self.run.commit_tool_batch(&batch.work_id)?;
|
||||||
continue;
|
return Ok(ProviderRunBlock::ReadyToCallModel);
|
||||||
}
|
}
|
||||||
return Ok(ProviderRunBlock::Tools(batch));
|
return Ok(ProviderRunBlock::Tools(batch));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -353,6 +353,9 @@ pub enum ProviderRunProtocolError {
|
|||||||
InvalidDriverObservation {
|
InvalidDriverObservation {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
|
InvalidTranscriptCompaction {
|
||||||
|
message: String,
|
||||||
|
},
|
||||||
InvalidRestoredState {
|
InvalidRestoredState {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
@@ -415,6 +418,9 @@ impl fmt::Display for ProviderRunProtocolError {
|
|||||||
Self::InvalidDriverObservation { message } => {
|
Self::InvalidDriverObservation { message } => {
|
||||||
write!(f, "invalid driver observation: {message}")
|
write!(f, "invalid driver observation: {message}")
|
||||||
}
|
}
|
||||||
|
Self::InvalidTranscriptCompaction { message } => {
|
||||||
|
write!(f, "invalid transcript compaction: {message}")
|
||||||
|
}
|
||||||
Self::InvalidRestoredState { message } => {
|
Self::InvalidRestoredState { message } => {
|
||||||
write!(f, "invalid restored provider run: {message}")
|
write!(f, "invalid restored provider run: {message}")
|
||||||
}
|
}
|
||||||
@@ -490,6 +496,36 @@ impl ProviderRun {
|
|||||||
self.tool_result_archive = archive;
|
self.tool_result_archive = archive;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces a summarized prefix while the run is parked between model calls.
|
||||||
|
///
|
||||||
|
/// Tool calls and results removed from the live transcript remain available to
|
||||||
|
/// `recall_tool_history` through the run-owned archive.
|
||||||
|
pub fn compact_transcript_at_model_boundary(
|
||||||
|
&mut self,
|
||||||
|
summarized_messages: usize,
|
||||||
|
summary_prefix: Vec<ConversationMessage>,
|
||||||
|
) -> Result<(), ProviderRunProtocolError> {
|
||||||
|
if !matches!(self.state, ProviderRunState::ReadyToCallModel) {
|
||||||
|
return Err(self.unexpected_state(ProviderRunPhase::ReadyToCallModel));
|
||||||
|
}
|
||||||
|
if summarized_messages == 0 || summarized_messages > self.transcript.len() {
|
||||||
|
return Err(ProviderRunProtocolError::InvalidTranscriptCompaction {
|
||||||
|
message: format!(
|
||||||
|
"cannot summarize {summarized_messages} of {} messages",
|
||||||
|
self.transcript.len()
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let drained = self
|
||||||
|
.transcript
|
||||||
|
.drain(0..summarized_messages)
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
archive_tool_results(&mut self.tool_result_archive, drained);
|
||||||
|
self.transcript.splice(0..0, summary_prefix);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
pub fn usage(&self) -> &Usage {
|
pub fn usage(&self) -> &Usage {
|
||||||
&self.usage
|
&self.usage
|
||||||
}
|
}
|
||||||
@@ -1662,6 +1698,48 @@ fn add_usage(total: &mut Usage, turn: &Usage) {
|
|||||||
.saturating_add(turn.cache_creation_input_tokens);
|
.saturating_add(turn.cache_creation_input_tokens);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn archive_tool_results(
|
||||||
|
archive: &mut Vec<ConversationMessage>,
|
||||||
|
messages: Vec<ConversationMessage>,
|
||||||
|
) {
|
||||||
|
const MAX_TOOL_RESULT_ARCHIVE_ENTRIES: usize = 400;
|
||||||
|
|
||||||
|
let mut pending_tool_uses = Vec::new();
|
||||||
|
for message in messages {
|
||||||
|
match &message.content {
|
||||||
|
MessageContent::ToolUse { .. } => pending_tool_uses.push(message),
|
||||||
|
MessageContent::ToolResult { .. } => {
|
||||||
|
if let Some(tool_use) = pending_tool_uses.pop() {
|
||||||
|
archive.push(tool_use);
|
||||||
|
}
|
||||||
|
archive.push(message);
|
||||||
|
}
|
||||||
|
MessageContent::MultiPart(parts) => {
|
||||||
|
for part in parts {
|
||||||
|
match part {
|
||||||
|
ContentPart::ToolUse { .. } => pending_tool_uses.push(message.clone()),
|
||||||
|
ContentPart::ToolResult { .. } => {
|
||||||
|
if let Some(tool_use) = pending_tool_uses.pop() {
|
||||||
|
archive.push(tool_use);
|
||||||
|
}
|
||||||
|
archive.push(message.clone());
|
||||||
|
}
|
||||||
|
ContentPart::Text(_)
|
||||||
|
| ContentPart::Reasoning { .. }
|
||||||
|
| ContentPart::Image { .. } => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MessageContent::Text(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
archive.extend(pending_tool_uses);
|
||||||
|
|
||||||
|
if archive.len() > MAX_TOOL_RESULT_ARCHIVE_ENTRIES {
|
||||||
|
archive.drain(0..archive.len() - MAX_TOOL_RESULT_ARCHIVE_ENTRIES);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
#[path = "provider_run_tests.rs"]
|
#[path = "provider_run_tests.rs"]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|||||||
@@ -1211,6 +1211,60 @@ fn recovery_pending_tool_survives_another_restore_and_completes_once() {
|
|||||||
assert_eq!(restored.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
assert_eq!(restored.state().phase(), ProviderRunPhase::ReadyToCallModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_compaction_at_model_boundary_archives_drained_tool_history() {
|
||||||
|
let mut run = run();
|
||||||
|
let batch = accept_tool_turn(
|
||||||
|
&mut run,
|
||||||
|
tool_turn(vec![tool_call("read", "read_files")], &["read_files"]),
|
||||||
|
);
|
||||||
|
run.complete_tool(
|
||||||
|
&batch.work_id,
|
||||||
|
successful_result("read", "important file contents"),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
run.commit_tool_batch(&batch.work_id).unwrap();
|
||||||
|
let original_len = run.transcript().len();
|
||||||
|
let summary_prefix = vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("summary".to_string()),
|
||||||
|
}];
|
||||||
|
|
||||||
|
run.compact_transcript_at_model_boundary(original_len, summary_prefix.clone())
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(run.transcript(), summary_prefix);
|
||||||
|
assert_eq!(run.tool_result_archive().len(), 2);
|
||||||
|
assert!(matches!(
|
||||||
|
run.tool_result_archive()[0].content,
|
||||||
|
MessageContent::MultiPart(_)
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
run.tool_result_archive()[1].content,
|
||||||
|
MessageContent::MultiPart(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn transcript_compaction_is_rejected_during_a_model_call() {
|
||||||
|
let mut run = run();
|
||||||
|
let _ = next_model_call(&mut run);
|
||||||
|
|
||||||
|
assert!(matches!(
|
||||||
|
run.compact_transcript_at_model_boundary(
|
||||||
|
1,
|
||||||
|
vec![ConversationMessage {
|
||||||
|
role: MessageRole::User,
|
||||||
|
content: MessageContent::Text("summary".to_string()),
|
||||||
|
}],
|
||||||
|
),
|
||||||
|
Err(ProviderRunProtocolError::UnexpectedState {
|
||||||
|
expected: ProviderRunPhase::ReadyToCallModel,
|
||||||
|
actual: ProviderRunPhase::AwaitingModel,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn restore_normalization_commits_a_fully_resolved_batch() {
|
fn restore_normalization_commits_a_fully_resolved_batch() {
|
||||||
let mut run = run();
|
let mut run = run();
|
||||||
|
|||||||
@@ -383,13 +383,28 @@ fn text_indicates_context_window_exceeded(text: &str) -> bool {
|
|||||||
|| normalized.contains("exceeds the context")
|
|| normalized.contains("exceeds the context")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn completion_error_indicates_recoverable_transport(error: &CompletionError) -> bool {
|
||||||
|
match error {
|
||||||
|
// Rig flattens non-status SSE transport failures into ProviderError.
|
||||||
|
// Preserve their transport semantics so the durable provider run can
|
||||||
|
// retry a truncated or otherwise interrupted response stream.
|
||||||
|
CompletionError::ProviderError(message) => message
|
||||||
|
.to_ascii_lowercase()
|
||||||
|
.starts_with("http client error:"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn map_completion_error(error: CompletionError) -> AgentError {
|
fn map_completion_error(error: CompletionError) -> AgentError {
|
||||||
let is_context_window_exceeded = completion_error_indicates_context_window_exceeded(&error);
|
let is_context_window_exceeded = completion_error_indicates_context_window_exceeded(&error);
|
||||||
|
let is_recoverable_transport = completion_error_indicates_recoverable_transport(&error);
|
||||||
let status = error
|
let status = error
|
||||||
.provider_response_status()
|
.provider_response_status()
|
||||||
.map(|status| status.as_u16());
|
.map(|status| status.as_u16());
|
||||||
let kind = if is_context_window_exceeded {
|
let kind = if is_context_window_exceeded {
|
||||||
AgentErrorKind::ContextWindowExceeded
|
AgentErrorKind::ContextWindowExceeded
|
||||||
|
} else if is_recoverable_transport {
|
||||||
|
AgentErrorKind::Transport
|
||||||
} else {
|
} else {
|
||||||
match status {
|
match status {
|
||||||
Some(401 | 403) => AgentErrorKind::Authentication,
|
Some(401 | 403) => AgentErrorKind::Authentication,
|
||||||
@@ -522,3 +537,7 @@ mod tests {
|
|||||||
assert!(mapped.recoverable);
|
assert!(mapped.recoverable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[path = "stream_tests.rs"]
|
||||||
|
mod regression_tests;
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
use galaxy_agent_core::AgentErrorKind;
|
||||||
|
use rig_core::completion::CompletionError;
|
||||||
|
|
||||||
|
use super::map_completion_error;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn flattened_sse_http_client_error_is_recoverable_transport() {
|
||||||
|
let error = CompletionError::ProviderError(
|
||||||
|
"Http client error: error decoding response body".to_string(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mapped = map_completion_error(error);
|
||||||
|
|
||||||
|
assert_eq!(mapped.kind, AgentErrorKind::Transport);
|
||||||
|
assert!(mapped.recoverable);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user