diff --git a/app/src/ai/agent_sdk/ambient.rs b/app/src/ai/agent_sdk/ambient.rs index dd54e39a75..7681f04b61 100644 --- a/app/src/ai/agent_sdk/ambient.rs +++ b/app/src/ai/agent_sdk/ambient.rs @@ -418,6 +418,7 @@ impl AmbientAgentRunner { let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig { harness_type: args.harness, model_id: None, + reasoning_level: None, }); let harness_auth_secrets = args.claude_auth_secret.clone().map(|name| { crate::ai::ambient_agents::task::HarnessAuthSecretsConfig { diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 5257bee412..ba2ccadc0f 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -66,8 +66,8 @@ use crate::{ CancellationReason, RenderableAIError, RequestFileEditsResult, }, ambient_agents::{ - conversation_output_status_from_conversation, AmbientAgentTaskId, - AmbientConversationStatus, + conversation_output_status_from_conversation, task::HarnessModelConfig, + AmbientAgentTaskId, AmbientConversationStatus, }, blocklist::{ agent_view::AgentViewEntryOrigin, @@ -245,8 +245,8 @@ pub struct AgentDriverOptions { pub environment: Option, /// Selected execution harness for this run. pub selected_harness: Harness, - /// Model ID for the selected harness. Only used for non-Oz harnesses. - pub third_party_harness_model_id: Option, + /// Model config for the selected harness. Only used for non-Oz harnesses. + pub third_party_harness_model_config: Option, /// Whether to skip end-of-run snapshot upload. pub snapshot_disabled: Option, /// End-of-run snapshot upload timeout override. @@ -316,7 +316,7 @@ pub struct AgentDriver { /// conversation's `parent_agent_id` field at register time so the /// streamer recognizes the child role in driver-hosted processes. parent_run_id: Option, - third_party_harness_model_id: Option, + third_party_harness_model_config: Option, /// Async writer that records `file` declarations for paths the agent creates or edits /// via `RequestFileEdits`. `Some` only when `FeatureFlag::OzHandoff` is enabled, the run @@ -518,7 +518,7 @@ impl AgentDriver { cloud_providers, environment, selected_harness, - third_party_harness_model_id, + third_party_harness_model_config, snapshot_disabled, snapshot_upload_timeout, snapshot_script_timeout, @@ -573,7 +573,7 @@ impl AgentDriver { )); env_vars.extend(harness_model_env_vars( selected_harness, - third_party_harness_model_id.as_deref(), + third_party_harness_model_config.as_ref(), )); // Signal to third-party harnesses (e.g. Claude Code) that we're in a sandbox @@ -646,7 +646,7 @@ impl AgentDriver { .unwrap_or(snapshot::DEFAULT_DECLARATIONS_SCRIPT_TIMEOUT), run_conversation_id, parent_run_id: parent_run_id_for_self, - third_party_harness_model_id, + third_party_harness_model_config, snapshot_file_writer, }) } @@ -685,7 +685,7 @@ impl AgentDriver { snapshot_script_timeout: snapshot::DEFAULT_DECLARATIONS_SCRIPT_TIMEOUT, run_conversation_id: None, parent_run_id: None, - third_party_harness_model_id: None, + third_party_harness_model_config: None, snapshot_file_writer: None, } } @@ -1960,11 +1960,11 @@ impl AgentDriver { } }; - let (secrets, third_party_harness_model_id) = foreground + let (secrets, third_party_harness_model_config) = foreground .spawn(|me, _| { ( Arc::clone(&me.secrets), - me.third_party_harness_model_id.clone(), + me.third_party_harness_model_config.clone(), ) }) .await @@ -2011,7 +2011,7 @@ impl AgentDriver { &resolved_env_vars, &secrets_for_harness, &resolved_mcp_servers, - third_party_harness_model_id.as_deref(), + third_party_harness_model_config.as_ref(), )? .into(); diff --git a/app/src/ai/agent_sdk/driver/harness/claude_code.rs b/app/src/ai/agent_sdk/driver/harness/claude_code.rs index 506125d96a..992681c04a 100644 --- a/app/src/ai/agent_sdk/driver/harness/claude_code.rs +++ b/app/src/ai/agent_sdk/driver/harness/claude_code.rs @@ -15,7 +15,7 @@ use warp_cli::agent::Harness; use warpui::{ModelHandle, ModelSpawner}; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::ambient_agents::{task::HarnessModelConfig, AmbientAgentTaskId}; use crate::ai::mcp::JSONTransportType; use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient}; use crate::server::server_api::ServerApi; @@ -106,7 +106,7 @@ impl ThirdPartyHarness for ClaudeHarness { resolved_env_vars: &HashMap, _resolved_secrets: &HashMap, resolved_mcp_servers: &HashMap, - _third_party_harness_model_id: Option<&str>, + _third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> Result, AgentDriverError> { // Prepare the environment config files. prepare_claude_environment_config(working_dir, resolved_env_vars).map_err(|error| { diff --git a/app/src/ai/agent_sdk/driver/harness/codex.rs b/app/src/ai/agent_sdk/driver/harness/codex.rs index ab3e46a5cf..c64742dd4b 100644 --- a/app/src/ai/agent_sdk/driver/harness/codex.rs +++ b/app/src/ai/agent_sdk/driver/harness/codex.rs @@ -15,7 +15,7 @@ use warp_cli::agent::Harness; use warpui::{ModelHandle, ModelSpawner, SingletonEntity}; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::ambient_agents::{task::HarnessModelConfig, AmbientAgentTaskId}; use crate::ai::mcp::JSONTransportType; use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient}; use crate::server::server_api::ServerApi; @@ -90,7 +90,7 @@ impl ThirdPartyHarness for CodexHarness { resolved_env_vars: &HashMap, resolved_secrets: &HashMap, resolved_mcp_servers: &HashMap, - third_party_harness_model_id: Option<&str>, + third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> Result, AgentDriverError> { // Prepare the environment config files. prepare_codex_environment_config( @@ -99,7 +99,7 @@ impl ThirdPartyHarness for CodexHarness { resolved_env_vars, resolved_secrets, resolved_mcp_servers, - third_party_harness_model_id, + third_party_harness_model_config, ) .map_err(|error| AgentDriverError::HarnessConfigSetupFailed { harness: self.cli_agent().command_prefix().to_owned(), @@ -452,6 +452,7 @@ const CODEX_TRUST_LEVEL_TRUSTED: &str = "trusted"; const CODEX_OPENAI_BASE_URL_KEY: &str = "openai_base_url"; const CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY: &str = "check_for_update_on_startup"; const CODEX_MODEL_KEY: &str = "model"; +const CODEX_MODEL_REASONING_EFFORT_KEY: &str = "model_reasoning_effort"; /// Target model for the `[notice.model_migrations]` table that suppresses Codex's /// "choose a newer model" upgrade prompt at session launch. We stamp this for any /// pinned model id (even when it already matches the target) so the unattended @@ -466,7 +467,7 @@ fn prepare_codex_environment_config( resolved_env_vars: &HashMap, resolved_secrets: &HashMap, resolved_mcp_servers: &HashMap, - third_party_harness_model_id: Option<&str>, + third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> Result<()> { let home_dir = dirs::home_dir().ok_or_else(|| anyhow::anyhow!("could not determine home directory"))?; @@ -490,7 +491,7 @@ fn prepare_codex_environment_config( &codex_dir.join(CODEX_CONFIG_TOML_FILE_NAME), working_dir, resolved_mcp_servers, - third_party_harness_model_id, + third_party_harness_model_config, openai_base_url.as_deref(), )?; Ok(()) @@ -634,14 +635,14 @@ fn resolve_openai_base_url_from_secret( /// field), write it to config.toml. When absent, skip the key entirely so /// Codex uses the provider's default global endpoint. /// - update checks: disable Codex's startup update prompt for unattended runs. -/// - model override: when a non-default `third_party_harness_model_id` is +/// - model override: when a non-default harness model config is /// supplied, write the top-level `model` key so Codex pins the chosen model /// for new sessions. fn prepare_codex_config_toml( config_toml_path: &Path, working_dir: &Path, resolved_mcp_servers: &HashMap, - third_party_harness_model_id: Option<&str>, + third_party_harness_model_config: Option<&HarnessModelConfig>, openai_base_url: Option<&str>, ) -> Result<()> { let existing = match fs::read_to_string(config_toml_path) { @@ -666,7 +667,8 @@ fn prepare_codex_config_toml( set_codex_openai_base_url(&mut doc, url); } set_codex_check_for_update_on_startup(&mut doc, false); - set_codex_model(&mut doc, third_party_harness_model_id); + set_codex_model(&mut doc, third_party_harness_model_config); + set_codex_model_reasoning_effort(&mut doc, third_party_harness_model_config); let canonical = working_dir.canonicalize().with_context(|| { format!( @@ -709,9 +711,27 @@ fn set_codex_check_for_update_on_startup(doc: &mut toml_edit::DocumentMut, enabl doc[CODEX_CHECK_FOR_UPDATE_ON_STARTUP_KEY] = toml_edit::value(enabled); } -fn set_codex_model(doc: &mut toml_edit::DocumentMut, third_party_harness_model_id: Option<&str>) { - let Some(model_id) = - third_party_harness_model_id.filter(|id| !id.is_empty() && *id != "default") +fn set_codex_model_reasoning_effort( + doc: &mut toml_edit::DocumentMut, + third_party_harness_model_config: Option<&HarnessModelConfig>, +) { + let Some(reasoning_level) = third_party_harness_model_config + .and_then(|config| config.reasoning_level.as_deref()) + .filter(|level| !level.is_empty()) + else { + doc.remove(CODEX_MODEL_REASONING_EFFORT_KEY); + return; + }; + doc[CODEX_MODEL_REASONING_EFFORT_KEY] = toml_edit::value(reasoning_level); +} + +fn set_codex_model( + doc: &mut toml_edit::DocumentMut, + third_party_harness_model_config: Option<&HarnessModelConfig>, +) { + let Some(model_id) = third_party_harness_model_config + .map(|config| config.model_id.as_str()) + .filter(|id| !id.is_empty() && *id != "default") else { // No model specified or "default" selected — remove any pre-existing // key so Codex uses its own default. diff --git a/app/src/ai/agent_sdk/driver/harness/codex_tests.rs b/app/src/ai/agent_sdk/driver/harness/codex_tests.rs index 32a99ef594..be5b5fee71 100644 --- a/app/src/ai/agent_sdk/driver/harness/codex_tests.rs +++ b/app/src/ai/agent_sdk/driver/harness/codex_tests.rs @@ -171,6 +171,13 @@ fn read_codex_config(path: &std::path::Path) -> toml::Table { toml::from_str(&content).unwrap() } +fn harness_model_config(model_id: &str, reasoning_level: Option<&str>) -> HarnessModelConfig { + HarnessModelConfig { + model_id: model_id.to_string(), + reasoning_level: reasoning_level.map(str::to_string), + } +} + #[test] fn prepare_codex_config_toml_writes_fresh_config() { let tmp = TempDir::new().unwrap(); @@ -444,7 +451,7 @@ fn prepare_codex_config_toml_writes_model_when_specified() { &config_path, &working_dir, &HashMap::new(), - Some("gpt-5.5"), + Some(&harness_model_config("gpt-5.5", None)), None, ) .unwrap(); @@ -470,7 +477,7 @@ fn prepare_codex_config_toml_writes_model_migration_for_older_model() { &config_path, &working_dir, &HashMap::new(), - Some("gpt-5.2"), + Some(&harness_model_config("gpt-5.2", None)), None, ) .unwrap(); @@ -496,7 +503,7 @@ fn prepare_codex_config_toml_skips_model_for_default_sentinel() { &config_path, &working_dir, &HashMap::new(), - Some("default"), + Some(&harness_model_config("default", None)), None, ) .unwrap(); @@ -534,6 +541,41 @@ fn prepare_codex_config_toml_skips_model_when_none() { ); } +#[test] +fn prepare_codex_config_toml_writes_model_reasoning_effort_when_specified() { + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + + prepare_codex_config_toml( + &config_path, + &working_dir, + &HashMap::new(), + Some(&harness_model_config("gpt-5.5", Some("medium"))), + None, + ) + .unwrap(); + + let cfg = read_codex_config(&config_path); + assert_eq!(cfg["model"].as_str(), Some("gpt-5.5")); + assert_eq!(cfg["model_reasoning_effort"].as_str(), Some("medium")); +} + +#[test] +fn prepare_codex_config_toml_removes_stale_model_reasoning_effort_when_none() { + let tmp = TempDir::new().unwrap(); + let config_path = tmp.path().join("config.toml"); + let working_dir = tmp.path().join("workspace"); + fs::create_dir_all(&working_dir).unwrap(); + fs::write(&config_path, "model_reasoning_effort = \"high\"\n").unwrap(); + + prepare_codex_config_toml(&config_path, &working_dir, &HashMap::new(), None, None).unwrap(); + + let cfg = read_codex_config(&config_path); + assert!(cfg.get("model_reasoning_effort").is_none()); +} + #[test] fn find_child_git_repos_returns_only_repo_children() { let tmp = TempDir::new().unwrap(); diff --git a/app/src/ai/agent_sdk/driver/harness/gemini.rs b/app/src/ai/agent_sdk/driver/harness/gemini.rs index 9c0030b896..10379aa387 100644 --- a/app/src/ai/agent_sdk/driver/harness/gemini.rs +++ b/app/src/ai/agent_sdk/driver/harness/gemini.rs @@ -13,7 +13,7 @@ use warp_cli::agent::Harness; use warpui::{ModelHandle, ModelSpawner}; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::ambient_agents::{task::HarnessModelConfig, AmbientAgentTaskId}; use crate::server::server_api::harness_support::HarnessSupportClient; use crate::server::server_api::ServerApi; use crate::terminal::model::block::BlockId; @@ -64,7 +64,7 @@ impl ThirdPartyHarness for GeminiHarness { _resolved_env_vars: &HashMap, _resolved_secrets: &HashMap, _resolved_mcp_servers: &HashMap, - _third_party_harness_model_id: Option<&str>, + _third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> Result, AgentDriverError> { // Prepare the environment config files. prepare_gemini_environment_config(working_dir, system_prompt).map_err(|error| { diff --git a/app/src/ai/agent_sdk/driver/harness/mod.rs b/app/src/ai/agent_sdk/driver/harness/mod.rs index 149e7e8a92..3e7f14e23a 100644 --- a/app/src/ai/agent_sdk/driver/harness/mod.rs +++ b/app/src/ai/agent_sdk/driver/harness/mod.rs @@ -13,7 +13,7 @@ use warp_cli::agent::Harness; use warpui::{ModelHandle, ModelSpawner, SingletonEntity}; use crate::ai::agent::conversation::AIConversationId; -use crate::ai::ambient_agents::AmbientAgentTaskId; +use crate::ai::ambient_agents::{task::HarnessModelConfig, AmbientAgentTaskId}; use crate::ai::mcp::JSONMCPServer; use crate::server::server_api::harness_support::{upload_to_target, HarnessSupportClient}; use crate::server::server_api::ServerApi; @@ -185,7 +185,7 @@ pub(crate) trait ThirdPartyHarness: Send + Sync { resolved_env_vars: &HashMap, resolved_secrets: &HashMap, resolved_mcp_servers: &HashMap, - third_party_harness_model_id: Option<&str>, + third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> Result, AgentDriverError>; } @@ -379,10 +379,13 @@ pub(crate) fn task_env_vars( /// Claude Code's `settings.json`. pub(crate) fn harness_model_env_vars( selected_harness: Harness, - third_party_harness_model_id: Option<&str>, + third_party_harness_model_config: Option<&HarnessModelConfig>, ) -> HashMap { let mut env_vars = HashMap::new(); - let Some(model_id) = third_party_harness_model_id.filter(|id| !id.is_empty()) else { + let Some(model_id) = third_party_harness_model_config + .map(|config| config.model_id.as_str()) + .filter(|id| !id.is_empty()) + else { return env_vars; }; diff --git a/app/src/ai/agent_sdk/mod.rs b/app/src/ai/agent_sdk/mod.rs index 189f63c7da..9102c05e4c 100644 --- a/app/src/ai/agent_sdk/mod.rs +++ b/app/src/ai/agent_sdk/mod.rs @@ -350,6 +350,7 @@ fn build_merged_config_and_task( let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig { harness_type: args.harness, model_id: harness_model_id, + reasoning_level: None, }); let oz_model = if args.harness == Harness::Oz { @@ -450,6 +451,7 @@ fn build_server_side_task( let harness_override = (args.harness != Harness::Oz).then_some(HarnessConfig { harness_type: args.harness, model_id: harness_model_id, + reasoning_level: None, }); let skill_name = resolved_skill.as_ref().map(|s| s.name.clone()); @@ -825,10 +827,10 @@ impl AgentDriverRunner { let should_share = (args.share.is_shared() || args.task_id.is_some()) && FeatureFlag::AgentSharedSessions.is_enabled(); - let third_party_harness_model_id = merged_config + let third_party_harness_model_config = merged_config .harness .as_ref() - .and_then(|h| h.model_id.clone()); + .and_then(|h| h.model_config()); let driver_options = driver::AgentDriverOptions { working_dir: working_dir.clone(), task_id, @@ -840,7 +842,7 @@ impl AgentDriverRunner { cloud_providers: Vec::new(), environment: None, selected_harness: args.harness, - third_party_harness_model_id, + third_party_harness_model_config, snapshot_disabled: args.snapshot.no_snapshot.then_some(true), snapshot_upload_timeout: args .snapshot @@ -1125,7 +1127,7 @@ impl AgentDriverRunner { } } }; - let (parent_run_id, task_conversation_id, task_harness, task_harness_model_id) = + let (parent_run_id, task_conversation_id, task_harness, task_harness_model_config) = match task_metadata_result { Ok(Some(task_metadata)) => { // The task's harness is stored on the snapshot; if absent, it's the default Oz. @@ -1136,13 +1138,13 @@ impl AgentDriverRunner { let task_harness = task_harness_config .map(|h| h.harness_type) .unwrap_or(Harness::Oz); - let task_harness_model_id = - task_harness_config.and_then(|h| h.model_id.clone()); + let task_harness_model_config = + task_harness_config.and_then(|h| h.model_config()); ( task_metadata.parent_run_id, task_metadata.conversation_id, Some(task_harness), - task_harness_model_id, + task_harness_model_config, ) } Ok(None) => (None, None, None, None), @@ -1178,8 +1180,8 @@ impl AgentDriverRunner { driver_options.parent_run_id = parent_run_id; driver_options.secrets = secrets; // CLI flags continue to take precedence so users can still override per-invocation. - if driver_options.third_party_harness_model_id.is_none() { - driver_options.third_party_harness_model_id = task_harness_model_id; + if driver_options.third_party_harness_model_config.is_none() { + driver_options.third_party_harness_model_config = task_harness_model_config; } // Update the task prompt to include the downloaded attachments dir diff --git a/app/src/ai/ambient_agents/task.rs b/app/src/ai/ambient_agents/task.rs index 7b800196f8..b2f1161634 100644 --- a/app/src/ai/ambient_agents/task.rs +++ b/app/src/ai/ambient_agents/task.rs @@ -79,6 +79,16 @@ pub struct HarnessConfig { /// The model to use with this harness. None means use the harness default. #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, + /// Optional reasoning level for harnesses that support it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub struct HarnessModelConfig { + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, } impl HarnessConfig { @@ -87,8 +97,19 @@ impl HarnessConfig { Self { harness_type, model_id: None, + reasoning_level: None, } } + + pub fn model_config(&self) -> Option { + self.model_id + .as_ref() + .filter(|id| !id.is_empty()) + .map(|model_id| HarnessModelConfig { + model_id: model_id.clone(), + reasoning_level: self.reasoning_level.clone(), + }) + } } fn parse_session_id_from_link(session_link: &str) -> Option { diff --git a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs index ab372810d2..e28dd374ef 100644 --- a/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs +++ b/app/src/ai/blocklist/agent_view/agent_input_footer/mod.rs @@ -2023,7 +2023,20 @@ impl AgentInputFooter { AgentToolbarItemKind::ModelSelector => { let show = FeatureFlag::ProfilesDesignRevamp.is_enabled() || *SessionSettings::as_ref(app).show_model_selectors_in_prompt; - show.then(|| ChildView::new(&self.model_selector).finish()) + if !show { + return None; + } + let is_ambient_agent = self + .ambient_agent_view_model + .as_ref() + .is_some_and(|m| m.as_ref(app).is_ambient_agent()); + if is_ambient_agent { + self.v2_model_selector + .as_ref() + .map(|selector| ChildView::new(selector).finish()) + } else { + Some(ChildView::new(&self.model_selector).finish()) + } } AgentToolbarItemKind::NLDToggle => Some(ChildView::new(&self.nld_button).finish()), AgentToolbarItemKind::VoiceInput => { diff --git a/app/src/ai/cloud_agent_settings.rs b/app/src/ai/cloud_agent_settings.rs index 3b191a1665..761e1594ab 100644 --- a/app/src/ai/cloud_agent_settings.rs +++ b/app/src/ai/cloud_agent_settings.rs @@ -10,6 +10,22 @@ use warp_cli::agent::Harness; use crate::server::ids::SyncId; +#[derive( + Clone, + Debug, + PartialEq, + serde::Serialize, + serde::Deserialize, + schemars::JsonSchema, + settings_value::SettingsValue, +)] +#[schemars(description = "Selected third-party harness model.")] +pub struct HarnessModelSelection { + pub model_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, +} + define_settings_group!(CloudAgentSettings, settings: [ last_selected_environment_id: LastSelectedEnvironmentId { type: Option, @@ -40,7 +56,7 @@ define_settings_group!(CloudAgentSettings, settings: [ private: true, }, last_selected_harness_model: LastSelectedHarnessModel { - type: HashMap, + type: HashMap, default: HashMap::new(), supported_platforms: SupportedPlatforms::ALL, sync_to_cloud: SyncToCloud::Never, diff --git a/app/src/ai/harness_availability.rs b/app/src/ai/harness_availability.rs index 257b8508bd..711b978846 100644 --- a/app/src/ai/harness_availability.rs +++ b/app/src/ai/harness_availability.rs @@ -21,6 +21,8 @@ const CACHE_KEY: &str = "AvailableHarnesses"; pub struct HarnessModelInfo { pub id: String, pub display_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_level: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/app/src/pane_group/pane/local_harness_launch.rs b/app/src/pane_group/pane/local_harness_launch.rs index b6286f7b14..fea6532fea 100644 --- a/app/src/pane_group/pane/local_harness_launch.rs +++ b/app/src/pane_group/pane/local_harness_launch.rs @@ -12,7 +12,10 @@ use crate::ai::{ }, task_env_vars, validate_cli_installed, }, - ambient_agents::{task::HarnessConfig, AgentConfigSnapshot, AmbientAgentTaskId}, + ambient_agents::{ + task::{HarnessConfig, HarnessModelConfig}, + AgentConfigSnapshot, AmbientAgentTaskId, + }, }; use crate::server::server_api::ai::AIClient; use crate::terminal::cli_agent_sessions::plugin_manager::plugin_manager_for; @@ -87,6 +90,13 @@ pub(super) async fn prepare_local_harness_child_launch( startup_directory: Option, ai_client: Arc, ) -> Result { + let harness_model_config = + model_id + .filter(|id| !id.is_empty()) + .map(|model_id| HarnessModelConfig { + model_id, + reasoning_level: None, + }); let Some(harness) = normalize_local_child_harness(&harness_type) else { let harness_name = harness_type.trim(); return Err(if harness_name.is_empty() { @@ -181,7 +191,10 @@ pub(super) async fn prepare_local_harness_child_launch( // Propagate the selected model to Claude Code via ANTHROPIC_MODEL. // Codex local children never receive a model override — the UI // ensures model_id is empty for local Codex. - env_vars.extend(harness_model_env_vars(harness, model_id.as_deref())); + env_vars.extend(harness_model_env_vars( + harness, + harness_model_config.as_ref(), + )); Ok(PreparedLocalHarnessLaunch { command, diff --git a/app/src/server/server_api/ai.rs b/app/src/server/server_api/ai.rs index 447b377493..875481ceba 100644 --- a/app/src/server/server_api/ai.rs +++ b/app/src/server/server_api/ai.rs @@ -1463,6 +1463,7 @@ impl AIClient for ServerApi { .map(|m| crate::ai::harness_availability::HarnessModelInfo { id: m.id.into_inner(), display_name: m.display_name, + reasoning_level: m.reasoning_level, }) .collect(), }) diff --git a/app/src/terminal/view/ambient_agent/model.rs b/app/src/terminal/view/ambient_agent/model.rs index 7d4399c2ec..406847bd08 100644 --- a/app/src/terminal/view/ambient_agent/model.rs +++ b/app/src/terminal/view/ambient_agent/model.rs @@ -226,6 +226,8 @@ pub struct AmbientAgentViewModel { worker_host: Option, /// Selected model id for a third-party harness (e.g. `"opus"` for Claude). harness_model_id: Option, + /// Optional reasoning level for the selected harness model. + harness_reasoning_level: Option, /// Name of the selected auth secret for the current non-Oz harness. harness_auth_secret_name: Option, /// Whether the harness CLI @@ -302,6 +304,7 @@ impl AmbientAgentViewModel { harness, worker_host: None, harness_model_id: None, + harness_reasoning_level: None, harness_auth_secret_name: None, harness_command_started: false, active_execution_session_id: None, @@ -448,6 +451,7 @@ impl AmbientAgentViewModel { } self.harness = harness; self.harness_model_id = None; + self.harness_reasoning_level = None; self.harness_auth_secret_name = None; ctx.emit(AmbientAgentViewModelEvent::HarnessSelected); } @@ -460,15 +464,23 @@ impl AmbientAgentViewModel { self.harness_model_id.as_deref() } - pub fn set_harness_model_id( + pub fn selected_harness_reasoning_level(&self) -> Option<&str> { + self.harness_reasoning_level.as_deref() + } + + pub fn set_harness_model_selection( &mut self, harness_model_id: Option, + reasoning_level: Option, ctx: &mut ModelContext, ) { - if self.harness_model_id == harness_model_id { + if self.harness_model_id == harness_model_id + && self.harness_reasoning_level == reasoning_level + { return; } self.harness_model_id = harness_model_id; + self.harness_reasoning_level = reasoning_level; ctx.emit(AmbientAgentViewModelEvent::HarnessModelSelected); } @@ -854,24 +866,28 @@ impl AmbientAgentViewModel { ctx.emit(AmbientAgentViewModelEvent::RunLifecycleChanged); // Fetch the task so we can set the correct environment (instead of defaulting to the most - // recently-used one) and the correct harness (so non-oz viewers know to use the + // recently-used one), harness, and harness model (so non-oz viewers know to use the // queued-prompt / harness-command-started flow). ctx.spawn( async move { ai_client.get_ambient_agent_task(&task_id).await }, |me, result, ctx| match result { Ok(task) => { let snapshot = task.agent_config_snapshot.as_ref(); + let harness_config = snapshot.and_then(|s| s.harness.as_ref()); let environment_id = snapshot .and_then(|s| s.environment_id.as_deref()) .and_then(|id| ServerId::try_from(id).ok()) .map(SyncId::ServerId); - let harness = snapshot - .and_then(|s| s.harness.as_ref()) + let harness = harness_config .map(|h| h.harness_type) .unwrap_or(Harness::Oz); + let harness_model_id = harness_config.and_then(|h| h.model_id.clone()); + let harness_reasoning_level = + harness_config.and_then(|h| h.reasoning_level.clone()); me.set_environment_id(environment_id, ctx); me.set_harness(harness, ctx); + me.set_harness_model_selection(harness_model_id, harness_reasoning_level, ctx); ctx.emit(AmbientAgentViewModelEvent::ViewerHarnessResolved); } Err(err) => { @@ -975,6 +991,7 @@ impl AmbientAgentViewModel { self.task_id = None; self.conversation_id = None; self.harness_model_id = None; + self.harness_reasoning_level = None; self.harness_command_started = false; self.active_execution_session_id = None; self.last_ended_execution_session_id = None; @@ -1016,6 +1033,7 @@ impl AmbientAgentViewModel { let third_party_harness = (selected_harness != Harness::Oz).then(|| HarnessConfig { harness_type: selected_harness, model_id: self.harness_model_id.clone(), + reasoning_level: self.harness_reasoning_level.clone(), }); let harness_auth_secrets = @@ -1098,6 +1116,11 @@ impl AmbientAgentViewModel { ) }); } + if let Some(harness) = config.harness.as_ref() { + self.harness = harness.harness_type; + self.harness_model_id = harness.model_id.clone(); + self.harness_reasoning_level = harness.reasoning_level.clone(); + } } self.spawn_internal(request, ctx); diff --git a/app/src/terminal/view/ambient_agent/model_selector.rs b/app/src/terminal/view/ambient_agent/model_selector.rs index cb41931e9f..a3e98f1411 100644 --- a/app/src/terminal/view/ambient_agent/model_selector.rs +++ b/app/src/terminal/view/ambient_agent/model_selector.rs @@ -18,7 +18,7 @@ use warp_core::ui::theme::Fill; use settings::Setting as _; use crate::ai::blocklist::agent_view::agent_input_footer::AgentInputButtonTheme; -use crate::ai::cloud_agent_settings::CloudAgentSettings; +use crate::ai::cloud_agent_settings::{CloudAgentSettings, HarnessModelSelection}; use crate::ai::execution_profiles::model_menu_items::is_auto; use crate::ai::harness_availability::{HarnessAvailabilityEvent, HarnessAvailabilityModel}; use crate::ai::harness_display::icon_for as harness_icon_for; @@ -74,6 +74,7 @@ pub enum ModelSelectorAction { SelectHarnessModel { harness: Harness, model_id: String, + reasoning_level: Option, }, } @@ -85,6 +86,7 @@ pub enum ModelSelectorEvent { pub struct HarnessSelection { pub harness: Harness, pub model_id: String, + pub reasoning_level: Option, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -257,18 +259,26 @@ impl ModelSelector { { return; } - let saved_id = CloudAgentSettings::as_ref(ctx) + let saved = CloudAgentSettings::as_ref(ctx) .last_selected_harness_model .value() .get(harness.config_name()) .cloned(); - if let Some(saved_id) = saved_id { + if let Some(saved) = saved { if HarnessAvailabilityModel::as_ref(ctx) .models_for(harness) - .is_some_and(|models| models.iter().any(|m| m.id == saved_id)) + .is_some_and(|models| { + models.iter().any(|m| { + m.id == saved.model_id && m.reasoning_level == saved.reasoning_level + }) + }) { ambient_model.update(ctx, |model, ctx| { - model.set_harness_model_id(Some(saved_id), ctx); + model.set_harness_model_selection( + Some(saved.model_id), + saved.reasoning_level, + ctx, + ); }); } } @@ -280,6 +290,12 @@ impl ModelSelector { .map(|m| m.as_ref(app).selected_harness()) } + fn is_configuring(&self, app: &AppContext) -> bool { + self.ambient_agent_model + .as_ref() + .is_none_or(|m| m.as_ref(app).is_configuring_ambient_agent()) + } + pub fn is_menu_open(&self) -> bool { self.is_menu_open } @@ -295,12 +311,15 @@ impl ModelSelector { harness: Harness, app: &AppContext, ) -> Option { - let model_id = self - .ambient_agent_model - .as_ref() - .and_then(|m| m.as_ref(app).selected_harness_model_id()) - .map(str::to_owned)?; - Some(HarnessSelection { harness, model_id }) + let ambient_agent_model = self.ambient_agent_model.as_ref()?; + let model = ambient_agent_model.as_ref(app); + let model_id = model.selected_harness_model_id().map(str::to_owned)?; + let reasoning_level = model.selected_harness_reasoning_level().map(str::to_owned); + Some(HarnessSelection { + harness, + model_id, + reasoning_level, + }) } fn set_menu_visibility(&mut self, is_open: bool, ctx: &mut ViewContext) { @@ -359,6 +378,11 @@ impl ModelSelector { } fn refresh_button(&mut self, ctx: &mut ViewContext) { + let is_configuring = self.is_configuring(ctx); + self.button.update(ctx, |button, ctx| { + button.set_disabled(!is_configuring, ctx); + }); + let active_label = match self.active_harness(ctx) { Some(harness) if !matches!(harness, Harness::Oz | Harness::Unknown) => self .resolved_harness_selection(harness, ctx) @@ -368,11 +392,14 @@ impl ModelSelector { .and_then(|models| { models .iter() - .find(|m| m.id == selection.model_id) + .find(|m| { + m.id == selection.model_id + && m.reasoning_level == selection.reasoning_level + }) .map(|info| info.display_name.clone()) }) }) - .unwrap_or_else(|| "Default".to_string()), + .unwrap_or_else(|| "default".to_string()), _ => LLMPreferences::as_ref(ctx) .get_active_base_model(ctx, Some(self.terminal_view_id)) .display_name @@ -493,43 +520,69 @@ impl ModelSelector { hover_background: Fill, ctx: &AppContext, ) -> (Vec>, ModelSelectorAction) { - let active_id = self - .resolved_harness_selection(harness, ctx) - .map(|selection| selection.model_id) - .unwrap_or_default(); + let active_action = match self.resolved_harness_selection(harness, ctx) { + Some(HarnessSelection { + model_id, + reasoning_level, + .. + }) => ModelSelectorAction::SelectHarnessModel { + harness, + model_id, + reasoning_level, + }, + None => ModelSelectorAction::SelectHarnessModel { + harness, + model_id: String::new(), + reasoning_level: None, + }, + }; let icon = harness_icon_for(harness); + let default_action = ModelSelectorAction::SelectHarnessModel { + harness, + model_id: String::new(), + reasoning_level: None, + }; + let mut items: Vec> = Vec::new(); + if query.is_empty() || "default".contains(query) { + items.push(MenuItem::Item( + MenuItemFields::new("default") + .with_icon(icon) + .with_icon_size_override(ITEM_ICON_SIZE) + .with_font_size_override(ITEM_FONT_SIZE) + .with_padding_override(ITEM_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING) + .with_override_hover_background_color(hover_background) + .with_on_select_action(default_action), + )); + } + let models = HarnessAvailabilityModel::as_ref(ctx).models_for(harness); - let items: Vec> = models - .into_iter() - .flat_map(|slice| slice.iter()) - .filter_map(|model| { - let display_name = model.display_name.clone(); - if !query.is_empty() && !display_name.to_lowercase().contains(query) { - return None; - } - Some(MenuItem::Item( - MenuItemFields::new(display_name) - .with_icon(icon) - .with_icon_size_override(ITEM_ICON_SIZE) - .with_font_size_override(ITEM_FONT_SIZE) - .with_padding_override(ITEM_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING) - .with_override_hover_background_color(hover_background) - .with_on_select_action(ModelSelectorAction::SelectHarnessModel { - harness, - model_id: model.id.clone(), - }), - )) - }) - .collect(); + items.extend( + models + .into_iter() + .flat_map(|slice| slice.iter()) + .filter_map(|model| { + let display_name = model.display_name.clone(); + if !query.is_empty() && !display_name.to_lowercase().contains(query) { + return None; + } + Some(MenuItem::Item( + MenuItemFields::new(display_name) + .with_icon(icon) + .with_icon_size_override(ITEM_ICON_SIZE) + .with_font_size_override(ITEM_FONT_SIZE) + .with_padding_override(ITEM_VERTICAL_PADDING, MENU_HORIZONTAL_PADDING) + .with_override_hover_background_color(hover_background) + .with_on_select_action(ModelSelectorAction::SelectHarnessModel { + harness, + model_id: model.id.clone(), + reasoning_level: model.reasoning_level.clone(), + }), + )) + }), + ); - ( - items, - ModelSelectorAction::SelectHarnessModel { - harness, - model_id: active_id, - }, - ) + (items, active_action) } fn menu_positioning(&self, app: &AppContext) -> OffsetPositioning { @@ -578,8 +631,10 @@ impl TypedActionView for ModelSelector { fn handle_action(&mut self, action: &Self::Action, ctx: &mut ViewContext) { match action { ModelSelectorAction::ToggleMenu => { - let new_state = !self.is_menu_open; - self.set_menu_visibility(new_state, ctx); + if self.is_configuring(ctx) { + let new_state = !self.is_menu_open; + self.set_menu_visibility(new_state, ctx); + } } ModelSelectorAction::SelectModel(llm_id) => { let terminal_view_id = self.terminal_view_id; @@ -589,18 +644,41 @@ impl TypedActionView for ModelSelector { }); self.set_menu_visibility(false, ctx); } - ModelSelectorAction::SelectHarnessModel { harness, model_id } => { + ModelSelectorAction::SelectHarnessModel { + harness, + model_id, + reasoning_level, + } => { + let is_default = model_id.is_empty(); if let Some(ambient_agent_model) = self.ambient_agent_model.clone() { if ambient_agent_model.as_ref(ctx).selected_harness() == *harness { ambient_agent_model.update(ctx, |model, ctx| { - model.set_harness_model_id(Some(model_id.clone()), ctx); + model.set_harness_model_selection( + (!is_default).then(|| model_id.clone()), + if is_default { + None + } else { + reasoning_level.clone() + }, + ctx, + ); }); } } // Persist the selection per-harness to settings for next time. CloudAgentSettings::handle(ctx).update(ctx, |settings, ctx| { let mut map = settings.last_selected_harness_model.value().clone(); - map.insert(harness.config_name().to_string(), model_id.clone()); + if is_default { + map.remove(harness.config_name()); + } else { + map.insert( + harness.config_name().to_string(), + HarnessModelSelection { + model_id: model_id.clone(), + reasoning_level: reasoning_level.clone(), + }, + ); + } report_if_error!(settings.last_selected_harness_model.set_value(map, ctx)); }); self.set_menu_visibility(false, ctx); diff --git a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs index 1667b354ed..02a5e8cd65 100644 --- a/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs +++ b/app/src/terminal/view/shared_session/cloud_conversation_continuation_tests.rs @@ -222,6 +222,7 @@ impl AmbientAgentTaskTestExt for AmbientAgentTask { harness: (harness != Harness::Oz).then_some(HarnessConfig { harness_type: harness, model_id: None, + reasoning_level: None, }), ..Default::default() }); diff --git a/crates/graphql/src/api/queries/get_available_harnesses.rs b/crates/graphql/src/api/queries/get_available_harnesses.rs index b1dede6e55..15b716f312 100644 --- a/crates/graphql/src/api/queries/get_available_harnesses.rs +++ b/crates/graphql/src/api/queries/get_available_harnesses.rs @@ -52,4 +52,5 @@ pub struct HarnessInfo { pub struct HarnessModel { pub id: cynic::Id, pub display_name: String, + pub reasoning_level: Option, } diff --git a/crates/warp_graphql_schema/api/schema.graphql b/crates/warp_graphql_schema/api/schema.graphql index 64ecc992df..916196ab19 100644 --- a/crates/warp_graphql_schema/api/schema.graphql +++ b/crates/warp_graphql_schema/api/schema.graphql @@ -224,6 +224,7 @@ enum AgentHarness { type HarnessModel { displayName: String! id: ID! + reasoningLevel: String } """A harness available to the requesting user."""