From 596b7266a4ae4a2aca27151967fca2d0d75ebc58 Mon Sep 17 00:00:00 2001 From: Ryan Ward Date: Sun, 25 Oct 2026 01:31:39 -0500 Subject: [PATCH] Separate model reasoning selections and reduce agent notification noise --- .../agent_management_model.rs | 43 ++++-- .../agent_management_model_tests.rs | 30 ++++ app/src/ai/crosscheck/reviewer.rs | 6 +- app/src/ai/execution_profiles/editor/mod.rs | 105 ++++++++++++- .../ai/execution_profiles/editor/mod_tests.rs | 53 +++++++ .../execution_profiles/editor/ui_helpers.rs | 36 ++++- .../ai/execution_profiles/model_menu_items.rs | 55 ++++++- app/src/ai/llms.rs | 122 +++++++++++---- app/src/ai/llms_tests.rs | 103 +++++++++++++ app/src/ai/openai/client.rs | 7 +- app/src/ai/runtime/rig.rs | 34 ++-- app/src/settings/ai.rs | 2 +- app/src/terminal/input/models/data_source.rs | 15 +- app/src/terminal/profile_model_selector.rs | 47 ++++-- app/src/terminal/view.rs | 26 +++- crates/galaxy_agent_rig/src/native.rs | 145 +++++++++++++++--- crates/galaxy_agent_rig/src/native_tests.rs | 61 +++++++- .../galaxy_agent_rig/src/openai_compatible.rs | 24 ++- .../src/openai_compatible_tests.rs | 53 +++++-- 19 files changed, 829 insertions(+), 138 deletions(-) diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index d92d0962..4d995bbe 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -249,7 +249,7 @@ impl AgentNotificationsModel { terminal_surface_id, conversation_id, // We shouldn't trigger toasts when restoring conversations on startup. - update: ConversationStatusUpdate::Changed { .. }, + update: ConversationStatusUpdate::Changed { prev_status }, .. } = event else { @@ -266,6 +266,21 @@ impl AgentNotificationsModel { } let status = updated_conversation.status().clone(); + if &status == prev_status { + return; + } + // Workers report their results to the orchestrator. Only a worker that + // needs user input should interrupt the user independently. + if updated_conversation.is_child_agent_conversation() + && !status.should_notify_for_agent(true) + { + self.pending_artifacts.remove(conversation_id); + self.remove_notification_by_source( + NotificationOrigin::Conversation(*conversation_id), + ctx, + ); + return; + } let latest_query = updated_conversation.latest_user_query(); if FeatureFlag::HOANotifications.is_enabled() { self.handle_history_event_for_mailbox( @@ -283,6 +298,12 @@ impl AgentNotificationsModel { return; } + if matches!(status, ConversationStatus::Success) + && QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(*conversation_id) + { + return; + } + if is_terminal_view_visible(*terminal_surface_id, ctx) { return; } @@ -354,18 +375,10 @@ impl AgentNotificationsModel { ); } ConversationStatus::Cancelled => { - let artifacts = self.flush_pending_artifacts(conversation_id); - self.add_notification( - title, - "Task was cancelled.".to_owned(), - NotificationCategory::Complete, - oz_agent, - origin, - terminal_view_id, - artifacts, - metadata.branch, - ctx, - ); + // Steering and replacing a turn cancels the old generation; + // that is not a separate task-completion notification. + self.pending_artifacts.remove(&conversation_id); + self.remove_notification_by_source(origin, ctx); } ConversationStatus::Blocked { blocked_action } => { self.add_notification( @@ -484,6 +497,10 @@ pub enum AgentManagementEvent { } impl ConversationStatus { + pub(crate) fn should_notify_for_agent(&self, is_child: bool) -> bool { + self.should_trigger_notification() && (!is_child || self.is_blocked()) + } + /// Returns true if the updating the conversation with this status should trigger some /// notification to the user. /// diff --git a/app/src/ai/agent_management/agent_management_model_tests.rs b/app/src/ai/agent_management/agent_management_model_tests.rs index f2de3f1f..f282c567 100644 --- a/app/src/ai/agent_management/agent_management_model_tests.rs +++ b/app/src/ai/agent_management/agent_management_model_tests.rs @@ -297,6 +297,36 @@ fn should_trigger_notification_returns_false_for_cancelled() { assert!(!ConversationStatus::Cancelled.should_trigger_notification()); } +#[test] +fn worker_results_do_not_interrupt_the_orchestrator() { + for status in [ConversationStatus::Success, ConversationStatus::Error] { + assert!(!status.should_notify_for_agent(true)); + assert!(status.should_notify_for_agent(false)); + } +} + +#[test] +fn worker_permission_requests_still_notify() { + let status = ConversationStatus::Blocked { + blocked_action: "Approve deployment".to_owned(), + }; + assert!(status.should_notify_for_agent(true)); + assert!(status.should_notify_for_agent(false)); +} + +#[test] +fn running_recovering_and_cancelled_agents_do_not_notify() { + for status in [ + ConversationStatus::InProgress, + ConversationStatus::TransientError, + ConversationStatus::WaitingForEvents, + ConversationStatus::Cancelled, + ] { + assert!(!status.should_notify_for_agent(true)); + assert!(!status.should_notify_for_agent(false)); + } +} + // Mailbox suppression for non-terminal status updates. In App::test the // `is_conversation_open` gate always returns false, so the // WaitingForEvents and InProgress arms both clear stale notifications diff --git a/app/src/ai/crosscheck/reviewer.rs b/app/src/ai/crosscheck/reviewer.rs index 2e6bbcca..332fcacb 100644 --- a/app/src/ai/crosscheck/reviewer.rs +++ b/app/src/ai/crosscheck/reviewer.rs @@ -268,7 +268,7 @@ impl CrosscheckReviewer { let effective_model = config.model.unwrap_or(model_id); - let request_body = serde_json::json!({ + let mut request_body = serde_json::json!({ "model": effective_model, "messages": [ { @@ -286,6 +286,10 @@ impl CrosscheckReviewer { "stream": false }); + if let Some(effort) = config.reasoning_effort { + request_body["reasoning_effort"] = effort.into(); + } + // Use a non-streaming request by calling the same endpoint but with stream: false let url = format!("{}/chat/completions", config.base_url.trim_end_matches('/')); diff --git a/app/src/ai/execution_profiles/editor/mod.rs b/app/src/ai/execution_profiles/editor/mod.rs index 32caea06..dd7377c3 100644 --- a/app/src/ai/execution_profiles/editor/mod.rs +++ b/app/src/ai/execution_profiles/editor/mod.rs @@ -19,7 +19,9 @@ use warpui::{ }; use crate::ai::blocklist::BlocklistAIPermissions; -use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; +use crate::ai::execution_profiles::model_menu_items::{ + available_model_menu_items, collapse_model_reasoning_choices, reasoning_choices_for_model, +}; use crate::ai::execution_profiles::profiles::{ AIExecutionProfilesModel, AIExecutionProfilesModelEvent, ClientProfileId, }; @@ -34,6 +36,7 @@ use crate::cloud_object::model::generic_string_model::StringModel; use crate::editor::{ EditorView, Event as EditorEvent, InteractionState, SingleLineEditorOptions, TextOptions, }; +use crate::menu::{MenuItem, MenuItemFields, MenuTooltipPosition}; use crate::pane_group::focus_state::PaneFocusHandle; use crate::pane_group::pane::view; use crate::pane_group::{BackingView, PaneConfiguration, PaneEvent}; @@ -132,6 +135,13 @@ pub enum ExecutionProfileEditorViewEvent { Pane(PaneEvent), } +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProfileModelRole { + Base, + FullTerminalUse, + ComputerUse, +} + #[derive(Debug, Clone, PartialEq)] pub enum ExecutionProfileEditorViewAction { Save, @@ -139,6 +149,10 @@ pub enum ExecutionProfileEditorViewAction { SetBaseModel { id: LLMId, }, + SetModelReasoning { + role: ProfileModelRole, + id: LLMId, + }, /// Fired continuously while the user drags the context window slider. ContextWindowSliderDragged { value: u32, @@ -198,6 +212,10 @@ pub struct ExecutionProfileEditorView { full_terminal_use_model_dropdown: ViewHandle>, computer_use_model_dropdown: ViewHandle>, + base_reasoning_dropdown: ViewHandle>, + full_terminal_reasoning_dropdown: ViewHandle>, + computer_use_reasoning_dropdown: ViewHandle>, + reasoning_roles: Vec, permission_mode_dropdown: ViewHandle>, command_denylist_editor: ViewHandle, command_allowlist_editor: ViewHandle, @@ -301,6 +319,9 @@ impl ExecutionProfileEditorView { dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); dropdown }); + let base_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new); + let full_terminal_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new); + let computer_use_reasoning_dropdown = ctx.add_typed_action_view(Dropdown::new); let command_allowlist_editor = ctx.add_typed_action_view(|ctx| { let mut input = SubmittableTextInput::new(ctx).validate_on_edit(|s| Regex::new(s).is_ok()); input.set_placeholder_text("e.g. ^cargo test$", ctx); @@ -361,6 +382,10 @@ impl ExecutionProfileEditorView { coding_model_dropdown, full_terminal_use_model_dropdown, computer_use_model_dropdown, + base_reasoning_dropdown, + full_terminal_reasoning_dropdown, + computer_use_reasoning_dropdown, + reasoning_roles: Vec::new(), permission_mode_dropdown, command_denylist_editor, command_allowlist_editor, @@ -482,6 +507,7 @@ impl ExecutionProfileEditorView { ); } } + me.refresh_reasoning_dropdowns(ctx); }); // Refresh model dropdowns when BYO API keys update so key icons reflect current state. @@ -506,6 +532,7 @@ impl ExecutionProfileEditorView { ctx, ); me.sync_context_window_editor(ctx, false); + me.refresh_reasoning_dropdowns(ctx); ctx.notify(); }, ); @@ -647,6 +674,62 @@ impl ExecutionProfileEditorView { Self::update_profile_name_editor(&self.profile_name_editor, ¤t_permissions, ctx); self.sync_context_window_editor(ctx, false); + self.refresh_reasoning_dropdowns(ctx); + } + + fn refresh_reasoning_dropdowns(&mut self, ctx: &mut ViewContext) { + let profile = BlocklistAIPermissions::as_ref(ctx) + .permissions_profile_for_id(ctx, self.profile_id); + self.reasoning_roles.clear(); + for role in [ProfileModelRole::Base, ProfileModelRole::FullTerminalUse, ProfileModelRole::ComputerUse] { + let prefs = LLMPreferences::as_ref(ctx); + let (id, choices, dropdown) = match role { + ProfileModelRole::Base => ( + profile.base_model.clone().unwrap_or_else(|| prefs.get_default_base_model().id.clone()), + prefs.get_base_llm_choices_for_agent_mode(ctx).collect_vec(), + &self.base_reasoning_dropdown, + ), + ProfileModelRole::FullTerminalUse => ( + profile.cli_agent_model.clone().unwrap_or_else(|| prefs.get_default_cli_agent_model().id.clone()), + prefs.get_cli_agent_llm_choices(ctx).collect_vec(), + &self.full_terminal_reasoning_dropdown, + ), + ProfileModelRole::ComputerUse => ( + profile.computer_use_model.clone().unwrap_or_else(|| prefs.get_default_computer_use_model().id.clone()), + prefs.get_computer_use_llm_choices(ctx).collect_vec(), + &self.computer_use_reasoning_dropdown, + ), + }; + let id = prefs.get_llm_info(&id).map_or(id.clone(), |model| model.id.clone()); + let reasoning_choices = reasoning_choices_for_model(&id, &choices); + let items: Vec> = reasoning_choices.iter().map(|model| { + let mut fields = MenuItemFields::new( + model.reasoning_level.clone().unwrap_or_else(|| "Provider default".to_owned()), + ) + .with_on_select_action(DropdownAction::select_action_and_close( + ExecutionProfileEditorViewAction::SetModelReasoning { role, id: model.id.clone() }, + )) + .with_disabled(model.disable_reason.is_some()); + if let Some(reason) = &model.disable_reason { + fields = fields.with_tooltip(reason.tooltip_text()) + .with_tooltip_position(MenuTooltipPosition::Above); + } + fields.into_item() + }).collect(); + if !reasoning_choices.is_empty() { + self.reasoning_roles.push(role); + } + dropdown.update(ctx, |dropdown, ctx| { + dropdown.set_rich_items(items, ctx); + dropdown.set_selected_by_action(ExecutionProfileEditorViewAction::SetModelReasoning { role, id }, ctx); + if AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { + dropdown.set_enabled(ctx); + } else { + dropdown.set_disabled(ctx); + } + }); + } + ctx.notify(); } fn refresh_filterable_model_dropdown( @@ -673,7 +756,10 @@ impl ExecutionProfileEditorView { let llm_prefs = LLMPreferences::handle(ctx); let llm_prefs = llm_prefs.as_ref(ctx); - let choices = get_choices(llm_prefs, ctx); + let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs)); + let model_to_select = llm_prefs.get_llm_info(&model_to_select) + .map_or(model_to_select.clone(), |model| model.id.clone()); + let choices = collapse_model_reasoning_choices(get_choices(llm_prefs, ctx), Some(&model_to_select)); let has_upgrade_gated_models = choices .iter() @@ -685,7 +771,7 @@ impl ExecutionProfileEditorView { None, None, false, - false, + true, ctx, ); dropdown.set_rich_items(items, ctx); @@ -700,9 +786,6 @@ impl ExecutionProfileEditorView { dropdown.clear_footer(ctx); } - let llm_prefs = LLMPreferences::handle(ctx); - let llm_prefs = llm_prefs.as_ref(ctx); - let model_to_select = profile_model.unwrap_or_else(|| get_default_id(llm_prefs)); dropdown.set_selected_by_action(create_action(model_to_select), ctx); ctx.notify(); }); @@ -1036,6 +1119,16 @@ impl TypedActionView for ExecutionProfileEditorView { self.sync_context_window_editor(ctx, true); ctx.notify(); } + ExecutionProfileEditorViewAction::SetModelReasoning { role, id } => { + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { + match role { + ProfileModelRole::Base => profiles_model.set_base_model(self.profile_id, Some(id.clone()), ctx), + ProfileModelRole::FullTerminalUse => profiles_model.set_cli_agent_model(self.profile_id, Some(id.clone()), ctx), + ProfileModelRole::ComputerUse => profiles_model.set_computer_use_model(self.profile_id, Some(id.clone()), ctx), + } + }); + ctx.notify(); + } ExecutionProfileEditorViewAction::ContextWindowSliderDragged { value } => { if !AISettings::as_ref(ctx).is_any_ai_enabled(ctx) { self.sync_context_window_editor(ctx, true); diff --git a/app/src/ai/execution_profiles/editor/mod_tests.rs b/app/src/ai/execution_profiles/editor/mod_tests.rs index daf0223b..82330030 100644 --- a/app/src/ai/execution_profiles/editor/mod_tests.rs +++ b/app/src/ai/execution_profiles/editor/mod_tests.rs @@ -5,6 +5,9 @@ use warpui::App; use super::ui_helpers::context_window_snap_values; use crate::ai::execution_profiles::profiles::AIExecutionProfilesModel; +use crate::ai::execution_profiles::model_menu_items::{ + collapse_model_reasoning_choices, has_reasoning_variants, reasoning_choices_for_model, +}; use crate::ai::execution_profiles::{ has_configurable_context_window, should_show_long_context_pricing_warning, AIExecutionProfile, AIExecutionProfileAppExt as _, @@ -316,3 +319,53 @@ fn non_openai_configurable_context_ignores_gpt_flag_and_does_not_show_openai_war false )); } + +fn reasoning_model(id: &str, provider: &str, effort: Option<&str>) -> LLMInfo { + let mut model = configurable_model(LLMProvider::LiteLLM); + model.id = id.into(); + model.base_model_name = "Same model".to_owned(); + model.description = Some(provider.to_owned()); + model.reasoning_level = effort.map(str::to_owned); + model +} + +#[test] +fn reasoning_dropdown_supports_one_advertised_level_and_provider_default() { + let base = reasoning_model("model", "Connection A", None); + let high = reasoning_model("model::reasoning::high", "Connection A", Some("high")); + let choices = vec![&base, &high]; + assert!(has_reasoning_variants(&base, &choices)); + let levels = reasoning_choices_for_model(&base.id, &choices); + assert_eq!(levels.len(), 2); + assert_eq!(levels[0].reasoning_level, None); + assert_eq!(levels[1].reasoning_level.as_deref(), Some("high")); +} + +#[test] +fn reasoning_choices_without_discovery_remain_unchanged() { + let base = reasoning_model("model", "Connection A", None); + let choices = vec![&base]; + assert!(!has_reasoning_variants(&base, &choices)); + assert!(reasoning_choices_for_model(&base.id, &choices).is_empty()); + assert_eq!(collapse_model_reasoning_choices(choices, Some(&base.id))[0].id, base.id); +} + +#[test] +fn collapsed_models_preserve_selected_reasoning_and_provider_identity() { + let base = reasoning_model("model", "Connection A", None); + let high = reasoning_model("model::reasoning::high", "Connection A", Some("high")); + let other = reasoning_model("model::connection::other", "Connection B", None); + let choices = collapse_model_reasoning_choices(vec![&base, &high, &other], Some(&high.id)); + assert_eq!(choices.len(), 2); + assert_eq!(choices[0].id, high.id); + assert_eq!(choices[1].id, other.id); + assert!(reasoning_choices_for_model(&other.id, &[&base, &high, &other]).is_empty()); +} + +#[test] +fn reasoning_dropdown_does_not_invent_provider_default() { + let high = reasoning_model("model::reasoning::high", "Connection A", Some("high")); + let levels = reasoning_choices_for_model(&high.id, &[&high]); + assert_eq!(levels.len(), 1); + assert_eq!(levels[0].id, high.id); +} diff --git a/app/src/ai/execution_profiles/editor/ui_helpers.rs b/app/src/ai/execution_profiles/editor/ui_helpers.rs index 9401496f..caed3835 100644 --- a/app/src/ai/execution_profiles/editor/ui_helpers.rs +++ b/app/src/ai/execution_profiles/editor/ui_helpers.rs @@ -12,7 +12,7 @@ use warpui::ui_components::components::{Coords, UiComponent, UiComponentStyles}; use warpui::ui_components::text::Span; use warpui::{AppContext, Element, SingletonEntity, ViewHandle}; -use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction}; +use super::{ExecutionProfileEditorView, ExecutionProfileEditorViewAction, ProfileModelRole}; use crate::ai::blocklist::BlocklistAIPermissions; use crate::ai::execution_profiles::{ long_context_pricing_warning_title, AIExecutionProfile, AIExecutionProfileAppExt as _, @@ -247,6 +247,29 @@ fn render_permission_row( .finish() } +fn render_reasoning_dropdown_row( + appearance: &Appearance, + dropdown: &ViewHandle>, +) -> Box { + Container::new( + Flex::row() + .with_cross_axis_alignment(CrossAxisAlignment::Center) + .with_child( + Container::new( + Text::new("Reasoning level", appearance.ui_font_family(), 12.) + .with_color(appearance.theme().active_ui_text_color().into()) + .finish(), + ) + .with_margin_right(12.) + .finish(), + ) + .with_child(ChildView::new(dropdown).finish()) + .finish(), + ) + .with_margin_bottom(16.) + .finish() +} + pub fn render_models_section( appearance: &Appearance, view: &ExecutionProfileEditorView, @@ -262,6 +285,10 @@ pub fn render_models_section( &view.base_model_dropdown, )); + if view.reasoning_roles.contains(&ProfileModelRole::Base) { + column.add_child(render_reasoning_dropdown_row(appearance, &view.base_reasoning_dropdown)); + } + if let Some(row) = render_context_window_row(appearance, view, app) { column.add_child(row); } @@ -273,6 +300,10 @@ pub fn render_models_section( &view.full_terminal_use_model_dropdown, )); + if view.reasoning_roles.contains(&ProfileModelRole::FullTerminalUse) { + column.add_child(render_reasoning_dropdown_row(appearance, &view.full_terminal_reasoning_dropdown)); + } + if FeatureFlag::LocalComputerUse.is_enabled() { column.add_child(render_filterable_dropdown_row( appearance, @@ -280,6 +311,9 @@ pub fn render_models_section( "The model used when the agent takes control of your computer to interact with graphical applications through mouse movements, clicks, and keyboard input.", &view.computer_use_model_dropdown, )); + if view.reasoning_roles.contains(&ProfileModelRole::ComputerUse) { + column.add_child(render_reasoning_dropdown_row(appearance, &view.computer_use_reasoning_dropdown)); + } } Container::new(column.finish()) diff --git a/app/src/ai/execution_profiles/model_menu_items.rs b/app/src/ai/execution_profiles/model_menu_items.rs index 6af90d25..d811d15e 100644 --- a/app/src/ai/execution_profiles/model_menu_items.rs +++ b/app/src/ai/execution_profiles/model_menu_items.rs @@ -1,3 +1,4 @@ +use std::collections::HashSet; use std::sync::Arc; use galaxy_core::ui::Icon; @@ -21,15 +22,55 @@ pub fn is_auto(llm: &LLMInfo) -> bool { || llm.id.to_string().to_lowercase().contains("auto") } -/// Returns true if the given model has other variants with different reasoning levels. +/// Returns true when a model's configured connection advertises reasoning choices. pub fn has_reasoning_variants(llm: &LLMInfo, all_models: &[&LLMInfo]) -> bool { - if !llm.has_reasoning_level() { - return false; + all_models.iter().any(|other| { + other.reasoning_group_key() == llm.reasoning_group_key() && other.has_reasoning_level() + }) +} + +/// Keep one visible model entry per connection while preserving the selected +/// reasoning variant as the entry's action. Unknown reasoning metadata stays unchanged. +pub fn collapse_model_reasoning_choices<'a>( + choices: Vec<&'a LLMInfo>, + selected_id: Option<&LLMId>, +) -> Vec<&'a LLMInfo> { + let mut groups = HashSet::new(); + let mut result = Vec::new(); + for model in &choices { + if !has_reasoning_variants(model, &choices) { + result.push(*model); + continue; + } + let key = model.reasoning_group_key(); + if !groups.insert(key.clone()) { + continue; + } + let matching = || choices.iter().copied().filter(|other| other.reasoning_group_key() == key); + let representative = matching() + .find(|other| Some(&other.id) == selected_id) + .or_else(|| matching().find(|other| !other.has_reasoning_level())) + .unwrap_or(model); + result.push(representative); } - all_models - .iter() - .filter(|other| other.reasoning_group_key() == llm.reasoning_group_key() && other.id != llm.id) - .any(|other| other.has_reasoning_level()) + result +} + +/// Includes the provider-default entry only when that entry is actually available. +pub fn reasoning_choices_for_model<'a>( + selected_id: &LLMId, + choices: &[&'a LLMInfo], +) -> Vec<&'a LLMInfo> { + let Some(selected) = choices.iter().find(|model| &model.id == selected_id) else { + return Vec::new(); + }; + if !has_reasoning_variants(selected, choices) { + return Vec::new(); + } + choices.iter().copied() + .filter(|model| model.reasoning_group_key() == selected.reasoning_group_key()) + .unique_by(|model| model.reasoning_level.clone()) + .collect() } fn with_cost_and_profile_info( diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 6c96743e..850b1129 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -386,6 +386,8 @@ pub struct AvailableLLMs { /// The Warp "default" LLM. default_id: LLMId, choices: Vec, + #[serde(skip)] + legacy_reasoning_aliases: HashMap, #[serde(default)] preferred_codex_model_id: Option, @@ -423,12 +425,16 @@ impl AvailableLLMs { Ok(Self { default_id, choices: choices.into_iter().collect(), + legacy_reasoning_aliases: HashMap::new(), preferred_codex_model_id, }) } fn info_for_id(&self, id: &LLMId) -> Option<&LLMInfo> { - self.choices.iter().find(|info| info.id == *id) + self.choices.iter().find(|info| info.id == *id).or_else(|| { + let resolved = self.legacy_reasoning_aliases.get(id)?; + self.choices.iter().find(|info| info.id == *resolved) + }) } /// Returns the info for the given id only if the model is usable (present @@ -516,6 +522,7 @@ impl ModelsByFeature { /// Used both in `ModelsByFeature::default()` and as a fallback in `get_computer_use_available()`. fn default_computer_use_llms() -> AvailableLLMs { AvailableLLMs { + legacy_reasoning_aliases: HashMap::new(), default_id: "computer-use-agent-auto".to_owned().into(), choices: vec![LLMInfo { display_name: "auto".to_owned(), @@ -546,6 +553,7 @@ impl Default for ModelsByFeature { /// `inject_bedrock_models` once real models are loaded. fn default() -> Self { let placeholder = || AvailableLLMs { + legacy_reasoning_aliases: HashMap::new(), default_id: "placeholder".to_owned().into(), choices: vec![LLMInfo { display_name: "No models configured".to_owned(), @@ -1131,6 +1139,11 @@ impl LLMPreferences { cli.choices.retain(|m| m.provider != LLMProvider::LiteLLM); } self.openai_provider_routing.clear(); + self.models_by_feature.agent_mode.legacy_reasoning_aliases.clear(); + self.models_by_feature.coding.legacy_reasoning_aliases.clear(); + if let Some(cli) = &mut self.models_by_feature.cli_agent { + cli.legacy_reasoning_aliases.clear(); + } let settings = AISettings::as_ref(ctx); self.inject_acp_models(ctx); @@ -1241,6 +1254,8 @@ impl LLMPreferences { let mut total_injected = 0; let mut seen_model_ids: HashSet = HashSet::new(); + let mut legacy_reserved_model_ids = HashSet::new(); + let mut legacy_reasoning_aliases = HashMap::new(); let mut seen_connections = HashSet::new(); for ( provider_name, @@ -1263,31 +1278,33 @@ impl LLMPreferences { continue; } - let reasoning_efforts: Vec> = - if provider_kind == OpenAIProviderKind::ChatGPTSubscription { - // Keep the base model as the provider-default mode, then expose each - // explicitly supported effort as a separate selectable variant. - std::iter::once(None) - .chain(model.reasoning_efforts.iter().map(Some)) - .collect() - } else { - vec![None] - }; + // All providers may advertise discrete effort choices. Keep the + // default model key stable and route explicit choices separately. + let reasoning_efforts = std::iter::once(None) + .chain(model.reasoning_efforts.iter().map(Some)); + // Choose a namespace once for the whole family. Otherwise a + // colliding base with unique efforts would split one connection + // into multiple groups in the model/reasoning selectors. + let connection_suffix = if seen_model_ids.contains(&model.model_id) { + use sha2::{Digest, Sha256}; + Some(format!("{:x}", Sha256::digest(connection_identity.as_bytes()))) + } else { + None + }; for reasoning_effort in reasoning_efforts { let reasoning_effort = reasoning_effort.cloned(); let mut model_key = reasoning_effort.as_deref().map_or_else( || model.model_id.clone(), |effort| openai_model_variant_id(&model.model_id, effort), ); + let legacy_key = model_key.clone(); + let legacy_unclaimed = legacy_reserved_model_ids.insert(legacy_key.clone()); + if let Some(suffix) = &connection_suffix { + model_key = format!("{model_key}::connection::{suffix}"); + } if !seen_model_ids.insert(model_key.clone()) { - use sha2::{Digest, Sha256}; - let connection_hash = - format!("{:x}", Sha256::digest(connection_identity.as_bytes())); - model_key = format!("{model_key}::connection::{connection_hash}"); - if !seen_model_ids.insert(model_key.clone()) { - continue; - } + continue; } // Disabled entries still reserve their selection keys so // toggling a connection cannot silently reroute another @@ -1296,6 +1313,9 @@ impl LLMPreferences { continue; } + if legacy_unclaimed && reasoning_effort.is_some() && legacy_key != model_key { + legacy_reasoning_aliases.insert(LLMId::from(legacy_key), LLMId::from(model_key.clone())); + } // Register the routing entry. Reasoning variants keep the provider's // actual model ID while using their synthetic key only for selection. let client_config = OpenAIClientConfig { @@ -1354,6 +1374,18 @@ impl LLMPreferences { } } + // An exact key, including one reserved by a disabled model, always wins. + legacy_reasoning_aliases.retain(|legacy_id, _| !seen_model_ids.contains(legacy_id.as_str())); + for (legacy_id, canonical_id) in &legacy_reasoning_aliases { + if let Some(config) = self.openai_provider_routing.get(canonical_id.as_str()).cloned() { + self.openai_provider_routing.entry(legacy_id.to_string()).or_insert(config); + } + } + self.models_by_feature.agent_mode.legacy_reasoning_aliases = legacy_reasoning_aliases.clone(); + self.models_by_feature.coding.legacy_reasoning_aliases = legacy_reasoning_aliases.clone(); + if let Some(cli) = &mut self.models_by_feature.cli_agent { + cli.legacy_reasoning_aliases = legacy_reasoning_aliases; + } log::info!("[openai/litellm] Injected {total_injected} model(s) into available choices"); } @@ -1998,7 +2030,7 @@ impl LLMPreferences { provider: None, supports_system_messages: Some(true), capability_overrides: std::collections::HashMap::new(), - reasoning_efforts: Vec::new(), + reasoning_efforts: model.reasoning_efforts, enabled: true, }) .collect() @@ -2974,6 +3006,36 @@ fn u32_from_json_any(value: &serde_json::Value, keys: &[&str]) -> Option { .and_then(|value| u32::try_from(value).ok()) } +/// Read enumerated effort support, never infer choices from model names or a +/// generic supports_reasoning flag. Explicit lists take precedence over flags. +#[cfg(not(target_family = "wasm"))] +fn advertised_reasoning_efforts(value: &serde_json::Value) -> Vec { + for key in [ + "reasoning_effort_levels", + "supported_reasoning_efforts", + "reasoning_efforts", + "supported_reasoning_levels", + ] { + if let Some(levels) = value[key].as_array() { + let mut result = Vec::new(); + for level in levels { + if let Some(effort) = level.as_str().or_else(|| level["effort"].as_str()) { + let effort = effort.trim(); + if !effort.is_empty() && !result.iter().any(|entry| entry == effort) { + result.push(effort.to_string()); + } + } + } + return result; + } + } + ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"] + .into_iter() + .filter(|level| value[format!("supports_{level}_reasoning_effort")].as_bool() == Some(true)) + .map(str::to_string) + .collect() +} + #[cfg(not(target_family = "wasm"))] fn normalize_codex_release_version(version: &str) -> Option { let version = version.trim(); @@ -3125,17 +3187,7 @@ fn chatgpt_models_from_codex_response(body: &serde_json::Value) -> Vec>() - }) - .unwrap_or_default(); + let reasoning_efforts = advertised_reasoning_efforts(model); Some(OpenAIModelConfig { model_id: model_id.to_string(), @@ -3283,7 +3335,7 @@ async fn fetch_from_litellm_model_info( model_info["supports_system_messages"].as_bool() }, capability_overrides: std::collections::HashMap::new(), - reasoning_efforts: Vec::new(), + reasoning_efforts: advertised_reasoning_efforts(model_info), enabled: true, }) }) @@ -3322,6 +3374,12 @@ async fn fetch_litellm_catalog( .iter() .find(|info| info.model_id == model.model_id) .cloned() + .map(|mut info| { + if info.reasoning_efforts.is_empty() { + info.reasoning_efforts = model.reasoning_efforts.clone(); + } + info + }) .unwrap_or(model) }) .collect()) @@ -3442,7 +3500,7 @@ async fn fetch_from_openai_models( m["supports_system_messages"].as_bool() }, capability_overrides: std::collections::HashMap::new(), - reasoning_efforts: Vec::new(), + reasoning_efforts: advertised_reasoning_efforts(m), enabled: true, }) }) diff --git a/app/src/ai/llms_tests.rs b/app/src/ai/llms_tests.rs index e8546ff8..53e0f103 100644 --- a/app/src/ai/llms_tests.rs +++ b/app/src/ai/llms_tests.rs @@ -256,6 +256,7 @@ fn computer_use_picker_includes_vision_models_without_duplicate_entries() { text_only.display_name = "Text-only model".to_owned(); preferences.models_by_feature.agent_mode.choices = vec![visual.clone(), text_only]; preferences.models_by_feature.computer_use = Some(AvailableLLMs { + legacy_reasoning_aliases: HashMap::new(), default_id: "computer-use-agent-auto".into(), choices: vec![auto, visual], preferred_codex_model_id: None, @@ -1276,3 +1277,105 @@ fn model_selector_labels_and_reasoning_groups_include_configured_provider() { other.id = LLMId::from("model-b::connection::second-connection"); assert_ne!(first.reasoning_group_key(), other.reasoning_group_key()); } + +#[test] +fn reasoning_discovery_uses_explicit_lists_and_supported_flags_only() { + assert_eq!( + advertised_reasoning_efforts(&serde_json::json!({ + "reasoning_effort_levels": [" low ", "high", "low", "", null], + "supports_max_reasoning_effort": true + })), + ["low", "high"] + ); + assert_eq!( + advertised_reasoning_efforts(&serde_json::json!({ + "supported_reasoning_levels": [{"effort": "minimal"}, {"effort": "high"}] + })), + ["minimal", "high"] + ); + assert_eq!( + advertised_reasoning_efforts(&serde_json::json!({ + "supports_reasoning": true, + "supports_low_reasoning_effort": true, + "supports_high_reasoning_effort": false, + "supports_xhigh_reasoning_effort": true + })), + ["low", "xhigh"] + ); + assert!(advertised_reasoning_efforts(&serde_json::json!({ + "id": "gpt-test-high", "supports_reasoning": true + })).is_empty()); + assert!(advertised_reasoning_efforts(&serde_json::json!({ + "reasoning_effort_levels": [], "supports_low_reasoning_effort": true + })).is_empty()); +} + +#[test] +fn overlapping_connections_keep_all_reasoning_variants_in_one_family() { + App::test((), |mut app| async move { + initialize_settings_for_tests(&mut app); + AISettings::handle(&app).update(&mut app, |settings, ctx| { + settings.openai_enabled.set_value(true, ctx).unwrap(); + let mut reasoning_model = openai_model("shared-model"); + reasoning_model.reasoning_efforts = vec!["low".to_owned(), "high".to_owned()]; + settings.openai_providers.set_value(vec![ + OpenAIProviderConfig { + kind: OpenAIProviderKind::LiteLLM, + name: "First connection".to_owned(), + base_url: "https://first.test/v1".to_owned(), + models: vec![openai_model("shared-model")], + enabled: true, + api_key: None, + project_id: None, + location: None, + }, + OpenAIProviderConfig { + kind: OpenAIProviderKind::LiteLLM, + name: "Second connection".to_owned(), + base_url: "https://second.test/v1".to_owned(), + models: vec![reasoning_model], + enabled: true, + api_key: None, + project_id: None, + location: None, + }, + ], ctx).unwrap(); + }); + let mut preferences = empty_preferences(); + app.read(|ctx| preferences.inject_openai_models(ctx)); + let choices: Vec<_> = preferences.models_by_feature.agent_mode.choices.iter() + .filter(|model| model.description.as_deref() == Some("Second connection")) + .collect(); + assert_eq!(choices.len(), 3); + let base = choices.iter().find(|model| model.reasoning_level.is_none()).unwrap(); + assert!(base.id.as_str().contains("::connection::")); + let group_key = base.reasoning_group_key(); + for model in choices { + assert_eq!(model.reasoning_group_key(), group_key); + let route = preferences.openai_client_config_for_model(model.id.as_str()).unwrap(); + assert_eq!(route.model.as_deref(), Some("shared-model")); + assert_eq!(route.reasoning_effort, model.reasoning_level); + } + let old_id = LLMId::from("shared-model::reasoning::low"); + let canonical = preferences.get_llm_info(&old_id).unwrap(); + assert_eq!(canonical.description.as_deref(), Some("Second connection")); + assert!(canonical.id.as_str().contains("::connection::")); + assert_eq!(preferences.openai_client_config_for_model(old_id.as_str()).unwrap().reasoning_effort.as_deref(), Some("low")); + + AISettings::handle(&app).update(&mut app, |settings, ctx| { + let mut providers = settings.openai_providers.value().clone(); + providers[1].enabled = false; + let mut third = providers[1].clone(); + third.enabled = true; + third.name = "Third connection".to_owned(); + third.base_url = "https://third.test/v1".to_owned(); + providers.push(third); + settings.openai_providers.set_value(providers, ctx).unwrap(); + }); + app.read(|ctx| preferences.inject_openai_models(ctx)); + // A disabled second connection still reserves its legacy IDs: they + // must not silently migrate to the third connection. + assert!(preferences.get_llm_info(&old_id).is_none()); + assert!(preferences.openai_client_config_for_model(old_id.as_str()).is_none()); + }); +} diff --git a/app/src/ai/openai/client.rs b/app/src/ai/openai/client.rs index 23bb8eff..3e00eb7a 100644 --- a/app/src/ai/openai/client.rs +++ b/app/src/ai/openai/client.rs @@ -21,6 +21,7 @@ pub struct OpenAIClientConfig { } pub struct OpenAIClient { + reasoning_effort: Option, http: reqwest::Client, base_url: String, api_key: Option, @@ -54,6 +55,7 @@ impl OpenAIClient { pub fn from_config(config: OpenAIClientConfig) -> Self { let http = reqwest::Client::new(); Self { + reasoning_effort: config.reasoning_effort, http, base_url: config.base_url, api_key: config.api_key, @@ -62,9 +64,12 @@ impl OpenAIClient { pub async fn chat_completions_stream( &self, - request_body: serde_json::Value, + mut request_body: serde_json::Value, ) -> Result>, OpenAIError> { let url = format!("{}/chat/completions", self.base_url.trim_end_matches('/')); + if let Some(effort) = &self.reasoning_effort { + request_body["reasoning_effort"] = effort.clone().into(); + } let mut headers = HeaderMap::new(); headers.insert(CONTENT_TYPE, HeaderValue::from_static("application/json")); diff --git a/app/src/ai/runtime/rig.rs b/app/src/ai/runtime/rig.rs index 85140b4a..a22ef137 100644 --- a/app/src/ai/runtime/rig.rs +++ b/app/src/ai/runtime/rig.rs @@ -307,7 +307,8 @@ pub(crate) async fn provider_runtime_for_request( model, max_output_tokens: config.max_output_tokens.map(u64::from), supports_system_messages: config.supports_system_messages, - }), + }) + .with_reasoning_effort(config.reasoning_effort), ), OpenAIProviderKind::ChatGPTSubscription => Arc::new(ChatGPTSubscriptionRuntime::new( ChatGPTSubscriptionRuntimeConfig { @@ -324,7 +325,8 @@ pub(crate) async fn provider_runtime_for_request( api_key: credential.clone(), model, max_output_tokens: config.max_output_tokens.map(u64::from), - }); + }) + .with_reasoning_effort(config.reasoning_effort); if stored { runtime = runtime.with_credential_resolver(Arc::new(move || { Box::pin(crate::ai::claude_oauth::load_credential(credential.clone())) @@ -332,17 +334,23 @@ pub(crate) async fn provider_runtime_for_request( } Arc::new(runtime) } - OpenAIProviderKind::Gemini => Arc::new(GeminiRuntime::new(GeminiRuntimeConfig { - api_key: config.api_key.unwrap_or_default(), - model, - max_output_tokens: config.max_output_tokens.map(u64::from), - })), - OpenAIProviderKind::VertexAI => Arc::new(VertexAiRuntime::new(VertexAiRuntimeConfig { - project_id: config.project_id.unwrap_or_default(), - location: config.location.unwrap_or_else(|| "global".to_string()), - model, - max_output_tokens: config.max_output_tokens.map(u64::from), - })), + OpenAIProviderKind::Gemini => Arc::new( + GeminiRuntime::new(GeminiRuntimeConfig { + api_key: config.api_key.unwrap_or_default(), + model, + max_output_tokens: config.max_output_tokens.map(u64::from), + }) + .with_reasoning_effort(config.reasoning_effort), + ), + OpenAIProviderKind::VertexAI => Arc::new( + VertexAiRuntime::new(VertexAiRuntimeConfig { + project_id: config.project_id.unwrap_or_default(), + location: config.location.unwrap_or_else(|| "global".to_string()), + model, + max_output_tokens: config.max_output_tokens.map(u64::from), + }) + .with_reasoning_effort(config.reasoning_effort), + ), }, crate::ai::provider::ProviderConfig::Bedrock(config) => { let max_output_tokens = Some(bedrock_max_output_tokens(&model)); diff --git a/app/src/settings/ai.rs b/app/src/settings/ai.rs index be2b2c1e..7871d4c3 100644 --- a/app/src/settings/ai.rs +++ b/app/src/settings/ai.rs @@ -904,7 +904,7 @@ pub struct OpenAIModelConfig { pub capability_overrides: HashMap, #[serde(default)] #[schemars( - description = "Reasoning effort modes supported by this model when using the ChatGPT subscription provider." + description = "Discrete reasoning effort modes explicitly supported by this model and provider." )] pub reasoning_efforts: Vec, #[serde(default = "default_enabled")] diff --git a/app/src/terminal/input/models/data_source.rs b/app/src/terminal/input/models/data_source.rs index e3ff9bdc..5b9054f5 100644 --- a/app/src/terminal/input/models/data_source.rs +++ b/app/src/terminal/input/models/data_source.rs @@ -24,7 +24,7 @@ use super::model_spec_scores::{ MODEL_SPECS_DESCRIPTION, MODEL_SPECS_TITLE, REASONING_LEVEL_DESCRIPTION, REASONING_LEVEL_TITLE, }; use crate::ai::custom_model_routers::is_custom_router_id; -use crate::ai::execution_profiles::model_menu_items::is_auto; +use crate::ai::execution_profiles::model_menu_items::{collapse_model_reasoning_choices, is_auto}; use crate::ai::llms::{ is_using_api_key_for_provider, should_show_bedrock_icon_for_model, DisableReason, LLMId, LLMInfo, LLMPreferences, LLMProvider, LLMSpec, @@ -47,6 +47,14 @@ use crate::workspaces::user_workspaces::UserWorkspaces; const AUTO_BEDROCK_TOOLTIP: &str = "Warp uses Bedrock when the model Auto selects supports it; otherwise it may use Warp-hosted inference."; +fn model_picker_label(llm: &LLMInfo) -> String { + if llm.has_reasoning_level() && !is_custom_router_id(llm.id.as_str()) { + llm.label_with_provider(llm.base_model_name()) + } else { + llm.menu_display_name() + } +} + #[derive(Clone, Debug)] pub struct AcceptModel { pub id: LLMId, @@ -228,6 +236,7 @@ impl SyncDataSource for ModelSelectorDataSource { }) .collect_vec() }; + let choices = collapse_model_reasoning_choices(choices, Some(&active_llm_id)); let choices = Self::order_model_choices(llm_preferences, choices); let query_text = query.text.trim().to_lowercase(); @@ -243,7 +252,7 @@ impl SyncDataSource for ModelSelectorDataSource { .into_iter() .filter_map(|llm| { let match_result = match_indices_case_insensitive( - llm.menu_display_name().to_lowercase().as_str(), + model_picker_label(llm).to_lowercase().as_str(), query_text.as_str(), )?; @@ -327,7 +336,7 @@ impl ModelSearchItem { spec: llm.spec.clone(), leading_icon, credential_icon, - display_text: llm.menu_display_name(), + display_text: model_picker_label(llm), is_selected: &llm.id == active_llm_id, is_custom_endpoint, is_custom_router, diff --git a/app/src/terminal/profile_model_selector.rs b/app/src/terminal/profile_model_selector.rs index deb0ba0f..4b2e4f3e 100644 --- a/app/src/terminal/profile_model_selector.rs +++ b/app/src/terminal/profile_model_selector.rs @@ -1012,12 +1012,13 @@ impl ProfileModelSelector { // Group reasoning variants by model and configured provider. // Use "auto" as the key for all auto models so they collapse together. - // Only group models that have reasoning levels - others stay separate. + // Include the provider-default entry in its reasoning group, while + // leaving models without advertised reasoning choices unchanged. let mut groups: IndexMap> = IndexMap::new(); for llm in &server_choices { let key = if is_auto(llm) { "auto".to_string() - } else if llm.has_reasoning_level() { + } else if has_reasoning_variants(llm, &server_choices) { llm.reasoning_group_key() } else { llm.id.to_string() @@ -1155,7 +1156,7 @@ impl ProfileModelSelector { llm.display_name.clone() }; - Self::make_sidecar_item(label, &llm.id, is_selected) + Self::make_sidecar_item(label, llm, is_selected) }) .collect(), ModelSpecSidecarKind::Reasoning => { @@ -1186,11 +1187,13 @@ impl ProfileModelSelector { let items: Vec> = self .all_model_choices .iter() - .filter(|llm| llm.reasoning_group_key() == base_name && llm.has_reasoning_level()) + .filter(|llm| llm.reasoning_group_key() == base_name) .map(|llm| { let is_selected = llm.id == active_llm_id; - let label = llm.reasoning_level().unwrap_or_default(); - Self::make_sidecar_item(label, &llm.id, is_selected) + let label = llm + .reasoning_level() + .unwrap_or_else(|| "Provider default".to_owned()); + Self::make_sidecar_item(label, llm, is_selected) }) .collect(); @@ -1208,12 +1211,18 @@ impl ProfileModelSelector { fn make_sidecar_item( label: String, - llm_id: &LLMId, + llm: &LLMInfo, is_selected: bool, ) -> MenuItem { let mut fields = MenuItemFields::new(label) .with_font_size_override(14.) - .with_on_select_action(ProfileModelSelectorAction::SelectModel(llm_id.clone())); + .with_on_select_action(ProfileModelSelectorAction::SelectModel(llm.id.clone())); + + if let Some(reason) = &llm.disable_reason { + fields = fields + .with_disabled(true) + .with_tooltip(reason.tooltip_text()); + } if is_selected { fields = fields.with_icon(Icon::Check); @@ -1275,12 +1284,11 @@ impl ProfileModelSelector { item.item_on_select_action(), Some(ProfileModelSelectorAction::SelectAutoModel) ) - } else if active_llm.has_reasoning_level() { + } else if let Some(ProfileModelSelectorAction::SelectReasoningModel(name)) = + item.item_on_select_action() + { // Match the selected model and its configured provider. - matches!( - item.item_on_select_action(), - Some(ProfileModelSelectorAction::SelectReasoningModel(name)) if *name == active_llm.reasoning_group_key() - ) + *name == active_llm.reasoning_group_key() } else { let item_model_id = item .item_on_select_action() @@ -1334,12 +1342,17 @@ impl ProfileModelSelector { .cloned() } ProfileModelSelectorAction::SelectReasoningModel(base_name) => { - // Get the first reasoning variant for this base model + // Preserve the current effort when hovering its model. + // For another model, prefer the provider-default entry. + let preferences = LLMPreferences::as_ref(ctx); + let active = preferences.get_active_base_model(ctx, Some(self.terminal_view_id)); + if active.reasoning_group_key() == *base_name { + return Some(active.clone()); + } self.all_model_choices .iter() - .find(|llm| { - llm.reasoning_group_key() == *base_name && llm.has_reasoning_level() - }) + .filter(|llm| llm.reasoning_group_key() == *base_name) + .min_by_key(|llm| llm.has_reasoning_level()) .cloned() } _ => None, diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index a513b994..8a2d1af4 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -2742,6 +2742,7 @@ pub struct TerminalView { pending_user_query_kind: Option, queued_prompt_callback: Option, last_observed_conversation_status: HashMap, + last_desktop_notification_status: HashMap, last_observed_active_subagent: HashMap, usage_footer_view_ids: HashMap, @@ -4415,6 +4416,7 @@ impl TerminalView { pending_user_query_kind: None, queued_prompt_callback: None, last_observed_conversation_status: Default::default(), + last_desktop_notification_status: Default::default(), last_observed_active_subagent: Default::default(), usage_footer_view_ids: Default::default(), block_onboarding_active: false, @@ -6466,6 +6468,9 @@ impl TerminalView { ConversationStatusUpdate::Restored => None, }); + if !new_status.should_trigger_notification() { + self.last_desktop_notification_status.remove(conversation_id); + } // Don't send notifications or insert ambient agent session ended tombstone // if we're restoring this conversation on startup. if matches!(update, ConversationStatusUpdate::Restored) { @@ -6579,6 +6584,7 @@ impl TerminalView { // The singleton's own history subscriber drops queue state for cleared // conversations, so no `clear_all` call is needed here. self.last_observed_conversation_status.clear(); + self.last_desktop_notification_status.clear(); self.last_observed_active_subagent.clear(); if let Some(active_conversation_id) = active_conversation_id { self.ai_controller.update(ctx, |controller, ctx| { @@ -6645,6 +6651,7 @@ impl TerminalView { // needed here. self.last_observed_conversation_status .remove(conversation_id); + self.last_desktop_notification_status.remove(conversation_id); self.last_observed_active_subagent.remove(conversation_id); } BlocklistAIHistoryEvent::CreatedSubtask { @@ -13719,6 +13726,11 @@ impl TerminalView { // Desktop notifications — only when navigated away and not in-progress. if !self.is_navigated_away_from_window(ctx) || matches!(status, CLIAgentSessionStatus::InProgress) + // Linked workers use conversation notifications, which suppress + // routine completions and retain actionable permission requests. + || self + .child_conversation_id_for_cli_status_updates(ctx) + .is_some() { return; } @@ -16128,7 +16140,17 @@ impl TerminalView { return; }; if conversation.is_entirely_passive() - || !conversation.status().should_trigger_notification() + || !conversation + .status() + .should_notify_for_agent(conversation.is_child_agent_conversation()) + || self.last_desktop_notification_status.get(conversation_id) + == Some(conversation.status()) + { + return; + } + + if matches!(conversation.status(), ConversationStatus::Success) + && QueuedQueryModel::as_ref(ctx).has_autofireable_prompt(*conversation_id) { return; } @@ -16142,6 +16164,8 @@ impl TerminalView { } else { NotificationsTrigger::AgentTaskCompleted(block_summary.success) }; + self.last_desktop_notification_status + .insert(*conversation_id, conversation.status().clone()); self.send_agent_desktop_notification_or_show_banner( trigger, block_summary.title, diff --git a/crates/galaxy_agent_rig/src/native.rs b/crates/galaxy_agent_rig/src/native.rs index 767448ee..406026e0 100644 --- a/crates/galaxy_agent_rig/src/native.rs +++ b/crates/galaxy_agent_rig/src/native.rs @@ -2,15 +2,15 @@ use std::collections::HashSet; use std::sync::{Arc, OnceLock}; use async_trait::async_trait; -use futures::StreamExt; use futures::future::BoxFuture; +use futures::StreamExt; use galaxy_agent_core::{ AgentError, AgentErrorKind, AgentEventStream, AgentRuntime, RuntimeCapabilities, RuntimeDescriptor, RuntimeKind, TurnControl, TurnRequest, }; use rig_core::client::{CompletionClient, ModelListingClient}; -use rig_core::providers::gemini; use rig_core::http_client::{self, HttpClientExt}; +use rig_core::providers::gemini; use crate::anthropic_auth::{anthropic_client, prepare_subscription_request, restore_tool_names}; use crate::request::build_completion_request; @@ -22,6 +22,7 @@ pub struct RigModelInfo { pub display_name: String, pub context_size: Option, pub max_output_tokens: Option, + pub reasoning_efforts: Vec, } pub async fn discover_anthropic_models(api_key: &str) -> Result, String> { @@ -30,30 +31,48 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result>(request).await + let response = client + .send::<_, Vec>(request) + .await .map_err(|_| "Could not retrieve Anthropic models.".to_string())?; if !response.status().is_success() { - return Err(format!("Anthropic model discovery returned HTTP {}.", response.status())); + return Err(format!( + "Anthropic model discovery returned HTTP {}.", + response.status() + )); } - let body = response.into_body().await + let body = response + .into_body() + .await .map_err(|_| "Could not read the Anthropic models response.".to_string())?; let page: serde_json::Value = serde_json::from_slice(&body) .map_err(|_| "Invalid Anthropic models response.".to_string())?; - let entries = page["data"].as_array() + let entries = page["data"] + .as_array() .ok_or_else(|| "Anthropic models response is missing its model list.".to_string())?; for entry in entries { models.push(anthropic_model_info(entry)?); } match page["has_more"].as_bool() { Some(false) => return Ok(models), - Some(true) => {}, - None => return Err("Anthropic models response is missing pagination state.".to_string()), + Some(true) => {} + None => { + return Err("Anthropic models response is missing pagination state.".to_string()) + } } - let cursor = page["last_id"].as_str().filter(|cursor| { - !cursor.is_empty() && cursor.bytes().all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) - }).ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?; + let cursor = page["last_id"] + .as_str() + .filter(|cursor| { + !cursor.is_empty() + && cursor.bytes().all(|byte| { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.') + }) + }) + .ok_or_else(|| "Invalid Anthropic model pagination cursor.".to_string())?; if !cursors.insert(cursor.to_string()) { return Err("Anthropic model pagination did not advance.".to_string()); } @@ -62,17 +81,24 @@ pub async fn discover_anthropic_models(api_key: &str) -> Result Option { - value.as_u64().and_then(|tokens| u32::try_from(tokens).ok()).filter(|tokens| *tokens > 0) + value + .as_u64() + .and_then(|tokens| u32::try_from(tokens).ok()) + .filter(|tokens| *tokens > 0) } fn anthropic_model_info(entry: &serde_json::Value) -> Result { - let id = entry["id"].as_str().filter(|id| !id.is_empty()) + let id = entry["id"] + .as_str() + .filter(|id| !id.is_empty()) .ok_or_else(|| "Anthropic returned a model without an ID.".to_string())?; // Read live limits first. Rig's generic model lister drops these fields. // The models.dev snapshot is metadata only, never an availability allowlist. static LIMITS: OnceLock = OnceLock::new(); - let limits = LIMITS.get_or_init(|| serde_json::from_str(include_str!("../data/anthropic_limits.json")) - .expect("bundled Anthropic limits must be valid JSON")); + let limits = LIMITS.get_or_init(|| { + serde_json::from_str(include_str!("../data/anthropic_limits.json")) + .expect("bundled Anthropic limits must be valid JSON") + }); let fallback = &limits["models"][id]; Ok(RigModelInfo { id: id.to_string(), @@ -81,9 +107,22 @@ fn anthropic_model_info(entry: &serde_json::Value) -> Result Vec { + let effort = &entry["capabilities"]["effort"]; + if effort["supported"].as_bool() != Some(true) { + return Vec::new(); + } + ["low", "medium", "high", "xhigh", "max"] + .into_iter() + .filter(|level| effort[*level]["supported"].as_bool() == Some(true)) + .map(str::to_string) + .collect() +} + pub async fn discover_gemini_models(api_key: &str) -> Result, String> { let client = gemini::Client::new(api_key).map_err(|error| error.to_string())?; let models = client @@ -98,6 +137,7 @@ pub async fn discover_gemini_models(api_key: &str) -> Result, id: model.id, context_size: model.context_length, max_output_tokens: None, + reasoning_efforts: Vec::new(), }) .collect()) } @@ -125,6 +165,7 @@ pub fn vertex_ai_model_catalog() -> Vec { display_name: display_name.to_string(), context_size: None, max_output_tokens: None, + reasoning_efforts: Vec::new(), }) .collect() } @@ -168,6 +209,7 @@ pub struct AnthropicRuntime { config: AnthropicRuntimeConfig, descriptor: RuntimeDescriptor, credential_resolver: Option, + reasoning_effort: Option, } impl std::fmt::Debug for AnthropicRuntime { @@ -181,12 +223,18 @@ impl std::fmt::Debug for AnthropicRuntime { } impl AnthropicRuntime { + pub fn with_reasoning_effort(mut self, effort: Option) -> Self { + self.reasoning_effort = effort; + self + } + pub fn new(config: AnthropicRuntimeConfig) -> Self { let descriptor = native_descriptor("anthropic", &config.model); Self { config, descriptor, credential_resolver: None, + reasoning_effort: None, } } @@ -224,8 +272,15 @@ impl AgentRuntime for AnthropicRuntime { .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; let mut model = client.completion_model(&self.config.model); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); - let mut completion_request = - build_completion_request(request, max_output_tokens, true, true, None)?; + let mut completion_request = build_completion_request( + request, + max_output_tokens, + true, + true, + self.reasoning_effort + .as_deref() + .map(anthropic_reasoning_params), + )?; let aliases = if crate::is_claude_subscription_token(&credential) { model = model.with_prompt_caching(); Some(prepare_subscription_request(&mut completion_request)?) @@ -256,14 +311,24 @@ pub struct GeminiRuntimeConfig { #[derive(Clone, Debug)] pub struct GeminiRuntime { + reasoning_effort: Option, config: GeminiRuntimeConfig, descriptor: RuntimeDescriptor, } impl GeminiRuntime { + pub fn with_reasoning_effort(mut self, effort: Option) -> Self { + self.reasoning_effort = effort; + self + } + pub fn new(config: GeminiRuntimeConfig) -> Self { let descriptor = native_descriptor("gemini", &config.model); - Self { config, descriptor } + Self { + config, + descriptor, + reasoning_effort: None, + } } } @@ -288,8 +353,15 @@ impl AgentRuntime for GeminiRuntime { .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; let model = client.completion_model(&self.config.model); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); - let completion_request = - build_completion_request(request, max_output_tokens, true, true, None)?; + let completion_request = build_completion_request( + request, + max_output_tokens, + true, + true, + self.reasoning_effort + .as_deref() + .map(gemini_reasoning_params), + )?; start_model_turn(model, completion_request, control, max_output_tokens).await } } @@ -304,14 +376,24 @@ pub struct VertexAiRuntimeConfig { #[derive(Clone, Debug)] pub struct VertexAiRuntime { + reasoning_effort: Option, config: VertexAiRuntimeConfig, descriptor: RuntimeDescriptor, } impl VertexAiRuntime { + pub fn with_reasoning_effort(mut self, effort: Option) -> Self { + self.reasoning_effort = effort; + self + } + pub fn new(config: VertexAiRuntimeConfig) -> Self { let descriptor = native_descriptor("vertex-ai", &config.model); - Self { config, descriptor } + Self { + config, + descriptor, + reasoning_effort: None, + } } } @@ -343,12 +425,27 @@ impl AgentRuntime for VertexAiRuntime { .map_err(|error| AgentError::new(AgentErrorKind::Configuration, error.to_string()))?; let model = client.completion_model(&self.config.model); let max_output_tokens = request.max_output_tokens.or(self.config.max_output_tokens); - let completion_request = - build_completion_request(request, max_output_tokens, true, true, None)?; + let completion_request = build_completion_request( + request, + max_output_tokens, + true, + true, + self.reasoning_effort + .as_deref() + .map(gemini_reasoning_params), + )?; start_model_completion(model, completion_request, control, max_output_tokens).await } } +fn anthropic_reasoning_params(effort: &str) -> serde_json::Value { + serde_json::json!({ "output_config": { "effort": effort } }) +} + +fn gemini_reasoning_params(effort: &str) -> serde_json::Value { + serde_json::json!({ "generationConfig": { "thinkingConfig": { "thinkingLevel": effort } } }) +} + fn native_descriptor(provider: &str, model: &str) -> RuntimeDescriptor { RuntimeDescriptor { id: format!("rig-{provider}:{model}"), diff --git a/crates/galaxy_agent_rig/src/native_tests.rs b/crates/galaxy_agent_rig/src/native_tests.rs index 742f1fde..c9923048 100644 --- a/crates/galaxy_agent_rig/src/native_tests.rs +++ b/crates/galaxy_agent_rig/src/native_tests.rs @@ -21,10 +21,67 @@ fn anthropic_discovery_prefers_live_limits_over_catalog_defaults() { fn anthropic_discovery_uses_exact_catalog_limits_when_api_omits_them() { let model = super::anthropic_model_info(&serde_json::json!({ "id": "claude-opus-4-6", "max_input_tokens": null, "max_tokens": null - })).unwrap(); + })) + .unwrap(); assert_eq!(model.context_size, Some(1000000)); assert_eq!(model.max_output_tokens, Some(128000)); - let unknown = super::anthropic_model_info(&serde_json::json!({"id": "claude-unknown"})).unwrap(); + let unknown = + super::anthropic_model_info(&serde_json::json!({"id": "claude-unknown"})).unwrap(); assert_eq!(unknown.context_size, None); assert_eq!(unknown.max_output_tokens, None); } + +#[test] +fn anthropic_discovery_reads_only_supported_effort_capabilities() { + let model = super::anthropic_model_info(&serde_json::json!({ + "id": "claude-future", "capabilities": {"effort": { + "supported": true, "low": {"supported": true}, + "medium": {"supported": false}, "high": {"supported": true}, + "xhigh": {"supported": true}, "max": {"supported": false} + }} + })) + .unwrap(); + assert_eq!(model.reasoning_efforts, ["low", "high", "xhigh"]); + assert!(super::anthropic_reasoning_efforts(&serde_json::json!({ + "capabilities": {"effort": {"supported": false, "low": {"supported": true}}} + })) + .is_empty()); + assert!(super::anthropic_reasoning_efforts(&serde_json::json!({ + "id": "claude-opus-4-6" + })) + .is_empty()); +} + +#[test] +fn native_reasoning_selections_use_provider_request_fields() { + assert_eq!( + super::anthropic_reasoning_params("high"), + serde_json::json!({ + "output_config": {"effort": "high"} + }) + ); + assert_eq!( + super::gemini_reasoning_params("low"), + serde_json::json!({ + "generationConfig": {"thinkingConfig": {"thinkingLevel": "low"}} + }) + ); +} + +#[test] +fn gemini_reasoning_parameters_survive_rig_typed_conversion() { + use rig_core::providers::gemini::completion::gemini_api_types::{ + AdditionalParameters, ThinkingLevel, + }; + let params: AdditionalParameters = + serde_json::from_value(super::gemini_reasoning_params("medium")).unwrap(); + assert_eq!( + params + .generation_config + .unwrap() + .thinking_config + .unwrap() + .thinking_level, + Some(ThinkingLevel::Medium) + ); +} diff --git a/crates/galaxy_agent_rig/src/openai_compatible.rs b/crates/galaxy_agent_rig/src/openai_compatible.rs index ffaf9b54..afbc0962 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible.rs @@ -23,9 +23,15 @@ pub struct OpenAICompatibleRuntimeConfig { pub struct OpenAICompatibleRuntime { config: OpenAICompatibleRuntimeConfig, descriptor: RuntimeDescriptor, + reasoning_effort: Option, } impl OpenAICompatibleRuntime { + pub fn with_reasoning_effort(mut self, effort: Option) -> Self { + self.reasoning_effort = effort; + self + } + pub fn new(config: OpenAICompatibleRuntimeConfig) -> Self { let descriptor = RuntimeDescriptor { id: format!("rig-openai-compatible:{}", config.model), @@ -33,7 +39,11 @@ impl OpenAICompatibleRuntime { kind: RuntimeKind::Provider, capabilities: RuntimeCapabilities::provider(), }; - Self { config, descriptor } + Self { + config, + descriptor, + reasoning_effort: None, + } } } @@ -62,6 +72,7 @@ impl AgentRuntime for OpenAICompatibleRuntime { control, self.config.max_output_tokens, self.config.supports_system_messages, + self.reasoning_effort.as_deref(), ) .await } @@ -73,6 +84,7 @@ async fn start_model_turn( control: TurnControl, configured_max_output_tokens: Option, supports_system_messages: bool, + reasoning_effort: Option<&str>, ) -> Result where M: CompletionModel + Send + Sync + 'static, @@ -82,6 +94,7 @@ where request, configured_max_output_tokens, supports_system_messages, + reasoning_effort, )?; start_provider_model_turn(model, completion_request, control, max_output_tokens).await } @@ -90,15 +103,18 @@ fn build_completion_request( request: TurnRequest, configured_max_output_tokens: Option, supports_system_messages: bool, + reasoning_effort: Option<&str>, ) -> Result { + let mut params = serde_json::json!({ "stream_options": { "include_usage": true } }); + if let Some(effort) = reasoning_effort { + params["reasoning_effort"] = effort.into(); + } build_provider_completion_request( request, configured_max_output_tokens, supports_system_messages, true, - Some(serde_json::json!({ - "stream_options": { "include_usage": true } - })), + Some(params), ) } diff --git a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs index 5eff56e1..d67b99b8 100644 --- a/crates/galaxy_agent_rig/src/openai_compatible_tests.rs +++ b/crates/galaxy_agent_rig/src/openai_compatible_tests.rs @@ -49,7 +49,7 @@ async fn rig_stream_maps_reasoning_text_usage_and_stop() { let model = client.completion_model("test-model"); let (_, control) = galaxy_agent_core::turn_control(); - let events = start_model_turn(model, text_request(), control, None, true) + let events = start_model_turn(model, text_request(), control, None, true, None) .await .unwrap() .collect::>() @@ -104,7 +104,7 @@ async fn rig_stream_ignores_empty_reasoning_heartbeats() { let model = client.completion_model("test-model"); let (_, control) = galaxy_agent_core::turn_control(); - let events = start_model_turn(model, text_request(), control, None, true) + let events = start_model_turn(model, text_request(), control, None, true, None) .await .unwrap() .collect::>() @@ -118,11 +118,9 @@ async fn rig_stream_ignores_empty_reasoning_heartbeats() { AgentEvent::ReasoningCompleted { text, signature } if text.is_empty() && signature.is_none() ))); - assert!( - events - .iter() - .any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "done")) - ); + assert!(events + .iter() + .any(|event| matches!(event, AgentEvent::TextDelta { text } if text == "done"))); } #[tokio::test] @@ -176,7 +174,7 @@ async fn usage_at_the_requested_limit_maps_to_max_tokens() { let mut request = text_request(); request.max_output_tokens = Some(6); - let events = start_model_turn(model, request, control, None, true) + let events = start_model_turn(model, request, control, None, true, None) .await .unwrap() .collect::>() @@ -219,7 +217,7 @@ async fn rig_stream_maps_complete_tool_call_without_executing_it() { input_schema: serde_json::json!({"type": "object"}), }); - let events = start_model_turn(model, request, control, None, true) + let events = start_model_turn(model, request, control, None, true, None) .await .unwrap() .collect::>() @@ -274,7 +272,7 @@ fn request_conversion_preserves_history_tools_and_limits() { }), }); - let converted = build_completion_request(request, Some(999), true).unwrap(); + let converted = build_completion_request(request, Some(999), true, None).unwrap(); assert_eq!(converted.max_tokens, Some(123)); assert_eq!(converted.tools.len(), 1); @@ -318,7 +316,7 @@ fn request_conversion_preserves_tool_call_and_denied_result_for_the_next_turn() ], ); - let converted = build_completion_request(request, None, true).unwrap(); + let converted = build_completion_request(request, None, true, None).unwrap(); let messages = converted.chat_history.iter().collect::>(); let Message::Assistant { content, .. } = messages[0] else { panic!("expected assistant tool call"); @@ -352,7 +350,7 @@ fn request_conversion_places_system_prompt_in_user_message_when_system_role_is_u let mut request = text_request(); request.system_prompt = Some("Be useful".to_string()); - let converted = build_completion_request(request, None, false).unwrap(); + let converted = build_completion_request(request, None, false, None).unwrap(); let messages = converted.chat_history.iter().collect::>(); assert_eq!(messages.len(), 2); @@ -371,3 +369,34 @@ fn request_conversion_places_system_prompt_in_user_message_when_system_role_is_u 0 ); } + +#[test] +fn selected_reasoning_effort_is_sent_without_changing_the_model() { + let request = build_completion_request(text_request(), None, true, Some("high")).unwrap(); + assert_eq!( + request.additional_params, + Some(serde_json::json!({ + "stream_options": {"include_usage": true}, "reasoning_effort": "high" + })) + ); + let default = build_completion_request(text_request(), None, true, None).unwrap(); + assert!(default + .additional_params + .unwrap() + .get("reasoning_effort") + .is_none()); +} + +#[test] +fn reasoning_effort_survives_openai_request_serialization() { + let request = build_completion_request(text_request(), None, true, Some("low")).unwrap(); + let wire = rig_core::providers::openai::completion::CompletionRequest::try_from(( + "test-model".to_string(), + request, + )) + .unwrap(); + let json = serde_json::to_value(wire).unwrap(); + assert_eq!(json["model"], "test-model"); + assert_eq!(json["reasoning_effort"], "low"); + assert_eq!(json["stream_options"]["include_usage"], true); +}