diff --git a/app/src/ai/blocklist/action_model.rs b/app/src/ai/blocklist/action_model.rs index 8156f8bc9bf..69c578a7ac8 100644 --- a/app/src/ai/blocklist/action_model.rs +++ b/app/src/ai/blocklist/action_model.rs @@ -315,6 +315,10 @@ impl BlocklistAIActionModel { self.is_view_only = is_view_only; } + pub(crate) fn terminal_view_id(&self) -> EntityId { + self.terminal_view_id + } + /// Marks an action as remotely executing on the viewer side. /// This is called when a viewer receives a CommandExecutionStarted event from the sharer, /// allowing the viewer's UI to show the action as running even though it's not executing locally. diff --git a/app/src/ai/blocklist/action_model/execute/run_agents.rs b/app/src/ai/blocklist/action_model/execute/run_agents.rs index 379edcc7be7..c707a55ae2a 100644 --- a/app/src/ai/blocklist/action_model/execute/run_agents.rs +++ b/app/src/ai/blocklist/action_model/execute/run_agents.rs @@ -27,12 +27,15 @@ use crate::ai::agent::{ StartAgentExecutionMode, }; use crate::ai::auth_secret_types::auth_secret_types_for_harness; -use crate::ai::blocklist::inline_action::orchestration_controls::OrchestrationEditState; +use crate::ai::blocklist::inline_action::orchestration_controls::{ + normalize_orchestration_model, remote_oz_model_incompatible, OrchestrationEditState, +}; use crate::ai::blocklist::{BlocklistAIHistoryModel, BlocklistAIPermissions}; use crate::ai::cloud_agent_settings::CloudAgentSettings; use crate::ai::document::plan_publication::{ prepare_plan_publications, wait_for_plan_publications, }; +use crate::ai::llms::LLMPreferences; use crate::ai::local_harness_setup::local_harness_product_disabled_message; /// Per-child spawn timeout. If a child agent doesn't report back within @@ -170,7 +173,7 @@ impl RunAgentsExecutor { return receiver; } - if let Err(error) = validate_request(&request) { + if let Err(error) = validate_request(&request, ctx) { log::warn!("RunAgentsExecutor: validation failure: {error}"); let _ = sender.try_send(RunAgentsResult::Failure { error }); return receiver; @@ -414,6 +417,11 @@ impl RunAgentsExecutor { } let mut resolved_request = request.clone(); resolve_request_from_approved_config(&mut resolved_request, input.conversation_id, ctx); + populate_default_orchestration_model_for_execution( + &mut resolved_request, + self.terminal_view_id, + ctx, + ); populate_default_auth_secret_for_execution(&mut resolved_request, ctx); if self .duplicate_launched_agents_reason(&resolved_request, input.conversation_id, ctx) @@ -483,6 +491,7 @@ fn prepare_request_for_execution( ctx: &ModelContext, ) -> Option { let status = resolve_request_from_approved_config(request, parent_conversation_id, ctx); + populate_default_orchestration_model_for_execution(request, terminal_view_id, ctx); populate_default_auth_secret_for_execution(request, ctx); if let Some(reason) = duplicate_launched_agents_reason(request, parent_conversation_id, launched_agents, ctx) @@ -669,9 +678,25 @@ fn populate_default_auth_secret_for_execution( default_auth_secret_name_for_harness(&request.harness_type, ctx); } +fn populate_default_orchestration_model_for_execution( + request: &mut RunAgentsRequest, + terminal_view_id: EntityId, + ctx: &ModelContext, +) { + let profile_model_id = LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(terminal_view_id)) + .map(|model| model.id.clone()); + let mut edit_state = OrchestrationEditState::from_run_agents_fields( + &request.model_id, + &request.harness_type, + &request.execution_mode, + ); + normalize_orchestration_model(&mut edit_state, None, profile_model_id.as_ref()); + request.model_id = edit_state.model_id; +} + /// Unconditionally overrides run-wide fields on a `RunAgentsRequest` -/// from the approved orchestration config, delegating to -/// `OrchestrationEditState::override_from_approved_config`. +/// from the approved orchestration config through the shared normalizer. fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &OrchestrationConfig) { // The approved plan config is the source of truth for these run-wide fields, // so callers pass a mutable request and continue with the normalized value. @@ -680,7 +705,7 @@ fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &Orchestr &request.harness_type, &request.execution_mode, ); - edit_state.override_from_approved_config(config); + normalize_orchestration_model(&mut edit_state, Some(config), None); request.model_id = edit_state.model_id; request.harness_type = edit_state.harness_type; request.execution_mode = edit_state.execution_mode; @@ -688,7 +713,10 @@ fn resolve_request_from_config(request: &mut RunAgentsRequest, config: &Orchestr /// Defence-in-depth validation; mirrors the card view's /// `accept_disabled_reason` check. -fn validate_request(request: &RunAgentsRequest) -> Result<(), String> { +fn validate_request( + request: &RunAgentsRequest, + ctx: &ModelContext, +) -> Result<(), String> { if request.agent_run_configs.is_empty() { return Err("orchestrate: empty agent_run_configs".to_string()); } @@ -706,6 +734,17 @@ fn validate_request(request: &RunAgentsRequest) -> Result<(), String> { { return Err("Remote child agents do not support the opencode harness yet.".to_string()); } + let edit_state = OrchestrationEditState::from_run_agents_fields( + &request.model_id, + &request.harness_type, + &request.execution_mode, + ); + if remote_oz_model_incompatible(&edit_state, LLMPreferences::as_ref(ctx)) { + return Err( + "Selected orchestration model is only available locally. Choose a cloud-runnable model or router to continue." + .to_string(), + ); + } Ok(()) } diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls.rs b/app/src/ai/blocklist/inline_action/orchestration_controls.rs index 29f072cb3b7..fd8edac256f 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls.rs @@ -38,7 +38,7 @@ use crate::ai::connected_self_hosted_workers::{ConnectedSelfHostedWorkersModel, use crate::ai::execution_profiles::model_menu_items::available_model_menu_items; use crate::ai::harness_availability::{AuthSecretFetchState, HarnessAvailabilityModel}; use crate::ai::harness_display; -use crate::ai::llms::LLMInfo; +use crate::ai::llms::{LLMId, LLMInfo}; use crate::ai::local_harness_setup::{ local_harness_is_product_enabled, local_harness_product_disabled_message, local_harness_setup_state, LocalHarnessSetupState, @@ -166,6 +166,36 @@ impl OrchestrationEditState { } } +/// Applies approved-plan precedence, then fills only an omitted Oz/default-harness +/// model from the active execution profile. +pub fn normalize_orchestration_model( + state: &mut OrchestrationEditState, + approved_config: Option<&OrchestrationConfig>, + profile_model_id: Option<&LLMId>, +) { + if let Some(config) = approved_config { + state.override_from_approved_config(config); + } + let harness = Harness::parse_orchestration_harness(&state.harness_type); + if state.model_id.trim().is_empty() && matches!(harness, Some(Harness::Oz) | None) { + if let Some(model_id) = profile_model_id { + state.model_id = model_id.to_string(); + } + } +} + +pub(crate) fn remote_oz_model_incompatible( + state: &OrchestrationEditState, + llm_preferences: &LLMPreferences, +) -> bool { + if !state.execution_mode.is_remote() || state.model_id.trim().is_empty() { + return false; + } + let harness = Harness::parse_orchestration_harness(&state.harness_type); + matches!(harness, Some(Harness::Oz) | None) + && !llm_preferences.is_cloud_runnable_oz_model_id(&LLMId::from(state.model_id.as_str())) +} + impl OrchestrationEditState { pub(crate) fn sanitize_for_local_execution(&mut self) { let Some(harness) = Harness::parse_local_child_harness(&self.harness_type) else { @@ -268,26 +298,6 @@ impl OrchestrationEditState { } } - /// Fills in empty fields from the approved orchestration config. - /// When the LLM omits harness/model/execution_mode to inherit from - /// the active config, the raw request arrives with defaults (empty - /// harness, empty model, Local mode). This resolves those to the - /// config values so the UI shows the intended settings. - pub fn resolve_from_config(&mut self, config: &OrchestrationConfig) { - if self.harness_type.is_empty() && !config.harness_type.is_empty() { - self.harness_type = config.harness_type.clone(); - } - if self.model_id.is_empty() && !config.model_id.is_empty() { - self.model_id = config.model_id.clone(); - } - if !self.execution_mode.is_remote() && config.execution_mode.is_remote() { - self.execution_mode = Self::from_orchestration_config(config).execution_mode; - } - if matches!(self.execution_mode, RunAgentsExecutionMode::Local) { - self.sanitize_for_local_execution(); - } - } - /// Unconditionally overrides model, harness, and execution mode /// from the approved orchestration config. The plan config is the /// user-approved source of truth — the LLM's run_agents call may @@ -481,7 +491,7 @@ fn get_base_model_choices<'a>( ) -> impl Iterator { llm_prefs .get_base_llm_choices_for_agent_mode(app) - .filter(move |llm| is_local || llm_prefs.custom_llm_info_for_id(&llm.id).is_none()) + .filter(move |llm| is_local || llm_prefs.is_cloud_runnable_oz_model_id(&llm.id)) } /// Populates the model picker based on the active harness. /// @@ -630,14 +640,14 @@ pub fn is_model_in_filtered_choices( /// string (the "Default model" entry). pub fn first_filtered_model_id( harness_type: &str, + is_local: bool, ctx: &mut ViewContext, ) -> Option { let harness = Harness::parse_orchestration_harness(harness_type); match harness { Some(Harness::Oz) | None => { let llm_prefs = LLMPreferences::as_ref(ctx); - llm_prefs - .get_base_llm_choices_for_agent_mode(ctx) + get_base_model_choices(llm_prefs, ctx, is_local) .next() .map(|llm| llm.id.to_string()) } @@ -1174,6 +1184,12 @@ pub fn accept_disabled_reason_with_auth( if auth_secret_selection_required(state, ctx) { return Some("Select an API key for this harness to continue.".to_string()); } + if remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx)) { + return Some( + "Selected orchestration model is only available locally. Choose a cloud-runnable model or router to continue." + .to_string(), + ); + } None } @@ -1402,12 +1418,12 @@ pub fn apply_harness_change( .cloned(); if let Some(saved_id) = restored { state.model_id = saved_id; - } else if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { - // No saved model — fall back to conversation base model - // for Oz, or default for non-Oz. + } else if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) + && !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx)) + { let reset_id = fallback_base_model_id(ctx) .filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx)) - .or_else(|| first_filtered_model_id(&state.harness_type, ctx)) + .or_else(|| first_filtered_model_id(&state.harness_type, is_local, ctx)) .unwrap_or_default(); state.model_id = reset_id; } @@ -1459,10 +1475,12 @@ pub fn apply_execution_mode_change( } } } - if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { + if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) + && !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx)) + { let reset_id = fallback_base_model_id(ctx) .filter(|id| is_model_in_filtered_choices(id, &state.harness_type, is_local, ctx)) - .or_else(|| first_filtered_model_id(&state.harness_type, ctx)) + .or_else(|| first_filtered_model_id(&state.harness_type, is_local, ctx)) .unwrap_or_default(); state.model_id = reset_id; } @@ -1504,8 +1522,10 @@ pub fn repopulate_all_pickers( populate_harness_picker(handle, &state.harness_type, is_local, ctx); } // Reset model if it disappeared from the harness's catalog. - if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) { - if let Some(first_id) = first_filtered_model_id(&state.harness_type, ctx) { + if !is_model_in_filtered_choices(&state.model_id, &state.harness_type, is_local, ctx) + && !remote_oz_model_incompatible(state, LLMPreferences::as_ref(ctx)) + { + if let Some(first_id) = first_filtered_model_id(&state.harness_type, is_local, ctx) { state.model_id = first_id; } } diff --git a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs index 96c47fb96ee..3ba97018583 100644 --- a/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs +++ b/app/src/ai/blocklist/inline_action/orchestration_controls_tests.rs @@ -2,9 +2,10 @@ use ai::agent::action::RunAgentsExecutionMode; use ai::agent::orchestration_config::{OrchestrationConfig, OrchestrationExecutionMode}; use super::{ - should_show_auth_secret_picker, should_show_harness_picker, AuthSecretSelection, - OrchestrationEditState, + normalize_orchestration_model, should_show_auth_secret_picker, should_show_harness_picker, + AuthSecretSelection, OrchestrationEditState, }; +use crate::ai::llms::LLMId; fn remote_claude_state() -> OrchestrationEditState { OrchestrationEditState::from_run_agents_fields( @@ -140,35 +141,6 @@ fn accept_disabled_reason_allows_local_claude_product() { assert_eq!(state.accept_disabled_reason(), None); } -#[test] -fn resolve_from_config_preserves_local_claude() { - let mut state = - OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); - - state.resolve_from_config(&local_config("claude", "sonnet")); - assert_eq!(state.harness_type, "claude"); - assert_eq!(state.model_id, "sonnet"); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - -#[test] -fn resolve_from_config_sanitizes_disabled_local_codex() { - let mut state = - OrchestrationEditState::from_run_agents_fields("", "", &RunAgentsExecutionMode::Local); - - state.resolve_from_config(&local_config("codex", "gpt-5")); - - assert_eq!(state.harness_type, "oz"); - assert_eq!(state.model_id, ""); - assert!(matches!( - state.execution_mode, - RunAgentsExecutionMode::Local - )); -} - #[test] fn select_create_new_auth_secret_marks_creating_new_from_named() { let mut state = remote_claude_state(); @@ -198,3 +170,97 @@ fn select_create_new_auth_secret_marks_creating_new_from_inherit() { AuthSecretSelection::CreatingNew )); } + +#[test] +fn orchestration_model_precedence_uses_approved_config_first() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "tool-model", + "oz", + &RunAgentsExecutionMode::Local, + ); + let approved = local_config("oz", "approved-model"); + let profile_model = LLMId::from("profile-model"); + + normalize_orchestration_model(&mut state, Some(&approved), Some(&profile_model)); + + assert_eq!(state.model_id, "approved-model"); +} + +#[test] +fn orchestration_model_precedence_preserves_explicit_tool_model() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "tool-model", + "oz", + &RunAgentsExecutionMode::Local, + ); + let profile_model = LLMId::from("profile-model"); + + normalize_orchestration_model(&mut state, None, Some(&profile_model)); + + assert_eq!(state.model_id, "tool-model"); +} + +#[test] +fn orchestration_model_precedence_fills_omitted_oz_model_from_profile() { + let mut state = + OrchestrationEditState::from_run_agents_fields("", "oz", &RunAgentsExecutionMode::Local); + let profile_model = LLMId::from("profile-model"); + + normalize_orchestration_model(&mut state, None, Some(&profile_model)); + + assert_eq!(state.model_id, "profile-model"); +} + +#[test] +fn orchestration_model_precedence_fills_empty_approved_oz_config_from_profile() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "tool-model", + "oz", + &RunAgentsExecutionMode::Local, + ); + let approved = local_config("oz", ""); + let profile_model = LLMId::from("profile-model"); + + normalize_orchestration_model(&mut state, Some(&approved), Some(&profile_model)); + + assert_eq!(state.model_id, "profile-model"); +} + +#[test] +fn orchestration_model_precedence_leaves_model_empty_without_usable_profile_default() { + let mut state = + OrchestrationEditState::from_run_agents_fields("", "oz", &RunAgentsExecutionMode::Local); + + normalize_orchestration_model(&mut state, None, None); + + assert!(state.model_id.is_empty()); +} + +#[test] +fn orchestration_model_precedence_does_not_inject_profile_model_for_third_party_harness() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "", + "claude", + &RunAgentsExecutionMode::Local, + ); + let profile_model = LLMId::from("auto"); + + normalize_orchestration_model(&mut state, None, Some(&profile_model)); + + assert!(state.model_id.is_empty()); +} + +#[test] +fn orchestration_model_precedence_does_not_inject_for_approved_third_party_config() { + let mut state = OrchestrationEditState::from_run_agents_fields( + "tool-model", + "oz", + &RunAgentsExecutionMode::Local, + ); + let approved = local_config("claude", ""); + let profile_model = LLMId::from("auto"); + + normalize_orchestration_model(&mut state, Some(&approved), Some(&profile_model)); + + assert!(state.model_id.is_empty()); +} diff --git a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs index 1f881671aee..93571ddb156 100644 --- a/app/src/ai/blocklist/inline_action/run_agents_card_view.rs +++ b/app/src/ai/blocklist/inline_action/run_agents_card_view.rs @@ -238,6 +238,7 @@ pub struct RunAgentsCardView { /// stream. Used at decision time to diff the run-wide config /// fields the user changed before accepting. original_tool_call_request: RunAgentsRequest, + terminal_view_id: warpui::EntityId, /// Guards `OrchestrationEntered` against double-fires on re-renders. entered_event_emitted: bool, /// Guards the terminal decision event against double-fires. @@ -252,23 +253,9 @@ pub struct RunAgentsCardView { /// for the picker display and should NOT run before auto-launch /// matching. /// -/// 1. Defaults the Oz model to the conversation's base model. -/// 2. Defaults Remote worker_host to "warp". -/// 3. Defaults a Remote environment from settings / recency. -fn resolve_interactive_defaults( - state: &mut RunAgentsEditState, - block_model: &dyn AIBlockModel, - ctx: &AppContext, -) { - if state.orch.model_id.is_empty() { - let harness = - warp_cli::agent::Harness::parse_orchestration_harness(&state.orch.harness_type); - if matches!(harness, Some(warp_cli::agent::Harness::Oz) | None) { - if let Some(base) = block_model.base_model(ctx).map(|id| id.to_string()) { - state.orch.model_id = base; - } - } - } +/// 1. Defaults Remote worker_host to "warp". +/// 2. Defaults a Remote environment from settings / recency. +fn resolve_interactive_defaults(state: &mut RunAgentsEditState, ctx: &AppContext) { if let RunAgentsExecutionMode::Remote { environment_id, worker_host, @@ -303,7 +290,19 @@ impl RunAgentsCardView { block_model: Rc>, ctx: &mut ViewContext, ) -> Self { - let state = RunAgentsEditState::from_request(request); + let terminal_view_id = action_model.as_ref(ctx).terminal_view_id(); + let mut state = RunAgentsEditState::from_request(request); + let approved_config = active_config + .as_ref() + .and_then(|(config, status)| status.is_approved().then_some(config)); + let profile_model_id = LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(terminal_view_id)) + .map(|model| model.id.clone()); + oc::normalize_orchestration_model( + &mut state.orch, + approved_config, + profile_model_id.as_ref(), + ); // Snapshot the raw incoming request so we can diff against the // edited state at Accept time. let original_tool_call_request = request.clone(); @@ -384,7 +383,7 @@ impl RunAgentsCardView { // ready for user confirmation. Re-render so the card // transitions from the "Configuring agents..." placeholder // to the full confirmation UI. - resolve_interactive_defaults(&mut me.state, &*me.block_model, ctx); + resolve_interactive_defaults(&mut me.state, ctx); oc::repopulate_all_pickers(&mut me.state.orch, &me.handles.pickers, ctx); me.refresh_accept_button_state(ctx); me.maybe_auto_open_create_modal(ctx); @@ -491,6 +490,7 @@ impl RunAgentsCardView { saved_model_per_harness: HashMap::new(), create_environment_modal, original_tool_call_request, + terminal_view_id, entered_event_emitted: false, decision_event_emitted: false, has_auto_opened_create_modal: false, @@ -514,21 +514,18 @@ impl RunAgentsCardView { // streamed chunk. self.original_tool_call_request = request.clone(); let mut new_state = RunAgentsEditState::from_request(request); - // Resolve empty fields from the active config (same as in new()). - if let Some((config, status)) = &self.active_config { - if status.is_approved() { - new_state.orch.resolve_from_config(config); - } - } - if new_state.orch.model_id.is_empty() { - let harness = - warp_cli::agent::Harness::parse_orchestration_harness(&new_state.orch.harness_type); - if matches!(harness, Some(warp_cli::agent::Harness::Oz) | None) { - if let Some(base) = self.block_model.base_model(ctx).map(|id| id.to_string()) { - new_state.orch.model_id = base; - } - } - } + let approved_config = self + .active_config + .as_ref() + .and_then(|(config, status)| status.is_approved().then_some(config)); + let profile_model_id = LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(self.terminal_view_id)) + .map(|model| model.id.clone()); + oc::normalize_orchestration_model( + &mut new_state.orch, + approved_config, + profile_model_id.as_ref(), + ); // Re-seed an Unset selection from persisted per-harness settings, // honoring an explicit `Inherit` choice for this harness. if matches!( @@ -639,6 +636,7 @@ impl RunAgentsCardView { execution_mode: OrchestrationExecutionModeKind::from_run_agents( &self.state.orch.execution_mode, ), + model_id: self.state.orch.model_id.clone(), modified_fields_from_tool_call, modified_fields_from_active_config, had_active_config, @@ -720,10 +718,9 @@ impl RunAgentsCardView { let appearance = Appearance::as_ref(ctx); let (styles, colors) = oc::picker_styles(appearance); - let initial_model_id_default = self - .block_model - .base_model(ctx) - .map(|id| id.to_string()) + let initial_model_id_default = LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(self.terminal_view_id)) + .map(|model| model.id.to_string()) .unwrap_or_default(); let state = &self.state; @@ -1061,12 +1058,16 @@ impl TypedActionView for RunAgentsCardView { ctx.emit(RunAgentsCardViewEvent::RejectRequested); } RunAgentsCardViewAction::ExecutionModeToggled { is_remote } => { - let block_model = self.block_model.clone(); + let terminal_view_id = self.terminal_view_id; oc::apply_execution_mode_change( &mut self.state.orch, &self.handles.pickers, *is_remote, - |ctx| block_model.base_model(ctx).map(|id| id.to_string()), + |ctx| { + LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(terminal_view_id)) + .map(|model| model.id.to_string()) + }, ctx, ); // Mode change can newly reveal the auth picker (Local @@ -1082,13 +1083,17 @@ impl TypedActionView for RunAgentsCardView { ctx.notify(); } RunAgentsCardViewAction::HarnessChanged { harness_type } => { - let block_model = self.block_model.clone(); + let terminal_view_id = self.terminal_view_id; oc::apply_harness_change( &mut self.state.orch, &mut self.saved_model_per_harness, &self.handles.pickers, harness_type, - |ctx| block_model.base_model(ctx).map(|id| id.to_string()), + |ctx| { + LLMPreferences::as_ref(ctx) + .get_active_orchestration_model(ctx, Some(terminal_view_id)) + .map(|model| model.id.to_string()) + }, ctx, ); // Harness change resets per-harness selection state, so diff --git a/app/src/ai/blocklist/permissions.rs b/app/src/ai/blocklist/permissions.rs index 0f075958b26..75d889e8524 100644 --- a/app/src/ai/blocklist/permissions.rs +++ b/app/src/ai/blocklist/permissions.rs @@ -201,6 +201,7 @@ impl BlocklistAIPermissions { name: profile_data.name.clone(), is_default_profile: profile_data.is_default_profile, base_model: profile_data.base_model.clone(), + orchestration_model: profile_data.orchestration_model.clone(), coding_model: profile_data.coding_model.clone(), cli_agent_model: profile_data.cli_agent_model.clone(), computer_use_model: profile_data.computer_use_model.clone(), diff --git a/app/src/ai/blocklist/telemetry.rs b/app/src/ai/blocklist/telemetry.rs index 28452dfc955..e508f4c928e 100644 --- a/app/src/ai/blocklist/telemetry.rs +++ b/app/src/ai/blocklist/telemetry.rs @@ -171,6 +171,7 @@ pub(crate) struct RunAgentsCardDecisionEvent { pub agent_count: usize, pub harness: OrchestrationHarnessKind, pub execution_mode: OrchestrationExecutionModeKind, + pub model_id: String, /// Field names from [`orchestration_modified_field`] that diverged /// between the dispatched request and the LLM's original /// `RunAgentsRequest`. Empty when the user accepted without edits. diff --git a/app/src/ai/execution_profiles/editor/mod.rs b/app/src/ai/execution_profiles/editor/mod.rs index 0fceabd7c43..40bb850d3d7 100644 --- a/app/src/ai/execution_profiles/editor/mod.rs +++ b/app/src/ai/execution_profiles/editor/mod.rs @@ -152,6 +152,9 @@ pub enum ExecutionProfileEditorViewAction { SetBaseModel { id: LLMId, }, + SetOrchestrationModel { + id: LLMId, + }, /// Fired continuously while the user drags the context window slider. ContextWindowSliderDragged { value: u32, @@ -241,6 +244,7 @@ pub struct ExecutionProfileEditorView { focus_handle: Option, clipped_scroll_state: ClippedScrollStateHandle, base_model_dropdown: ViewHandle>, + orchestration_model_dropdown: ViewHandle>, context_window_slider_state: SliderStateHandle, context_window_editor: ViewHandle, last_synced_context_window_editor_value: Option, @@ -536,6 +540,11 @@ impl ExecutionProfileEditorView { dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); dropdown }); + let orchestration_model_dropdown = ctx.add_typed_action_view(|ctx| { + let mut dropdown = FilterableDropdown::new(ctx); + dropdown.set_menu_width(MODEL_MENU_WIDTH, ctx); + dropdown + }); // Initialize the context window editor buffer with the profile's // persisted limit (or the active model's max as a sensible default). @@ -648,6 +657,7 @@ impl ExecutionProfileEditorView { focus_handle: None, clipped_scroll_state: Default::default(), base_model_dropdown, + orchestration_model_dropdown, context_window_slider_state, context_window_editor, last_synced_context_window_editor_value, @@ -761,6 +771,21 @@ impl ExecutionProfileEditorView { &me.upgrade_footer_mouse_state, ctx, ); + Self::refresh_filterable_model_dropdown( + &me.orchestration_model_dropdown, + current_permissions.orchestration_model.clone(), + |prefs, app| prefs.get_orchestration_llm_choices(app).collect_vec(), + |id| ExecutionProfileEditorViewAction::SetOrchestrationModel { id }, + |prefs, app| { + prefs + .get_default_orchestration_model(app) + .unwrap_or_else(|| prefs.get_default_base_model(app)) + .id + .clone() + }, + &me.upgrade_footer_mouse_state, + ctx, + ); Self::refresh_coding_model_dropdown( &me.coding_model_dropdown, current_permissions.coding_model.clone(), @@ -824,6 +849,21 @@ impl ExecutionProfileEditorView { &me.upgrade_footer_mouse_state, ctx, ); + Self::refresh_filterable_model_dropdown( + &me.orchestration_model_dropdown, + current_permissions.orchestration_model.clone(), + |prefs, app| prefs.get_orchestration_llm_choices(app).collect_vec(), + |id| ExecutionProfileEditorViewAction::SetOrchestrationModel { id }, + |prefs, app| { + prefs + .get_default_orchestration_model(app) + .unwrap_or_else(|| prefs.get_default_base_model(app)) + .id + .clone() + }, + &me.upgrade_footer_mouse_state, + ctx, + ); Self::refresh_coding_model_dropdown( &me.coding_model_dropdown, current_permissions.coding_model.clone(), @@ -940,6 +980,21 @@ impl ExecutionProfileEditorView { &self.upgrade_footer_mouse_state, ctx, ); + Self::refresh_filterable_model_dropdown( + &self.orchestration_model_dropdown, + current_permissions.orchestration_model.clone(), + |prefs, app| prefs.get_orchestration_llm_choices(app).collect_vec(), + |id| ExecutionProfileEditorViewAction::SetOrchestrationModel { id }, + |prefs, app| { + prefs + .get_default_orchestration_model(app) + .unwrap_or_else(|| prefs.get_default_base_model(app)) + .id + .clone() + }, + &self.upgrade_footer_mouse_state, + ctx, + ); Self::refresh_coding_model_dropdown( &self.coding_model_dropdown, current_permissions.coding_model.clone(), @@ -1573,6 +1628,12 @@ impl TypedActionView for ExecutionProfileEditorView { self.sync_context_window_editor(ctx, true); ctx.notify(); } + ExecutionProfileEditorViewAction::SetOrchestrationModel { id } => { + AIExecutionProfilesModel::handle(ctx).update(ctx, |profiles_model, ctx| { + profiles_model.set_orchestration_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/ui_helpers.rs b/app/src/ai/execution_profiles/editor/ui_helpers.rs index 13d1ec35ad3..214b3b3d938 100644 --- a/app/src/ai/execution_profiles/editor/ui_helpers.rs +++ b/app/src/ai/execution_profiles/editor/ui_helpers.rs @@ -274,6 +274,12 @@ pub fn render_models_section( if let Some(row) = render_context_window_row(appearance, view, app) { column.add_child(row); } + column = column.with_child(render_filterable_dropdown_row( + appearance, + "Orchestration model", + "The default model or routing strategy used for child agents when neither the agent nor an approved plan specifies one.", + &view.orchestration_model_dropdown, + )); column = column.with_child(render_filterable_dropdown_row( appearance, diff --git a/app/src/ai/execution_profiles/profiles.rs b/app/src/ai/execution_profiles/profiles.rs index 23c23c14f14..29e41630a0f 100644 --- a/app/src/ai/execution_profiles/profiles.rs +++ b/app/src/ai/execution_profiles/profiles.rs @@ -564,6 +564,34 @@ impl AIExecutionProfilesModel { } } + pub fn set_orchestration_model( + &mut self, + profile_id: ClientProfileId, + model_id: Option, + ctx: &mut ModelContext, + ) { + self.edit_profile_internal( + profile_id, + |profile| { + if profile.orchestration_model != model_id { + profile.orchestration_model = model_id.clone(); + return true; + } + false + }, + ctx, + ); + + if let Some(model_id) = &model_id { + send_telemetry_from_ctx!( + TelemetryEvent::AIExecutionProfileModelSelected { + model_type: "orchestration".to_string(), + model_value: model_id.to_string(), + }, + ctx + ); + } + } pub fn set_cli_agent_model( &mut self, profile_id: ClientProfileId, diff --git a/app/src/ai/llms.rs b/app/src/ai/llms.rs index 1ec384294f8..83fa90766ab 100644 --- a/app/src/ai/llms.rs +++ b/app/src/ai/llms.rs @@ -810,6 +810,23 @@ impl LLMPreferences { ) -> &'a LLMInfo { self.get_preferred_base_model(app, terminal_view_id) } + /// Returns the effective execution-profile default for orchestrated Oz children. + pub fn get_active_orchestration_model<'a>( + &'a self, + app: &'a AppContext, + terminal_view_id: Option, + ) -> Option<&'a LLMInfo> { + let profile = AIExecutionProfilesModel::as_ref(app).active_profile(terminal_view_id, app); + + profile + .data() + .orchestration_model + .as_ref() + .and_then(|id| { + self.usable_model_info_for_id(&self.models_by_feature.agent_mode, id, app) + }) + .or_else(|| self.get_default_orchestration_model(app)) + } /// Returns `LLMInfo` for the currently selected LLM to be used for Agent Mode. fn get_preferred_base_model( @@ -879,6 +896,22 @@ impl LLMPreferences { .or_else(|| self.custom_router_llm_info_for_id_if_enabled(id)) } + fn usable_model_info_for_id<'a>( + &'a self, + available: &'a AvailableLLMs, + id: &LLMId, + app: &AppContext, + ) -> Option<&'a LLMInfo> { + available + .usable_info_for_id(id, app) + .filter(|info| { + FeatureFlag::CustomModelRouters.is_enabled() + || !custom_model_routers::is_cloud_custom_router_id(info.id.as_str()) + }) + .or_else(|| self.custom_llm_info_for_id_if_enabled(id, app)) + .or_else(|| self.custom_router_llm_info_for_id_if_enabled(id)) + } + /// Resolves the TUI's file-backed `agents.model` setting (the /// `TuiAgentModel` setting) to an `LLMInfo`. /// @@ -964,6 +997,14 @@ impl LLMPreferences { .chain(self.custom_router_choices()) } + /// Returns Agent Mode's model and router catalog for orchestration defaults. + pub fn get_orchestration_llm_choices( + &self, + app: &AppContext, + ) -> impl Iterator { + self.get_base_llm_choices_for_agent_mode(app) + } + /// Returns the set of LLMs available for coding. pub fn get_coding_llm_choices(&self, app: &AppContext) -> impl Iterator { // Don't show admin-disabled models in the dropdown @@ -1281,6 +1322,7 @@ impl LLMPreferences { .collect(); let mut updated_agent_mode = false; + let mut updated_orchestration = false; let mut updated_coding = false; self.base_llm_for_terminal_view.retain(|_, id| { @@ -1305,6 +1347,15 @@ impl LLMPreferences { profiles.set_context_window_limit(profile_id, None, ctx); updated_agent_mode = true; } + let orchestration_stale = + profile_data.orchestration_model.as_ref().is_some_and(|id| { + custom_model_routers::is_local_custom_router_id(id.as_str()) + && !valid_local.contains(id) + }); + if orchestration_stale { + profiles.set_orchestration_model(profile_id, None, ctx); + updated_orchestration = true; + } let coding_stale = profile_data.coding_model.as_ref().is_some_and(|id| { custom_model_routers::is_local_custom_router_id(id.as_str()) && !valid_local.contains(id) @@ -1320,6 +1371,9 @@ impl LLMPreferences { self.trigger_snapshot_save(ctx); ctx.emit(LLMPreferencesEvent::UpdatedActiveAgentModeLLM); } + if updated_orchestration { + ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); + } if updated_coding { ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM); } @@ -1343,6 +1397,7 @@ impl LLMPreferences { .map(|info| info.id.clone()) .collect(); let mut updated_agent_mode = false; + let mut updated_orchestration = false; let mut updated_coding = false; let mut updated_other = false; @@ -1368,6 +1423,14 @@ impl LLMPreferences { profiles.set_context_window_limit(profile_id, None, ctx); updated_agent_mode = true; } + if profile_data + .orchestration_model + .as_ref() + .is_some_and(|id| custom_ids.contains(id)) + { + profiles.set_orchestration_model(profile_id, None, ctx); + updated_orchestration = true; + } if profile_data .coding_model .as_ref() @@ -1402,7 +1465,7 @@ impl LLMPreferences { if updated_coding { ctx.emit(LLMPreferencesEvent::UpdatedActiveCodingLLM); } - if updated_other { + if updated_orchestration || updated_other { ctx.emit(LLMPreferencesEvent::UpdatedAvailableLLMs); } } @@ -1413,6 +1476,19 @@ impl LLMPreferences { self.fallback_llm_info(&self.models_by_feature.agent_mode, app) } + /// Returns built-in `auto` when usable, then Agent Mode's disable-aware fallback. + pub fn get_default_orchestration_model(&self, app: &AppContext) -> Option<&LLMInfo> { + let auto_id = LLMId::from("auto"); + self.models_by_feature + .agent_mode + .usable_info_for_id(&auto_id, app) + .or_else(|| { + self.models_by_feature + .agent_mode + .usable_default_llm_info(app) + }) + } + /// Returns the effective default coding model as a fallback /// (disable-aware, see [`Self::fallback_llm_info`]). pub fn get_default_coding_model(&self, app: &AppContext) -> &LLMInfo { @@ -1729,6 +1805,18 @@ impl LLMPreferences { { profiles.set_context_window_limit(profile_id, None, ctx); } + if let Some(preferred_llm_id) = &profile.data().orchestration_model { + if self + .usable_model_info_for_id( + &self.models_by_feature.agent_mode, + preferred_llm_id, + ctx, + ) + .is_none() + { + profiles.set_orchestration_model(profile_id, None, ctx); + } + } if let Some(preferred_llm_id) = &profile.data().coding_model { if self .models_by_feature diff --git a/crates/cloud_object_models/src/ai_execution_profile.rs b/crates/cloud_object_models/src/ai_execution_profile.rs index 44889dda17b..fae7a1236fd 100644 --- a/crates/cloud_object_models/src/ai_execution_profile.rs +++ b/crates/cloud_object_models/src/ai_execution_profile.rs @@ -378,6 +378,7 @@ pub struct AIExecutionProfile { pub computer_use: ComputerUsePermission, pub base_model: Option, + pub orchestration_model: Option, pub coding_model: Option, pub cli_agent_model: Option, pub computer_use_model: Option, @@ -410,6 +411,7 @@ impl Default for AIExecutionProfile { mcp_denylist: Vec::new(), computer_use: ComputerUsePermission::Never, base_model: None, + orchestration_model: None, coding_model: None, cli_agent_model: None, computer_use_model: None, @@ -440,6 +442,7 @@ impl AIExecutionProfile { mcp_denylist: Vec::new(), computer_use: ComputerUsePermission::Never, base_model: None, + orchestration_model: None, coding_model: None, cli_agent_model: None, computer_use_model: None, @@ -496,6 +499,7 @@ impl AIExecutionProfile { mcp_denylist: Vec::new(), computer_use: computer_use_permission, base_model: None, + orchestration_model: None, coding_model: None, cli_agent_model: None, computer_use_model: None, @@ -517,3 +521,7 @@ pub type CloudAIExecutionProfile = pub type CloudAIExecutionProfileModel = GenericStringModel; pub type ServerAIExecutionProfile = GenericServerObject; + +#[cfg(test)] +#[path = "ai_execution_profile_tests.rs"] +mod tests;