diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index 168be7677e4f45..75fb004ed3ae7e 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -2516,8 +2516,7 @@ impl Thread { cx: &mut Context, ) -> Result>> { let model = self - .model() - .cloned() + .compaction_model(cx) .ok_or_else(|| anyhow!(NoModelConfiguredError))?; // Flush any pending message and cancel an in-flight turn before we @@ -2530,11 +2529,12 @@ impl Thread { self.advance_prompt_id(); let request = self.build_compaction_request(request_end_ix, &model, cx); self.current_request_token_usage = TokenUsage::default(); - (model, request) + (model.clone(), request) }); if compaction.is_some() { - self.pending_compaction_telemetry = self.build_compaction_telemetry("manual", cx); + self.pending_compaction_telemetry = + self.build_compaction_telemetry("manual", &model, cx); } self.clear_summary(); @@ -3092,13 +3092,14 @@ impl Thread { ) -> Result> { let Some((model, request, insertion_ix)) = this.update(cx, |this, cx| { let insertion_ix = this.compaction_message_target_ix(cx)?; - let model = this.model().cloned()?; + let model = this.compaction_model(cx)?; let request = this.build_compaction_request(insertion_ix, &model, cx); this.current_request_token_usage = TokenUsage::default(); // Preserve telemetry across retries so the retry count keeps // accumulating rather than resetting on each attempt. if this.pending_compaction_telemetry.is_none() { - this.pending_compaction_telemetry = this.build_compaction_telemetry("auto", cx); + this.pending_compaction_telemetry = + this.build_compaction_telemetry("auto", &model, cx); } Some((model, request, insertion_ix)) })? @@ -4318,11 +4319,10 @@ impl Thread { extend_request_history_until(&self.messages, request_messages, end_ix); } - /// Captures the data for an `"Agent Compaction Completed"` telemetry event - /// at the moment a compaction starts. Returns `None` if there's no model. fn build_compaction_telemetry( &self, trigger: &'static str, + compaction_model: &Arc, cx: &App, ) -> Option { let model = self.model()?; @@ -4338,7 +4338,7 @@ impl Thread { parent_thread_id: self.parent_thread_id().map(|id| id.to_string()), prompt_id: self.prompt_id.to_string(), model: model.telemetry_id(), - model_provider: model.provider_id().to_string(), + compaction_model: compaction_model.telemetry_id(), thinking_effort: self.thinking_effort.clone(), max_tokens, tokens_before, @@ -4431,6 +4431,13 @@ impl Thread { Some(self.messages.len()) } + fn compaction_model(&self, cx: &App) -> Option> { + LanguageModelRegistry::read_global(cx) + .compaction_model() + .map(|m| m.model) + .or_else(|| self.model().cloned()) + } + fn build_compaction_request( &self, insertion_ix: usize, @@ -4608,7 +4615,7 @@ struct CompactionTelemetry { parent_thread_id: Option, prompt_id: String, model: String, - model_provider: String, + compaction_model: String, thinking_effort: Option, max_tokens: u64, /// Tokens in the context window immediately before compaction. @@ -4632,7 +4639,7 @@ impl CompactionTelemetry { parent_thread_id = self.parent_thread_id, prompt_id = self.prompt_id, model = self.model, - model_provider = self.model_provider, + compaction_model = self.compaction_model, thinking_effort = self.thinking_effort, max_tokens = self.max_tokens, tokens_before = self.tokens_before, @@ -6670,12 +6677,15 @@ mod tests { use language_model::LanguageModelToolUseId; use language_model::fake_provider::FakeLanguageModel; use serde_json::json; + use settings::LanguageModelProviderSetting; use std::sync::Arc; async fn setup_thread_for_test(cx: &mut TestAppContext) -> (Entity, ThreadEventStream) { cx.update(|cx| { let settings_store = settings::SettingsStore::test(cx); cx.set_global(settings_store); + + LanguageModelRegistry::test(cx); }); let fs = fs::FakeFs::new(cx.background_executor.clone()); @@ -6712,6 +6722,19 @@ mod tests { AgentSettings::override_global(settings, cx); } + fn set_registry_compaction_model(cx: &mut App, model: Option>) { + use language_model::fake_provider::FakeLanguageModelProvider; + use language_model::{ConfiguredModel, LanguageModelProvider}; + LanguageModelRegistry::global(cx).update(cx, |registry, cx| { + let configured = model.map(|m| ConfiguredModel { + provider: Arc::new(FakeLanguageModelProvider::default()) + as Arc, + model: m, + }); + registry.set_compaction_model(configured, cx); + }); + } + #[test] fn test_summary_compaction_renders_for_request_and_markdown() { let message = Message::Compaction(CompactionInfo::Summary("Older context".into())); @@ -7397,6 +7420,183 @@ mod tests { ); } + /// When `agent.compaction_model` is configured, manual `/compact` streams + /// to the configured model rather than the thread's primary model. + #[gpui::test] + async fn test_compaction_uses_configured_compaction_model(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + let compaction_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + set_registry_compaction_model(cx, Some(compaction_model.clone())); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!( + thread_model.pending_completions().len(), + 0, + "thread's primary model should not have been used for compaction" + ); + let request = compaction_model + .pending_completions() + .pop() + .expect("compaction model should have received the request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + thread.read_with(cx, |thread, _cx| { + let telemetry = thread + .pending_compaction_telemetry + .as_ref() + .expect("pending telemetry"); + assert_eq!(telemetry.model, compaction_model.telemetry_id()); + }); + + compaction_model.send_completion_stream_text_chunk(&request, "summary"); + compaction_model.end_completion_stream(&request); + cx.run_until_parked(); + } + + /// When `agent.compaction_model` is configured but doesn't resolve (e.g. + /// the provider isn't registered), manual `/compact` falls back to the + /// thread's primary model and the telemetry reflects the actual stream + /// model — not the one the user tried to configure. + #[gpui::test] + async fn test_compaction_falls_back_when_compaction_model_unavailable(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(ClientUserMessageId::new(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + }); + // Settings say "configured"; registry says "couldn't resolve". + let mut settings = AgentSettings::get_global(cx).clone(); + settings.compaction_model = Some(LanguageModelSelection { + provider: LanguageModelProviderSetting("missing".into()), + model: "missing-model".into(), + enable_thinking: false, + effort: None, + speed: None, + }); + AgentSettings::override_global(settings, cx); + set_registry_compaction_model(cx, None); + }); + + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.compact(ClientUserMessageId::new(), cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + let request = thread_model + .pending_completions() + .pop() + .expect("thread model should have received the fallback request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + thread.read_with(cx, |thread, _cx| { + let telemetry = thread + .pending_compaction_telemetry + .as_ref() + .expect("pending telemetry"); + assert_eq!(telemetry.model, thread_model.telemetry_id()); + }); + + thread_model.send_completion_stream_text_chunk(&request, "summary"); + thread_model.end_completion_stream(&request); + cx.run_until_parked(); + } + + /// Auto-compaction triggered by the threshold also honors + /// `agent.compaction_model`. + #[gpui::test] + async fn test_auto_compaction_uses_compaction_model(cx: &mut TestAppContext) { + let (thread, _event_stream) = setup_thread_for_test(cx).await; + let thread_model = Arc::new(FakeLanguageModel::default()); + let compaction_model = Arc::new(FakeLanguageModel::default()); + let old_user_message_id = ClientUserMessageId::new(); + + cx.update(|cx| { + thread.update(cx, |thread, cx| { + thread.set_model(thread_model.clone(), cx); + thread + .messages + .push(user_text_message(old_user_message_id.clone(), "old user")); + thread.messages.push(agent_text_message("old assistant")); + thread.request_token_usage.insert( + old_user_message_id.clone(), + TokenUsage { + input_tokens: u64::MAX, + ..Default::default() + }, + ); + }); + set_auto_compact_settings( + cx, + agent_settings::AutoCompactSettings { + enabled: true, + threshold: agent_settings::AutoCompactThreshold::Percentage(0.5), + }, + ); + set_registry_compaction_model(cx, Some(compaction_model.clone())); + }); + + // The auto-compact gate fires inside `run_turn` when we kick off a + // new user message. Drive a `send` so `perform_compaction_if_needed` + // runs. + let _events = cx + .update(|cx| { + thread.update(cx, |thread, cx| { + thread.send(ClientUserMessageId::new(), vec!["new prompt"], cx) + }) + }) + .unwrap(); + cx.run_until_parked(); + + assert_eq!(thread_model.pending_completions().len(), 0); + let request = compaction_model + .pending_completions() + .pop() + .expect("compaction model should have received the auto-compaction request"); + assert_eq!( + request.intent, + Some(CompletionIntent::ThreadContextSummarization) + ); + + compaction_model.send_completion_stream_text_chunk(&request, "summary"); + compaction_model.end_completion_stream(&request); + cx.run_until_parked(); + } + #[gpui::test] async fn test_compaction_usage_counts_toward_cumulative_usage(cx: &mut TestAppContext) { let (thread, _event_stream) = setup_thread_for_test(cx).await; diff --git a/crates/agent/src/tool_permissions.rs b/crates/agent/src/tool_permissions.rs index 02198a3f8db415..601c24d73af2c0 100644 --- a/crates/agent/src/tool_permissions.rs +++ b/crates/agent/src/tool_permissions.rs @@ -583,6 +583,7 @@ mod tests { commit_message_include_project_rules: true, commit_message_instructions: None, thread_summary_model: None, + compaction_model: None, inline_alternatives: vec![], favorite_models: vec![], default_profile: AgentProfileId::default(), diff --git a/crates/agent_settings/src/agent_settings.rs b/crates/agent_settings/src/agent_settings.rs index e1cce89d3b0223..534cebcca104dc 100644 --- a/crates/agent_settings/src/agent_settings.rs +++ b/crates/agent_settings/src/agent_settings.rs @@ -219,6 +219,7 @@ pub struct AgentSettings { pub commit_message_include_project_rules: bool, pub commit_message_instructions: Option, pub thread_summary_model: Option, + pub compaction_model: Option, pub inline_alternatives: Vec, pub favorite_models: Vec, pub default_profile: AgentProfileId, @@ -770,6 +771,7 @@ impl Settings for AgentSettings { commit_message_model: agent.commit_message_model, commit_message_instructions: agent.commit_message_instructions, thread_summary_model: agent.thread_summary_model, + compaction_model: agent.compaction_model, inline_alternatives: agent.inline_alternatives.unwrap_or_default(), favorite_models: agent.favorite_models, default_profile: AgentProfileId(agent.default_profile.unwrap()), diff --git a/crates/agent_ui/src/agent_ui.rs b/crates/agent_ui/src/agent_ui.rs index 91ae944e700e1b..1570315f888f2f 100644 --- a/crates/agent_ui/src/agent_ui.rs +++ b/crates/agent_ui/src/agent_ui.rs @@ -929,6 +929,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { .thread_summary_model .as_ref() .map(to_selected_model); + let compaction = settings.compaction_model.as_ref().map(to_selected_model); let inline_alternatives = settings .inline_alternatives .iter() @@ -940,6 +941,7 @@ fn update_active_language_model_from_settings(cx: &mut App) { registry.select_inline_assistant_model(inline_assistant.as_ref(), cx); registry.select_commit_message_model(commit_message.as_ref(), cx); registry.select_thread_summary_model(thread_summary.as_ref(), cx); + registry.select_compaction_model(compaction.as_ref(), cx); registry.select_inline_alternative_models(inline_alternatives, cx); registry.set_should_use_fallback(should_use_fallback); }); @@ -986,6 +988,7 @@ mod tests { commit_message_include_project_rules: true, commit_message_instructions: None, thread_summary_model: None, + compaction_model: None, inline_alternatives: vec![], favorite_models: vec![], default_profile: AgentProfileId::default(), diff --git a/crates/language_model/src/registry.rs b/crates/language_model/src/registry.rs index 28033e482f362b..f9a00a3f460c54 100644 --- a/crates/language_model/src/registry.rs +++ b/crates/language_model/src/registry.rs @@ -53,6 +53,7 @@ pub struct LanguageModelRegistry { inline_assistant_model: Option, commit_message_model: Option, thread_summary_model: Option, + compaction_model: Option, providers: BTreeMap>, inline_alternatives: Vec>, /// Set of installed extension IDs that provide language models. @@ -111,6 +112,7 @@ pub enum Event { DefaultModelChanged, InlineAssistantModelChanged, CommitMessageModelChanged, + CompactionModelChanged, ThreadSummaryModelChanged, ProviderStateChanged(LanguageModelProviderId), AddedProvider(LanguageModelProviderId), @@ -324,6 +326,15 @@ impl LanguageModelRegistry { self.set_thread_summary_model(configured_model, cx); } + pub fn select_compaction_model( + &mut self, + model: Option<&SelectedModel>, + cx: &mut Context, + ) { + let configured_model = model.and_then(|model| self.select_model(model, cx)); + self.set_compaction_model(configured_model, cx); + } + /// Selects and sets the inline alternatives for language models based on /// provider name and id. pub fn select_inline_alternative_models( @@ -436,6 +447,15 @@ impl LanguageModelRegistry { self.thread_summary_model = model; } + pub fn set_compaction_model(&mut self, model: Option, cx: &mut Context) { + match (self.compaction_model.as_ref(), model.as_ref()) { + (Some(old), Some(new)) if old.is_same_as(new) => {} + (None, None) => {} + _ => cx.emit(Event::CompactionModelChanged), + } + self.compaction_model = model; + } + pub fn default_model(&self) -> Option { #[cfg(debug_assertions)] if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { @@ -495,6 +515,18 @@ impl LanguageModelRegistry { .or_else(|| self.default_model()) } + /// Returns the configured compaction model without falling back through + /// `default_fast_model`/`default_model`. Callers that want a fallback to + /// the thread's primary model should handle `None` themselves. + pub fn compaction_model(&self) -> Option { + #[cfg(debug_assertions)] + if std::env::var("ZED_SIMULATE_NO_LLM_PROVIDER").is_ok() { + return None; + } + + self.compaction_model.clone() + } + /// The models to use for inline assists. Returns the union of the active /// model and all inline alternatives. When there are multiple models, the /// user will be able to cycle through results. diff --git a/crates/settings_content/src/agent.rs b/crates/settings_content/src/agent.rs index 460714c4622d6f..a365f675b14579 100644 --- a/crates/settings_content/src/agent.rs +++ b/crates/settings_content/src/agent.rs @@ -258,6 +258,11 @@ pub struct AgentSettingsContent { pub commit_message_instructions: Option, /// Model to use for generating thread summaries. Defaults to default_model when not specified. pub thread_summary_model: Option, + /// Model to use for context compaction (`/compact` and auto-compaction). + /// Falls back to the thread's currently selected model when not specified. + /// If the configured model is unavailable (provider not registered, model + /// not found), the thread's current model is used instead. + pub compaction_model: Option, /// Additional models with which to generate alternatives when performing inline assists. pub inline_alternatives: Option>, /// The default profile to use in the Agent. diff --git a/docs/src/ai/agent-settings.md b/docs/src/ai/agent-settings.md index 6c29cc6dd102ab..406695f0d8afa8 100644 --- a/docs/src/ai/agent-settings.md +++ b/docs/src/ai/agent-settings.md @@ -38,6 +38,7 @@ Some Zed AI features have their own model or prompt settings in `settings.json`, - `agent.inline_assistant_model` - `agent.commit_message_model` - `agent.thread_summary_model` +- `agent.compaction_model` - `agent.subagent_model` - `agent.commit_message_instructions` - `agent.inline_alternatives` @@ -83,6 +84,29 @@ The `threshold` value can be one of: You can compact a Zed Agent thread manually at any time by typing `/compact` in the Agent Panel message editor. For more on thread token usage and compaction behavior, see [Token Usage and Compaction](./agent-panel.md#token-usage). +## Compaction Model {#compaction-model} + +By default, context compaction (both `/compact` and auto-compaction) uses the thread's currently selected model. Set `agent.compaction_model` to use a different model: + +```json [settings] +{ + "agent": { + "default_model": { + "provider": "anthropic", + "model": "claude-opus-4-6" + }, + "compaction_model": { + "provider": "anthropic", + "model": "claude-sonnet-4-5" + } + } +} +``` + +**Notes:** + +- The configured model should have a context window at least as large as the thread's primary model for predictable behavior. + ## External Agents {#external-agents} The External Agents section configures ACP-integrated agents.