diff --git a/app/src/ai/agent/conversation.rs b/app/src/ai/agent/conversation.rs index 602a03f5..e2ca968f 100644 --- a/app/src/ai/agent/conversation.rs +++ b/app/src/ai/agent/conversation.rs @@ -60,6 +60,7 @@ use crate::ai::blocklist::{ SerializedBlockListItem, }; use crate::ai::llms::LLMPreferences; +use crate::ai::runtime::ProviderContextUsage; use crate::ai::skills::SkillDescriptor; use crate::code_review::CodeReviewTelemetryEvent; use crate::notebooks::NotebookId; @@ -341,6 +342,9 @@ pub struct AIConversation { progressive_summary: Option, messages_summarized_up_to: usize, current_context_tokens: u32, + /// The bounded request projection used by direct-provider context budgeting. + /// Recomputed at committed model/tool boundaries rather than restored as live usage. + provider_context_usage: Option, latest_model_call_usage: ProviderUsage, has_pending_progressive_summary: bool, } @@ -419,6 +423,7 @@ impl AIConversation { progressive_summary: None, messages_summarized_up_to: 0, current_context_tokens: 0, + provider_context_usage: None, latest_model_call_usage: ProviderUsage::default(), has_pending_progressive_summary: false, } @@ -732,6 +737,7 @@ impl AIConversation { // The previous response stream no longer exists; its drain count cannot apply. messages_summarized_up_to: 0, current_context_tokens: 0, + provider_context_usage: None, latest_model_call_usage: ProviderUsage::default(), has_pending_progressive_summary: false, }) @@ -786,6 +792,9 @@ impl AIConversation { if !can_change_backend && self.agent_backend != agent_backend { return false; } + if self.agent_backend != agent_backend { + self.provider_context_usage = None; + } self.agent_backend = agent_backend; true } @@ -813,6 +822,24 @@ impl AIConversation { self.current_context_tokens = val; } + pub(crate) fn provider_context_usage(&self) -> Option<&ProviderContextUsage> { + self.provider_context_usage.as_ref() + } + + pub(crate) fn set_provider_context_usage(&mut self, usage: Option) { + self.provider_context_usage = usage; + } + + /// Provider-reported input for the last model call, separate from the next-request estimate. + pub(crate) fn latest_model_call_reported_context_tokens(&self) -> Option { + let tokens = self + .latest_model_call_usage + .input_tokens + .saturating_add(self.latest_model_call_usage.cached_input_tokens) + .saturating_add(self.latest_model_call_usage.cache_creation_input_tokens); + (tokens > 0).then(|| u32::try_from(tokens).unwrap_or(u32::MAX)) + } + pub fn set_latest_model_call_usage(&mut self, usage: ProviderUsage) { self.current_context_tokens = u32::try_from( usage diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index 99623c16..b8f1d746 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -52,6 +52,7 @@ pub(crate) use self::environment_selector::{ use crate::ai::blocklist::agent_view::is_in_cloud_context; use crate::ai::blocklist::history_model::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; use crate::ai::blocklist::prompt::prompt_alert::{PromptAlertEvent, PromptAlertView}; +use crate::ai::blocklist::usage::conversation_usage_view::provider_context_usage_tooltip; use crate::ai::blocklist::usage::icon_for_context_window_usage; use crate::ai::blocklist::BlocklistAIInputModel; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; @@ -802,6 +803,18 @@ impl AgentInputFooter { } match event { + BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { + conversation_id, + } => { + if BlocklistAIHistoryModel::as_ref(ctx) + .active_conversation(me.terminal_view_id) + .is_some_and(|conversation| conversation.id() == *conversation_id) + { + me.update_context_window_button(ctx); + me.update_llm_usage_buttons(ctx); + ctx.notify(); + } + } BlocklistAIHistoryEvent::StartedNewConversation { .. } | BlocklistAIHistoryEvent::SetActiveConversation { .. } | BlocklistAIHistoryEvent::ClearedActiveConversation { .. } @@ -1985,7 +1998,11 @@ impl AgentInputFooter { if let Some(conversation) = BlocklistAIHistoryModel::as_ref(ctx).active_conversation(self.terminal_view_id) { - let usage = conversation.context_window_usage().clamp(0.0, 1.0); + let provider_context = conversation.provider_context_usage(); + let usage = provider_context + .map(|usage| usage.compaction_usage()) + .unwrap_or_else(|| conversation.context_window_usage()) + .clamp(0.0, 1.0); let icon = icon_for_context_window_usage(usage); let remaining_pct = ((1.0 - usage) * 100.0).round() as i32; @@ -1995,7 +2012,11 @@ impl AgentInputFooter { }); let is_cache_expired = FeatureFlag::PromptCacheExpiryWarning.is_enabled() && expiry.is_some_and(|expiry| expiry <= Local::now()); - let context_remaining_tooltip = format!("{remaining_pct}% context remaining"); + let context_remaining_tooltip = if provider_context.is_some() { + format!("~{remaining_pct}% compaction history budget remaining") + } else { + format!("{remaining_pct}% context remaining") + }; let tooltip = if is_cache_expired { format!("{context_remaining_tooltip} · prompt cache expired") } else { @@ -2022,10 +2043,20 @@ impl AgentInputFooter { conversation.latest_model_call_cache_read_tokens(), conversation.latest_model_call_cache_write_tokens(), conversation.latest_model_call_cache_miss_tokens(), + conversation.provider_context_usage().cloned(), + conversation.latest_model_call_reported_context_tokens(), ) }); - let Some((current_context, reported_usage, cache_read, cache_write, cache_miss)) = metrics + let Some(( + current_context, + reported_usage, + cache_read, + cache_write, + cache_miss, + provider_context, + last_reported_input, + )) = metrics else { self.llm_context_usage_button.update(ctx, |button, ctx| { button.set_label("Ctx —", ctx); @@ -2051,29 +2082,48 @@ impl AgentInputFooter { .active_profile(Some(self.terminal_view_id), ctx) .data() .context_window_display_value_for_model(active_model, ctx); - let model_max_context = active_model - .context_window - .default_max - .max(active_model.context_window.max); - let max_context = profile_context - .or((model_max_context > 0).then_some(model_max_context)) - .unwrap_or(200_000); - let context_usage = if max_context > 0 && current_context > 0 { + let model_context = [ + active_model.context_window.default_max, + active_model.context_window.max, + ] + .into_iter() + .find(|limit| *limit > 0); + let max_context = profile_context.or(model_context).unwrap_or(200_000); + let context_usage = if let Some(usage) = &provider_context { + usage.compaction_usage().clamp(0.0, 1.0) + } else if max_context > 0 && current_context > 0 { (current_context as f32 / max_context as f32).clamp(0.0, 1.0) } else { reported_usage.clamp(0.0, 1.0) }; - let context_pct = context_usage * 100.0; - let context_label = format!( - "Ctx {context_pct:.1}% · {}/{}", - format_llm_token_count(current_context), - format_llm_token_count(max_context), - ); - let context_tooltip = format!( - "Current LLM context: {} of {} tokens ({context_pct:.1}%). This is not cumulative.", - format_llm_token_count(current_context), - format_llm_token_count(max_context), - ); + let context_pct = provider_context + .as_ref() + .map(|usage| usage.compaction_usage()) + .unwrap_or(context_usage) + * 100.0; + let (context_label, context_tooltip) = if let Some(usage) = &provider_context { + ( + format!( + "Ctx budget ~{context_pct:.1}% · ~{}/{}", + format_llm_token_count(usage.compaction.history_tokens), + format_llm_token_count(usage.compaction.history_capacity_tokens), + ), + provider_context_usage_tooltip(usage, last_reported_input), + ) + } else { + ( + format!( + "Ctx {context_pct:.1}% · {}/{}", + format_llm_token_count(current_context), + format_llm_token_count(max_context), + ), + format!( + "Current LLM context: {} of {} tokens ({context_pct:.1}%). This is not cumulative.", + format_llm_token_count(current_context), + format_llm_token_count(max_context), + ), + ) + }; let context_icon = icon_for_context_window_usage(context_usage); self.llm_context_usage_button.update(ctx, |button, ctx| { button.set_label(context_label, ctx); diff --git a/app/src/ai/blocklist/controller.rs b/app/src/ai/blocklist/controller.rs index 2727b45d..75b809db 100644 --- a/app/src/ai/blocklist/controller.rs +++ b/app/src/ai/blocklist/controller.rs @@ -15,7 +15,7 @@ use std::collections::{BTreeMap, HashMap, HashSet, VecDeque}; #[cfg(not(target_family = "wasm"))] use std::path::PathBuf; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use ai::skills::SkillPathOrigin; use anyhow::anyhow; @@ -84,12 +84,13 @@ use crate::ai::provider::ProviderConfig; #[cfg(not(target_family = "wasm"))] use crate::ai::remote_logging::{self, RemoteLogLevel, RemoteLogRecord}; use crate::ai::runtime::{ - prepare_provider_run, provider_context_window_tokens, provider_runtime_for_request, - restore_provider_config, OrchestrationModelOption, PreparedProviderRun, ProviderActionContext, - ProviderRunBlock, ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection, + estimated_context_message_tokens, prepare_provider_run, provider_context_window_tokens, + provider_runtime_for_request, restore_provider_config, OrchestrationModelOption, + PreparedProviderRun, ProviderActionContext, ProviderContextUsage, ProviderRunBlock, + ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection, ProviderRunResponseProjector, ProviderToolExecutionRef, ProviderToolLifecycleOutcome, RuntimeResponseConfig, BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE, - COMPUTER_USE_PROVIDER_PROFILE, + COMPUTER_USE_PROVIDER_PROFILE, PROGRESSIVE_SUMMARY_TRIGGER_USAGE, }; use crate::ai::AIRequestUsageModel; use crate::cloud_object::model::persistence::CloudModel; @@ -114,7 +115,6 @@ use crate::terminal::ShellLaunchData; use crate::workspaces::update_manager::TeamUpdateManager; use crate::workspaces::user_workspaces::UserWorkspaces; -const PROGRESSIVE_SUMMARY_TRIGGER_USAGE: f32 = 0.70; const PROGRESSIVE_SUMMARY_RETAINED_USAGE: f32 = 0.50; const PROGRESSIVE_SUMMARY_MIN_RECENT_MESSAGES: usize = 1; const PROGRESSIVE_SUMMARY_OUTPUT_TOKENS: u32 = 8_000; @@ -148,6 +148,8 @@ struct ActiveProgressiveSummaryPlan { candidates: Vec, summarized_prefix: Vec, summarize_messages: Vec, + context_usage: Option, + started_at: Instant, } #[derive(Clone, PartialEq, Eq)] @@ -265,6 +267,18 @@ fn content_contains_tool_result(content: &MessageContent) -> bool { fn progressive_summary_split_point( messages: &[ConversationMessage], retained_token_budget: u32, +) -> usize { + progressive_summary_split_point_with_estimator( + messages, + retained_token_budget, + estimated_message_tokens, + ) +} + +fn progressive_summary_split_point_with_estimator( + messages: &[ConversationMessage], + retained_token_budget: u32, + estimate_message: fn(&ConversationMessage) -> u32, ) -> usize { if messages.len() <= PROGRESSIVE_SUMMARY_MIN_RECENT_MESSAGES { return 0; @@ -273,7 +287,7 @@ fn progressive_summary_split_point( let mut retained_tokens = 0_u32; let mut split_point = messages.len(); for index in (0..messages.len()).rev() { - let message_tokens = estimated_message_tokens(&messages[index]); + let message_tokens = estimate_message(&messages[index]); let retained_count = messages.len() - index; // Keep the latest message, then retain only as much history as fits. // A fixed message-count floor can retain many oversized file reads. @@ -302,7 +316,12 @@ fn progressive_summary_split_point( if messages .last() .is_some_and(|message| content_contains_tool_result(&message.content)) - && estimated_history_tokens(&messages[split_point..]) > retained_token_budget + && messages[split_point..] + .iter() + .fold(0_u32, |total, message| { + total.saturating_add(estimate_message(message)) + }) + > retained_token_budget { return messages.len(); } @@ -2046,6 +2065,7 @@ fn provider_llm_lifecycle(projection: &ProviderRunProjection) -> Option ProviderLlmLifecycle { phase: ProviderLlmLifecyclePhase::Requested, work_id: work_id.clone(), @@ -2245,6 +2265,7 @@ fn provider_run_terminal_remote_log_record( enum ProviderDriveMessage { Projection { lifecycle: Box>, + context_usage: Option, latest_usage: Option, tool_call_progress: ProviderToolCallProgressUpdate, events: Vec, @@ -7137,6 +7158,73 @@ impl BlocklistAIController { } } + fn publish_provider_context_usage( + &self, + conversation_id: AIConversationId, + usage: Option, + phase: &str, + ctx: &mut ModelContext, + ) { + let changed = BlocklistAIHistoryModel::as_ref(ctx) + .conversation(&conversation_id) + .is_some_and(|conversation| conversation.provider_context_usage() != usage.as_ref()); + if !changed { + return; + } + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { + if let Some(conversation) = history.conversation_mut(&conversation_id) { + conversation.set_provider_context_usage(usage.clone()); + ctx.emit(BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { + conversation_id, + }); + } + }); + #[cfg(not(target_family = "wasm"))] + if let Some(usage) = usage { + let slot = self.active_provider_runs.get(&conversation_id); + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Provider context budget updated".to_owned(), + context: serde_json::json!({ + "event": "provider_context_usage", + "conversation_id": conversation_id, + "provider_run_id": slot.map(|slot| slot.run_id.as_str()), + "stream_id": slot.map(|slot| slot.stream_id.as_str()), + "phase": phase, + "source": "projected_request_estimate", + "active": usage.active, + "compaction": usage.compaction, + "compaction_usage": usage.compaction_usage(), + "compaction_trigger_usage": PROGRESSIVE_SUMMARY_TRIGGER_USAGE, + "request_safety_usage": 0.90, + }), + }, + ); + } + } + + fn refresh_active_provider_context_usage( + &self, + conversation_id: AIConversationId, + phase: &str, + ctx: &mut ModelContext, + ) { + if let Some(run) = self + .active_provider_runs + .get(&conversation_id) + .and_then(|slot| slot.run.as_ref()) + { + self.publish_provider_context_usage( + conversation_id, + run.coordinator.context_usage_snapshot(), + phase, + ctx, + ); + } + } + fn active_provider_context_estimate( &self, conversation_id: AIConversationId, @@ -7166,9 +7254,7 @@ impl BlocklistAIController { .map(Self::live_provider_command_context_tokens) .unwrap_or(0); Some(( - current_context_tokens - .max(estimated_context_tokens) - .saturating_add(live_command_output_tokens), + estimated_context_tokens.saturating_add(live_command_output_tokens), persistent_context_tokens, )) } @@ -7229,17 +7315,22 @@ impl BlocklistAIController { .coordinator .progressive_summary_context_budget() .unwrap_or((active_context_limit, current_persistent_context_tokens)); + let context_usage = active_run.coordinator.context_usage_snapshot(); // Trigger and retain against the space history can actually use. Fixed // prompts and a large output reservation cannot be reduced by summarizing. let history_capacity = summary_context_limit.saturating_sub(summary_persistent_context_tokens); - let current_history_tokens = - current_context_tokens.saturating_sub(current_persistent_context_tokens); + let current_history_tokens = context_usage.as_ref().map_or_else( + || current_context_tokens.saturating_sub(current_persistent_context_tokens), + |usage| usage.compaction.history_tokens, + ); + let compaction_usage = context_usage.as_ref().map_or_else( + || current_history_tokens as f32 / history_capacity.max(1) as f32, + ProviderContextUsage::compaction_usage, + ); if summary_pending || last_failure_context_tokens == Some(current_context_tokens) - || (!force - && current_history_tokens as f32 / (history_capacity.max(1) as f32) - < PROGRESSIVE_SUMMARY_TRIGGER_USAGE) + || (!force && compaction_usage < PROGRESSIVE_SUMMARY_TRIGGER_USAGE) { return false; } @@ -7247,7 +7338,28 @@ impl BlocklistAIController { (history_capacity as f32 * PROGRESSIVE_SUMMARY_RETAINED_USAGE) as u32; let retained_history_budget = retained_history_budget.saturating_sub(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS); - let split_point = progressive_summary_split_point(transcript, retained_history_budget); + // Satisfy both profiles' own retention targets: per-profile output bounds + // can produce different history sizes even for the same durable transcript. + let split_point = [ + active_run.coordinator.run().profile().as_str(), + CLI_MONITOR_PROVIDER_PROFILE, + ] + .into_iter() + .filter_map(|profile| { + let usage = active_run.coordinator.context_profile_usage(profile)?; + let messages = active_run + .coordinator + .projected_transcript_for_context(profile)?; + let budget = + (usage.history_capacity_tokens as f32 * PROGRESSIVE_SUMMARY_RETAINED_USAGE) as u32; + Some(progressive_summary_split_point_with_estimator( + &messages, + budget.saturating_sub(PROGRESSIVE_SUMMARY_OUTPUT_TOKENS), + estimated_context_message_tokens, + )) + }) + .max() + .unwrap_or_else(|| progressive_summary_split_point(transcript, retained_history_budget)); if split_point == 0 { return false; } @@ -7307,6 +7419,8 @@ impl BlocklistAIController { role: MessageRole::User, content: MessageContent::Text(summarize_content), }], + context_usage, + started_at: Instant::now(), }; let Some(slot) = self.active_provider_runs.get(&conversation_id) else { @@ -7349,6 +7463,26 @@ impl BlocklistAIController { plan.retained_messages_count, candidate_names, ); + #[cfg(not(target_family = "wasm"))] + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Provider background compaction started".to_owned(), + context: serde_json::json!({ + "event": "provider_compaction_started", + "conversation_id": conversation_id, + "provider_run_id": identity.run_id.as_str(), + "stream_id": identity.stream_id.as_str(), + "forced": force, + "context_usage": plan.context_usage, + "trigger_usage": PROGRESSIVE_SUMMARY_TRIGGER_USAGE, + "retained_usage": PROGRESSIVE_SUMMARY_RETAINED_USAGE, + "summarized_message_count": plan.split_point, + "retained_message_count": plan.retained_messages_count, + }), + }, + ); ctx.spawn( async move { let mut errors = Vec::new(); @@ -7419,6 +7553,25 @@ impl BlocklistAIController { return; } + #[cfg(not(target_family = "wasm"))] + remote_logging::log_model_event( + ctx, + RemoteLogRecord { + level: RemoteLogLevel::Info, + message: "Provider background compaction finished".to_owned(), + context: serde_json::json!({ + "event": "provider_compaction_finished", + "conversation_id": conversation_id, + "provider_run_id": identity.run_id.as_str(), + "stream_id": identity.stream_id.as_str(), + "elapsed_ms": plan.started_at.elapsed().as_millis() as u64, + "succeeded": result.is_ok(), + "waiting_for_context": waiting_for_context, + "context_usage": plan.context_usage, + }), + }, + ); + self.active_provider_progressive_summaries.insert( conversation_id, ActiveProviderProgressiveSummaryState::Ready { @@ -7630,6 +7783,7 @@ impl BlocklistAIController { ); return true; } + self.refresh_active_provider_context_usage(conversation_id, "compaction_applied", ctx); false } @@ -7807,6 +7961,7 @@ impl BlocklistAIController { if self.apply_ready_active_provider_progressive_summary(conversation_id, ctx) { return; } + self.refresh_active_provider_context_usage(conversation_id, "committed_boundary", ctx); self.schedule_provider_command_output_poll(conversation_id, ctx); // Ordinary background work never blocks the agent. Once a model has // exhausted its context, polling must not relaunch that same request @@ -7866,6 +8021,12 @@ impl BlocklistAIController { turn_control, |projection| { let lifecycle = provider_llm_lifecycle(&projection); + let context_usage = match &projection { + ProviderRunProjection::ModelTurnRequested { + context_usage, .. + } => context_usage.clone(), + _ => None, + }; let tool_call_progress = provider_tool_call_progress_update(&projection); let latest_usage = match &projection { @@ -7888,6 +8049,7 @@ impl BlocklistAIController { projection_sender .send(ProviderDriveMessage::Projection { lifecycle: Box::new(lifecycle), + context_usage, latest_usage, tool_call_progress, events, @@ -7951,20 +8113,32 @@ impl BlocklistAIController { match message { ProviderDriveMessage::Projection { lifecycle, + context_usage, latest_usage, tool_call_progress, events, acknowledgement, } => { + let response_stream = slot.response_stream.clone(); + let did_input_contain_user_query = slot.did_input_contain_user_query; + if let Some(usage) = context_usage { + self.publish_provider_context_usage( + conversation_id, + Some(usage), + "model_requested", + ctx, + ); + } if let Some(usage) = latest_usage { - BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, _| { + BlocklistAIHistoryModel::handle(ctx).update(ctx, |history, ctx| { if let Some(conversation) = history.conversation_mut(&conversation_id) { conversation.set_latest_model_call_usage(usage); + ctx.emit(BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { + conversation_id, + }); } }); } - let response_stream = slot.response_stream.clone(); - let did_input_contain_user_query = slot.did_input_contain_user_query; let mut result = Ok(()); for event in events { let event = ResponseStream::projected_event(event); @@ -8271,6 +8445,18 @@ impl BlocklistAIController { result: Result, ctx: &mut ModelContext, ) { + if self + .active_provider_runs + .get(&conversation_id) + .is_some_and(|slot| slot.run_id == *run.coordinator.run().id()) + { + self.publish_provider_context_usage( + conversation_id, + run.coordinator.context_usage_snapshot(), + "committed_result", + ctx, + ); + } let Some(slot) = self.active_provider_runs.get_mut(&conversation_id) else { return; }; @@ -9380,6 +9566,11 @@ impl BlocklistAIController { let retained = transcript.split_off(plan.split_point); let retained_count = retained.len(); + let mut projected_history = progressive_summary_messages(summary.clone()); + projected_history.extend_from_slice(&retained); + let context_usage = conversation.provider_context_usage() + .or(plan.context_usage.as_ref()) + .map(|usage| usage.with_transcript(&projected_history)); let new_context_tokens = plan .persistent_context_tokens .saturating_add(summary_prefix_tokens) @@ -9391,6 +9582,10 @@ impl BlocklistAIController { conversation.set_progressive_summary(Some(summary), 0); conversation.set_current_context_tokens(new_context_tokens); conversation.set_context_window_usage(new_usage.clamp(0.0, 1.0)); + conversation.set_provider_context_usage(context_usage); + ctx.emit(BlocklistAIHistoryEvent::ConversationUsageMetadataUpdated { + conversation_id, + }); conversation.write_updated_conversation_state(ctx); log::info!( "[progressive-summary] Applied background summary after active run completion {:?}: ~{} tokens ({:.1}% of {} context), {} tail messages preserved", diff --git a/app/src/ai/blocklist/controller_tests.rs b/app/src/ai/blocklist/controller_tests.rs index 496da48f..f8289a55 100644 --- a/app/src/ai/blocklist/controller_tests.rs +++ b/app/src/ai/blocklist/controller_tests.rs @@ -56,6 +56,7 @@ fn provider_lifecycle_logs_expose_llm_completion_and_sanitize_errors() { retry_error.recoverable = true; let projections = [ ProviderRunProjection::ModelTurnRequested { + context_usage: None, work_id: work_id.clone(), profile: "base".into(), runtime_id: "rig:openai".to_owned(), @@ -4301,6 +4302,8 @@ fn progressive_summary_test_plan( candidates: Vec::new(), summarize_messages: summarized_prefix.clone(), summarized_prefix, + context_usage: None, + started_at: std::time::Instant::now(), } } diff --git a/app/src/ai/blocklist/usage/conversation_usage_view.rs b/app/src/ai/blocklist/usage/conversation_usage_view.rs index fe71701c..f7042318 100644 --- a/app/src/ai/blocklist/usage/conversation_usage_view.rs +++ b/app/src/ai/blocklist/usage/conversation_usage_view.rs @@ -29,6 +29,7 @@ use crate::ai::blocklist::usage::rollup::{ }; use crate::ai::blocklist::view_util::{format_credits, format_token_count}; use crate::ai::blocklist::{BlocklistAIHistoryEvent, BlocklistAIHistoryModel}; +use crate::ai::runtime::{ProviderContextUsage, PROGRESSIVE_SUMMARY_TRIGGER_USAGE}; use crate::appearance::Appearance; use crate::persistence::model::{ token_usage_category_display_name, ContextWindowSegment, ContextWindowSegmentType, @@ -61,6 +62,10 @@ pub struct ConversationUsageInfo { pub commands_executed: i32, /// Live context window token count (from most recent Bedrock response). pub current_context_tokens: u32, + /// Local estimate for the projected direct-provider request and its compaction budget. + pub provider_context_usage: Option, + /// Input tokens reported by the last completed provider call, separate from local estimates. + pub latest_model_call_reported_context_tokens: Option, /// Cumulative cost across all requests. pub estimated_cost_cents: f32, /// Cumulative cache read tokens (session total). @@ -71,6 +76,44 @@ pub struct ConversationUsageInfo { pub total_input_tokens: u32, } +pub(crate) fn provider_context_usage_tooltip( + usage: &ProviderContextUsage, + last_reported_input_tokens: Option, +) -> String { + let active = &usage.active; + let compaction = &usage.compaction; + let last_reported = last_reported_input_tokens + .map(|tokens| format!("{tokens} tokens")) + .unwrap_or_else(|| "not yet available".to_string()); + let trigger_percent = PROGRESSIVE_SUMMARY_TRIGGER_USAGE * 100.0; + format!( + "Local estimate for the projected request; provider tokenization may differ. Updates at model/tool boundaries.\n\ + Active request: ~{} input tokens of {} effective context tokens ({:.1}%).\n\ + Active profile: {} ({}).\n\ + Fixed prompts and tools: ~{} input tokens. Reserved output: {} tokens.\n\ + Compaction budget: ~{} history tokens of {} usable history tokens ({:.1}%).\n\ + Limiting profile: {} ({}), {} effective context tokens, ~{} fixed input tokens and {} reserved output tokens.\n\ + Compaction starts at {trigger_percent:.0}% of usable history.\n\ + Last provider-reported input: {}.", + active.estimated_input_tokens, + active.context_limit, + active.input_usage() * 100.0, + active.profile, + active.model_id, + active.fixed_input_tokens, + active.reserved_output_tokens, + compaction.history_tokens, + compaction.history_capacity_tokens, + usage.compaction_usage() * 100.0, + compaction.profile, + compaction.model_id, + compaction.context_limit, + compaction.fixed_input_tokens, + compaction.reserved_output_tokens, + last_reported, + ) +} + /// Timing information for the last set of agent responses /// (all blocks since the last user input, as this is the granularity /// at which we show the usage footer) @@ -139,6 +182,29 @@ pub struct ConversationUsageView { } impl ConversationUsageView { + fn provider_context_usage( + &self, + app: &AppContext, + ) -> Option<(ProviderContextUsage, Option)> { + if let Some(conversation) = self + .parent_conversation_id + .and_then(|id| BlocklistAIHistoryModel::as_ref(app).conversation(&id)) + { + if let Some(usage) = conversation.provider_context_usage() { + return Some(( + usage.clone(), + conversation.latest_model_call_reported_context_tokens(), + )); + } + } + self.usage_info.provider_context_usage.clone().map(|usage| { + ( + usage, + self.usage_info.latest_model_call_reported_context_tokens, + ) + }) + } + pub fn new( usage_info: ConversationUsageInfo, display_mode: DisplayMode, @@ -316,8 +382,14 @@ impl ConversationUsageView { let theme = appearance.theme(); let font_size = appearance.ui_font_size() + 2.; let text_color = blended_colors::text_main(theme, theme.surface_2()); - let context_window_breakdown_enabled = FeatureFlag::ContextWindowUsageBreakdown - .is_enabled() + let provider_context_usage = self.provider_context_usage(app); + let context_window_usage = provider_context_usage + .as_ref() + .map(|(usage, _)| usage.compaction_usage()) + .unwrap_or(self.usage_info.context_window_usage) + .clamp(0.0, 1.0); + let context_window_breakdown_enabled = provider_context_usage.is_none() + && FeatureFlag::ContextWindowUsageBreakdown.is_enabled() && !context_window_segment_display_rows( self.usage_info.context_window_usage, &self.usage_info.context_window_segments, @@ -478,8 +550,53 @@ impl ConversationUsageView { ); } - // Context tokens (live state — current context window size) - if self.usage_info.current_context_tokens > 0 { + if let Some((usage, last_reported)) = &provider_context_usage { + let active = &usage.active; + let compaction = &usage.compaction; + for (label, value) in [ + ( + "Projected input (local estimate)", + format!("~{} tokens", active.estimated_input_tokens), + ), + ( + "Effective request context", + format!("{} tokens", active.context_limit), + ), + ( + "Fixed prompts and tools (estimate)", + format!("~{} tokens", active.fixed_input_tokens), + ), + ( + "Reserved output", + format!("{} tokens", active.reserved_output_tokens), + ), + ( + "Compaction history (estimate)", + format!( + "~{} / {} tokens", + compaction.history_tokens, compaction.history_capacity_tokens, + ), + ), + ("Compaction profile", compaction.profile.clone()), + ("Compaction model", compaction.model_id.clone()), + ( + "Compaction trigger", + format!( + "{:.0}% of usable history", + PROGRESSIVE_SUMMARY_TRIGGER_USAGE * 100.0 + ), + ), + ( + "Last provider-reported input", + last_reported + .map(|tokens| format!("{tokens} tokens")) + .unwrap_or_else(|| "Not yet available".to_string()), + ), + ] { + labels.push(render_label_text(label, appearance)); + values.push(render_value_text(value, appearance)); + } + } else if self.usage_info.current_context_tokens > 0 { labels.push(render_label_text("Context tokens", appearance)); values.push(render_value_text( format_token_count(self.usage_info.current_context_tokens), @@ -521,11 +638,22 @@ impl ConversationUsageView { } } - labels.push(render_label_text("Context window used", appearance)); - let context_window_usage = self.usage_info.context_window_usage.clamp(0.0, 1.0); - let context_usage_pct = context_window_usage * 100.; - let context_usage_str = if context_window_breakdown_enabled && self.context_window_expanded - { + labels.push(render_label_text( + if provider_context_usage.is_some() { + "History budget used (estimate)" + } else { + "Context window used" + }, + appearance, + )); + let context_usage_pct = provider_context_usage + .as_ref() + .map(|(usage, _)| usage.compaction_usage()) + .unwrap_or(context_window_usage) + * 100.; + let context_usage_str = if provider_context_usage.is_some() { + format!("~{context_usage_pct:.1}%") + } else if context_window_breakdown_enabled && self.context_window_expanded { format!("{context_usage_pct:.2}%") } else { format!("{}%", context_usage_pct.round()) diff --git a/app/src/ai/runtime/event_translator_tests.rs b/app/src/ai/runtime/event_translator_tests.rs index bba27bfd..0c66fd04 100644 --- a/app/src/ai/runtime/event_translator_tests.rs +++ b/app/src/ai/runtime/event_translator_tests.rs @@ -728,6 +728,7 @@ fn authentication_failures_identify_the_requested_transport_before_stream_start( ] { projector .project(ProviderRunProjection::ModelTurnRequested { + context_usage: None, work_id: galaxy_agent_core::ExternalWorkId { run_id: galaxy_agent_core::ProviderRunId::new("run"), epoch: galaxy_agent_core::RunEpoch::new(1), diff --git a/app/src/ai/runtime/mod.rs b/app/src/ai/runtime/mod.rs index 8236accb..0c9e0634 100644 --- a/app/src/ai/runtime/mod.rs +++ b/app/src/ai/runtime/mod.rs @@ -13,9 +13,10 @@ pub(crate) use event_translator::{ ProviderRunResponseProjector, RuntimeResponseConfig, RuntimeResponseTranslator, }; pub(crate) use provider_run_coordinator::{ - ProviderRunBlock, ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection, - ProviderToolExecutionRef, ProviderToolLifecycleOutcome, BASE_PROVIDER_PROFILE, - CLI_MONITOR_PROVIDER_PROFILE, COMPUTER_USE_PROVIDER_PROFILE, + estimated_context_message_tokens, ProviderContextUsage, ProviderRunBlock, + ProviderRunCoordinator, ProviderRunProfile, ProviderRunProjection, ProviderToolExecutionRef, + ProviderToolLifecycleOutcome, BASE_PROVIDER_PROFILE, CLI_MONITOR_PROVIDER_PROFILE, + COMPUTER_USE_PROVIDER_PROFILE, PROGRESSIVE_SUMMARY_TRIGGER_USAGE, }; pub(crate) use rig::{ prepare_provider_run, provider_context_window_tokens, provider_runtime_for_request, diff --git a/app/src/ai/runtime/provider_run_coordinator.rs b/app/src/ai/runtime/provider_run_coordinator.rs index efc035ea..40889d09 100644 --- a/app/src/ai/runtime/provider_run_coordinator.rs +++ b/app/src/ai/runtime/provider_run_coordinator.rs @@ -25,6 +25,7 @@ use crate::ai::model_output_logging; pub(crate) const BASE_PROVIDER_PROFILE: &str = "base"; pub(crate) const CLI_MONITOR_PROVIDER_PROFILE: &str = "cli-monitor"; pub(crate) const COMPUTER_USE_PROVIDER_PROFILE: &str = "computer-use"; +pub(crate) const PROGRESSIVE_SUMMARY_TRIGGER_USAGE: f32 = 0.70; const PROVIDER_MODEL_START_TIMEOUT: Duration = Duration::from_secs(120); const PROVIDER_MODEL_EVENT_IDLE_TIMEOUT: Duration = Duration::from_secs(90); const PROVIDER_MODEL_RETRY_DELAYS: [Duration; 3] = [ @@ -33,6 +34,99 @@ const PROVIDER_MODEL_RETRY_DELAYS: [Duration; 3] = [ Duration::from_secs(5), ]; +/// Estimated occupancy of the same bounded request projection sent to a provider. +/// The durable transcript remains complete; this snapshot contains no message content. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct ProviderContextProfileUsage { + pub(crate) profile: String, + pub(crate) model_id: String, + pub(crate) context_limit: u32, + pub(crate) estimated_input_tokens: u32, + pub(crate) fixed_input_tokens: u32, + pub(crate) reserved_output_tokens: u32, + pub(crate) history_tokens: u32, + pub(crate) history_capacity_tokens: u32, +} + +impl ProviderContextProfileUsage { + pub(crate) fn input_usage(&self) -> f32 { + self.estimated_input_tokens as f32 / self.context_limit.max(1) as f32 + } + + pub(crate) fn total_reserved_tokens(&self) -> u32 { + self.estimated_input_tokens + .saturating_add(self.reserved_output_tokens) + } + + fn with_transcript(&self, messages: &[ConversationMessage]) -> Self { + let messages = project_messages_for_context(messages, Some(self.context_limit)); + let text_bytes = messages + .iter() + .map(estimate_conversation_message_bytes) + .sum::(); + let image_tokens = messages + .iter() + .map(|message| count_images_in_content(&message.content)) + .sum::() + .saturating_mul(ESTIMATED_IMAGE_TOKENS); + let history_tokens = (text_bytes as u64) + .div_ceil(ESTIMATED_BYTES_PER_TOKEN) + .saturating_add(image_tokens); + let mut usage = self.clone(); + usage.history_tokens = u32::try_from(history_tokens).unwrap_or(u32::MAX); + // Without retaining the fixed prompt itself, rounding its token estimate + // separately from history can differ from the next full estimate by one token. + usage.estimated_input_tokens = usage + .fixed_input_tokens + .saturating_add(usage.history_tokens); + usage + } +} + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub(crate) struct ProviderContextUsage { + pub(crate) active: ProviderContextProfileUsage, + pub(crate) compaction: ProviderContextProfileUsage, + #[serde(default)] + pub(crate) monitor: Option, +} + +impl ProviderContextUsage { + pub(crate) fn compaction_usage(&self) -> f32 { + self.compaction.history_tokens as f32 + / self.compaction.history_capacity_tokens.max(1) as f32 + } + + pub(crate) fn with_transcript(&self, messages: &[ConversationMessage]) -> Self { + let active = self.active.with_transcript(messages); + let monitor = self + .monitor + .as_ref() + .map(|usage| usage.with_transcript(messages)); + let compaction = controlling_context_profile(&active, monitor.as_ref()); + Self { + active, + compaction, + monitor, + } + } +} + +fn controlling_context_profile( + active: &ProviderContextProfileUsage, + monitor: Option<&ProviderContextProfileUsage>, +) -> ProviderContextProfileUsage { + monitor + .filter(|usage| { + // Profiles bound individual outputs differently. Compare actual + // projected pressure rather than only the available capacity. + u64::from(usage.history_tokens) * u64::from(active.history_capacity_tokens.max(1)) + > u64::from(active.history_tokens) * u64::from(usage.history_capacity_tokens.max(1)) + }) + .unwrap_or(active) + .clone() +} + #[derive(Clone, Debug, PartialEq)] pub(crate) enum ProviderRunProjection { ModelTurnRequested { @@ -41,6 +135,7 @@ pub(crate) enum ProviderRunProjection { runtime_id: String, model_id: String, retry_attempt: u32, + context_usage: Option, }, ModelTurnStarted { work_id: ExternalWorkId, @@ -267,33 +362,66 @@ impl ProviderRunCoordinator { pub(crate) fn progressive_summary_context_budget(&self) -> Option<(u32, u32)> { [self.run.profile().as_str(), CLI_MONITOR_PROVIDER_PROFILE] .into_iter() - .filter_map(|name| { - let profile = self.profiles.get(name)?; - let limit = profile.max_context_tokens.or(self.max_context_tokens)?; - let mut request = profile.request.clone(); - request.prompt = None; - request.messages.clear(); - add_task_list_context(&mut request, self.run.task_list()); - let fixed = u32::try_from(estimate_turn_request_tokens(&request)) - .unwrap_or(u32::MAX); - Some((limit, fixed)) + .filter_map(|name| self.context_profile_usage(name)) + .min_by_key(|usage| usage.history_capacity_tokens) + .map(|usage| { + ( + usage.context_limit, + usage + .fixed_input_tokens + .saturating_add(usage.reserved_output_tokens), + ) }) - .min_by_key(|(limit, fixed)| limit.saturating_sub(*fixed)) + } + + /// Shared by compaction decisions and the context display. Each profile is + /// projected with its own output bounds before measuring its usable history. + pub(crate) fn context_usage_snapshot(&self) -> Option { + let active = self.context_profile_usage(self.run.profile().as_str())?; + let monitor = self.context_profile_usage(CLI_MONITOR_PROVIDER_PROFILE); + let compaction = controlling_context_profile(&active, monitor.as_ref()); + Some(ProviderContextUsage { + active, + compaction, + monitor, + }) + } + + /// Retention can use these projected message sizes while preserving the same + /// indices into the complete durable transcript for prefix validation. + pub(crate) fn projected_transcript_for_context( + &self, + profile: &str, + ) -> Option> { + Some(self.projected_request_for_context(profile)?.messages) + } + + fn projected_request_for_context(&self, name: &str) -> Option { + let profile = self.profiles.get(name)?; + Some(project_request_for_messages( + profile.request.clone(), + self.run.transcript(), + profile.max_context_tokens.or(self.max_context_tokens), + self.run.task_list(), + )) + } + + pub(crate) fn context_profile_usage(&self, name: &str) -> Option { + let profile = self.profiles.get(name)?; + let limit = profile.max_context_tokens.or(self.max_context_tokens)?; + let request = self.projected_request_for_context(name)?; + Some(context_profile_usage(name, limit, &request)) } /// Includes the current transcript, fixed request context, tools, and output reservation. /// Read at the model boundary so newly committed tool output is included. pub(crate) fn estimated_context_tokens(&self) -> Option<(u32, u32)> { - let profile = self.profiles.get(self.run.profile().as_str())?; - let mut request = profile.request.clone(); - request.prompt = None; - request.messages.clear(); - add_task_list_context(&mut request, self.run.task_list()); - let persistent_tokens = estimate_turn_request_tokens(&request); - request.messages = self.run.transcript().to_vec(); + let request = self.projected_request_for_context(self.run.profile().as_str())?; + let mut fixed_request = request.clone(); + fixed_request.messages.clear(); Some(( u32::try_from(estimate_turn_request_tokens(&request)).unwrap_or(u32::MAX), - u32::try_from(persistent_tokens).unwrap_or(u32::MAX), + u32::try_from(estimate_turn_request_tokens(&fixed_request)).unwrap_or(u32::MAX), )) } @@ -516,7 +644,10 @@ impl ProviderRunCoordinator { // Provider tokenizers and request envelopes differ. Compact before the // estimate reaches the advertised limit, leaving room for that uncertainty. let safe_context_tokens = u64::from(max_context_tokens) * 9 / 10; - estimate_turn_request_tokens(&request) >= safe_context_tokens + u64::from( + context_profile_usage(call.profile.as_str(), max_context_tokens, &request) + .total_reserved_tokens(), + ) >= safe_context_tokens } async fn checkpoint_or_fail( @@ -585,6 +716,7 @@ impl ProviderRunCoordinator { runtime_id: runtime_id.clone(), model_id: model_id.clone(), retry_attempt: call.retry_attempt, + context_usage: self.context_usage_snapshot(), }, project, ) @@ -1185,21 +1317,68 @@ fn validate_profile_runtime( } fn request_for_model_call( - mut template: TurnRequest, + template: TurnRequest, call: &ProviderModelCall, max_context_tokens: Option, task_list: Option<&TaskListSnapshot>, +) -> TurnRequest { + project_request_for_messages(template, &call.messages, max_context_tokens, task_list) +} + +fn project_request_for_messages( + mut template: TurnRequest, + messages: &[ConversationMessage], + max_context_tokens: Option, + task_list: Option<&TaskListSnapshot>, ) -> TurnRequest { template.prompt = None; - template.messages = call.messages.clone(); - for message in &mut template.messages { + template.messages = project_messages_for_context(messages, max_context_tokens); + add_task_list_context(&mut template, task_list); + add_exploration_reminder(&mut template); + template +} + +fn project_messages_for_context( + messages: &[ConversationMessage], + max_context_tokens: Option, +) -> Vec { + let mut messages = messages.to_vec(); + for message in &mut messages { message.truncate_tool_results_for_provider_request_with_context(max_context_tokens); message .truncate_tagged_terminal_output_for_provider_request_with_context(max_context_tokens); } - add_task_list_context(&mut template, task_list); - add_exploration_reminder(&mut template); - template + messages +} + +fn context_profile_usage( + profile: &str, + context_limit: u32, + request: &TurnRequest, +) -> ProviderContextProfileUsage { + let reserved_output_tokens = request.max_output_tokens.unwrap_or_default(); + let estimated_input_tokens = + estimate_turn_request_tokens(request).saturating_sub(reserved_output_tokens); + let mut fixed_request = request.clone(); + fixed_request.messages.clear(); + let fixed_input_tokens = + estimate_turn_request_tokens(&fixed_request).saturating_sub(reserved_output_tokens); + ProviderContextProfileUsage { + profile: profile.to_owned(), + model_id: request.model.as_str().to_owned(), + context_limit, + estimated_input_tokens: u32::try_from(estimated_input_tokens).unwrap_or(u32::MAX), + fixed_input_tokens: u32::try_from(fixed_input_tokens).unwrap_or(u32::MAX), + reserved_output_tokens: u32::try_from(reserved_output_tokens).unwrap_or(u32::MAX), + history_tokens: u32::try_from(estimated_input_tokens.saturating_sub(fixed_input_tokens)) + .unwrap_or(u32::MAX), + history_capacity_tokens: u32::try_from( + u64::from(context_limit) + .saturating_sub(fixed_input_tokens) + .saturating_sub(reserved_output_tokens), + ) + .unwrap_or(u32::MAX), + } } /// A transient prompt reminder, never a limit or a synthetic tool result. It is @@ -1271,6 +1450,17 @@ const ESTIMATED_BYTES_PER_TOKEN: u64 = 3; const ESTIMATED_REQUEST_OVERHEAD_TOKENS: u64 = 512; const ESTIMATED_IMAGE_TOKENS: u64 = 1_600; +/// Per-message history estimate from the same bounded projection used by requests. +/// Rounding each message separately is conservative by at most one token per message. +pub(crate) fn estimated_context_message_tokens(message: &ConversationMessage) -> u32 { + let tokens = (estimate_conversation_message_bytes(message) as u64) + .div_ceil(ESTIMATED_BYTES_PER_TOKEN) + .saturating_add( + count_images_in_content(&message.content).saturating_mul(ESTIMATED_IMAGE_TOKENS), + ); + u32::try_from(tokens).unwrap_or(u32::MAX) +} + /// Heuristic estimate, not an exact tokenizer count or provider-specific bound. /// UTF-8 bytes account for non-ASCII content better than character counts; raw /// image payload bytes are excluded from text accounting and charged separately. diff --git a/app/src/ai/runtime/provider_run_coordinator_tests.rs b/app/src/ai/runtime/provider_run_coordinator_tests.rs index b535d26e..9bb66205 100644 --- a/app/src/ai/runtime/provider_run_coordinator_tests.rs +++ b/app/src/ai/runtime/provider_run_coordinator_tests.rs @@ -1900,6 +1900,152 @@ async fn final_model_request_bounds_new_tool_results_to_the_active_context() { assert_eq!(content, &original_content); } +#[tokio::test] +async fn context_usage_snapshot_matches_the_bounded_model_request() { + let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()])); + let original_content = "x".repeat(20_000); + let mut turn_request = request(); + turn_request.max_output_tokens = Some(500); + turn_request.messages = vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "large-result".to_string(), + content: original_content.clone(), + is_error: false, + }, + }]; + let mut coordinator = ProviderRunCoordinator::from_request( + "projected-context-snapshot", + ProviderRunProfile::new(runtime.clone(), turn_request).with_max_context_tokens(Some(8_000)), + Vec::new(), + ProviderRunLimits::default(), + ) + .unwrap(); + let snapshot = coordinator.context_usage_snapshot().unwrap(); + + // The complete result would exceed the early-compaction threshold, but the + // bounded request fits comfortably. Occupancy must describe that request. + assert!(snapshot.compaction_usage() < 0.70); + assert_eq!(snapshot.active.reserved_output_tokens, 500); + let MessageContent::ToolResult { content, .. } = &coordinator.run().transcript()[0].content + else { + panic!("expected original tool result"); + }; + assert_eq!(content, &original_content); + + let (_sender, control) = turn_control(); + let block = coordinator + .drive_until_blocked(control, |_| Ok(())) + .await + .unwrap(); + assert!(matches!(block, ProviderRunBlock::AwaitingDriver { .. })); + let requests = runtime.requests(); + assert_eq!( + u64::from(snapshot.active.total_reserved_tokens()), + estimate_turn_request_tokens(&requests[0]) + ); + assert_eq!( + snapshot.active.estimated_input_tokens, + snapshot + .active + .total_reserved_tokens() + .saturating_sub(snapshot.active.reserved_output_tokens) + ); +} + +#[test] +fn context_usage_snapshot_projects_history_for_the_limiting_profile() { + let runtime = Arc::new(ScriptedRuntime::new(vec![])); + let mut turn_request = request(); + turn_request.messages = vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "large-result".to_string(), + content: "x".repeat(20_000), + is_error: false, + }, + }]; + let mut coordinator = ProviderRunCoordinator::from_request( + "limiting-profile-context-snapshot", + ProviderRunProfile::new(runtime.clone(), turn_request) + .with_max_context_tokens(Some(100_000)), + Vec::new(), + ProviderRunLimits::default(), + ) + .unwrap(); + coordinator + .insert_profile( + CLI_MONITOR_PROVIDER_PROFILE, + ProviderRunProfile::new(runtime, request()).with_max_context_tokens(Some(8_000)), + ) + .unwrap(); + + let snapshot = coordinator.context_usage_snapshot().unwrap(); + + assert_eq!(snapshot.active.context_limit, 100_000); + assert_eq!(snapshot.compaction.profile, CLI_MONITOR_PROVIDER_PROFILE); + assert_eq!(snapshot.compaction.context_limit, 8_000); + assert!(snapshot.active.history_tokens > snapshot.compaction.history_tokens); + assert!(snapshot.compaction_usage() < 0.70); + assert_eq!( + snapshot.compaction.history_capacity_tokens, + 8_000 - snapshot.compaction.fixed_input_tokens + ); +} + +#[test] +fn compaction_pressure_uses_projected_history_as_well_as_profile_capacity() { + let runtime = Arc::new(ScriptedRuntime::new(vec![])); + let mut turn_request = request(); + turn_request.max_output_tokens = Some(70_000); + turn_request.messages = vec![ConversationMessage { + role: MessageRole::User, + content: MessageContent::ToolResult { + tool_use_id: "large-result".to_string(), + content: "x".repeat(100_000), + is_error: false, + }, + }]; + let mut coordinator = ProviderRunCoordinator::from_request( + "profile-pressure-order", + ProviderRunProfile::new(runtime.clone(), turn_request) + .with_max_context_tokens(Some(100_000)), + Vec::new(), + ProviderRunLimits::default(), + ) + .unwrap(); + coordinator + .insert_profile( + CLI_MONITOR_PROVIDER_PROFILE, + ProviderRunProfile::new(runtime, request()).with_max_context_tokens(Some(8_000)), + ) + .unwrap(); + + let snapshot = coordinator.context_usage_snapshot().unwrap(); + let monitor = coordinator + .context_profile_usage(CLI_MONITOR_PROVIDER_PROFILE) + .unwrap(); + + // The smaller monitor clips the result much more aggressively, so its + // smaller capacity must not hide compaction pressure in the active model. + assert!(snapshot.active.history_capacity_tokens > monitor.history_capacity_tokens); + assert_eq!(snapshot.compaction.profile, BASE_PROVIDER_PROFILE); + assert!(snapshot.compaction_usage() >= 0.70); + assert!(monitor.history_tokens as f32 / (monitor.history_capacity_tokens as f32) < 0.70); + assert_eq!( + coordinator.progressive_summary_context_budget().unwrap().0, + 8_000 + ); + + let restored: ProviderContextUsage = + serde_json::from_str(&serde_json::to_string(&snapshot).unwrap()).unwrap(); + let compacted = restored.with_transcript(&[ConversationMessage { + role: MessageRole::User, + content: MessageContent::Text("Task checkpoint".to_string()), + }]); + assert_eq!(compacted.compaction.profile, CLI_MONITOR_PROVIDER_PROFILE); +} + #[tokio::test] async fn preflight_leaves_headroom_before_the_advertised_context_limit() { let runtime = Arc::new(ScriptedRuntime::new(vec![answer_turn()])); diff --git a/app/src/workspaces/gql_convert.rs b/app/src/workspaces/gql_convert.rs index ec509fab..d959a5c4 100644 --- a/app/src/workspaces/gql_convert.rs +++ b/app/src/workspaces/gql_convert.rs @@ -263,6 +263,8 @@ impl From<&gql_usage::ConversationUsage> for ConversationUsageInfo { lines_removed: tool.apply_file_diff_stats.lines_removed, commands_executed: tool.run_command_stats.commands_executed, current_context_tokens: 0, + provider_context_usage: None, + latest_model_call_reported_context_tokens: None, estimated_cost_cents: 0.0, total_cache_read_tokens: 0, total_cache_write_tokens: 0,