diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index e394b06769..b01bcfd2fa 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -16,6 +16,7 @@ mod daemon; mod diagnostics; mod logging; mod management; +mod model_selection; mod modes; mod peer_host; mod plugin_diagnostics; diff --git a/src/apps/cli/src/management.rs b/src/apps/cli/src/management.rs index ab47c72245..21d50ec277 100644 --- a/src/apps/cli/src/management.rs +++ b/src/apps/cli/src/management.rs @@ -83,6 +83,7 @@ pub(crate) async fn print_models() -> Result<()> { config_service.get_config(None).await?; let primary_model_id = global_config.ai.default_models.primary.clone(); + let mode_model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai); println!("AI models"); println!(); @@ -93,12 +94,7 @@ pub(crate) async fn print_models() -> Result<()> { for model in models { let is_primary = primary_model_id.as_deref() == Some(model.id.as_str()); - let current_modes: Vec = global_config - .ai - .agent_models - .iter() - .filter_map(|(mode, model_id)| (model_id == &model.id).then_some(mode.clone())) - .collect(); + let is_mode_default = mode_model_id.as_deref() == Some(model.id.as_str()); println!( "- {}{} ({})", @@ -109,8 +105,8 @@ pub(crate) async fn print_models() -> Result<()> { println!(" Name: {}", model.name); println!(" Provider: {}", model.provider); println!(" Model: {}", model.model_name); - if !current_modes.is_empty() { - println!(" Used by modes: {}", current_modes.join(", ")); + if is_mode_default { + println!(" Used by modes: all"); } } @@ -170,16 +166,12 @@ pub(crate) async fn print_mcp_servers() -> Result<()> { pub(crate) async fn set_default_model(model_id: &str) -> Result<()> { let config_service = ensure_global_config_service().await?; - let agent_registry = get_agent_registry(); - let modes = agent_registry.get_modes_info().await; - config_service .set_config("ai.default_models.primary", model_id) .await?; - for mode in modes { - let path = format!("ai.agent_models.{}", mode.id); - config_service.set_config(&path, model_id).await?; - } + config_service + .set_config("ai.agent_model_defaults.mode", model_id) + .await?; println!("Default model set to: {}", model_id); Ok(()) diff --git a/src/apps/cli/src/model_selection.rs b/src/apps/cli/src/model_selection.rs new file mode 100644 index 0000000000..3267be14ab --- /dev/null +++ b/src/apps/cli/src/model_selection.rs @@ -0,0 +1,68 @@ +use bitfun_core::service::config::AIConfig; + +/// Resolve the shared future-mode selector to the concrete enabled model shown +/// by CLI model pickers and status surfaces. +pub(crate) fn resolve_mode_model_id(ai_config: &AIConfig) -> Option { + let selector = ai_config.agent_model_defaults.mode.trim(); + match selector { + "" | "auto" | "default" => ai_config.resolve_model_selection("primary"), + selector => ai_config.resolve_model_selection(selector), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config_with_selector(selector: &str) -> AIConfig { + serde_json::from_value(serde_json::json!({ + "models": [ + { + "id": "primary-model", + "name": "Primary", + "provider": "openai", + "model_name": "primary-model", + "enabled": true + }, + { + "id": "fast-model", + "name": "Fast", + "provider": "openai", + "model_name": "fast-model", + "enabled": true + }, + { + "id": "explicit-model", + "name": "Explicit", + "provider": "openai", + "model_name": "explicit-model", + "enabled": true + } + ], + "default_models": { + "primary": "primary-model", + "fast": "fast-model" + }, + "agent_model_defaults": { + "mode": selector + } + })) + .expect("test AI config should deserialize") + } + + #[test] + fn resolves_symbolic_and_explicit_mode_defaults_for_cli_display() { + assert_eq!( + resolve_mode_model_id(&config_with_selector("auto")).as_deref(), + Some("primary-model") + ); + assert_eq!( + resolve_mode_model_id(&config_with_selector("fast")).as_deref(), + Some("fast-model") + ); + assert_eq!( + resolve_mode_model_id(&config_with_selector("explicit-model")).as_deref(), + Some("explicit-model") + ); + } +} diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 691e2861eb..b87b9101d3 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -3881,7 +3881,6 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let agent_type = self.agent_type.clone(); let result: Option = tokio::task::block_in_place(|| { rt_handle.block_on(async { let config_service = GlobalConfigManager::get_service().await.ok()?; @@ -3890,14 +3889,7 @@ impl ChatMode { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - // Resolve model ID for the current agent - let model_id = global_config - .ai - .agent_models - .get(&agent_type) - .cloned() - .or_else(|| global_config.ai.default_models.primary.clone()) - .unwrap_or_else(|| "primary".to_string()); + let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; fn provider_display_name( model: &bitfun_core::service::config::AIModelConfig, @@ -3927,20 +3919,10 @@ impl ChatMode { format!("{} / {}", model.model_name, provider_display_name(model)) } - // Find model name - let model_name = if model_id == "primary" { - // Resolve primary model - let primary_id = global_config.ai.default_models.primary.as_deref()?; - models - .iter() - .find(|m| m.id == primary_id) - .map(model_display_name) - } else { - models - .iter() - .find(|m| m.id == model_id) - .map(model_display_name) - }; + let model_name = models + .iter() + .find(|model| model.id == model_id) + .map(model_display_name); model_name }) @@ -3958,7 +3940,6 @@ impl ChatMode { chat_state: &mut ChatState, rt_handle: &tokio::runtime::Handle, ) { - let agent_type = self.agent_type.clone(); let result = tokio::task::block_in_place(|| { rt_handle.block_on(async { let config_service = match GlobalConfigManager::get_service().await { @@ -3974,13 +3955,8 @@ impl ChatMode { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - // Get current model ID - let current_model_id = global_config - .ai - .agent_models - .get(&agent_type) - .cloned() - .or_else(|| global_config.ai.default_models.primary.clone()); + let current_model_id = + crate::model_selection::resolve_mode_model_id(&global_config.ai); // Convert to ModelItem list (only enabled models) let model_items: Vec = models @@ -4020,10 +3996,19 @@ impl ChatMode { ) { let selected_id = selected.id.clone(); let selected_display_name = format!("{} / {}", selected.model_name, selected.name); - let modes = self.get_mode_agents(rt_handle); + let session_id = chat_state.core_session_id.clone(); let success = tokio::task::block_in_place(|| { rt_handle.block_on(async { + if let Err(e) = self + .agent + .update_session_model(&session_id, &selected_id) + .await + { + tracing::error!("Failed to update current session model: {}", e); + return false; + } + let config_service = match GlobalConfigManager::get_service().await { Ok(s) => s, Err(e) => { @@ -4032,23 +4017,14 @@ impl ChatMode { } }; - // Update default primary model if let Err(e) = config_service - .set_config("ai.default_models.primary", &selected_id) + .set_config("ai.agent_model_defaults.mode", &selected_id) .await { - tracing::error!("Failed to set default primary model: {}", e); + tracing::error!("Failed to set future mode model: {}", e); return false; } - // Update agent_models for all modes - for mode in &modes { - let path = format!("ai.agent_models.{}", mode.id); - if let Err(e) = config_service.set_config(&path, &selected_id).await { - tracing::error!("Failed to set model for mode '{}': {}", mode.id, e); - } - } - true }) }); diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 1b57daea50..2f78372989 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -83,7 +83,7 @@ impl ExecTokenUsage { ) -> Option<&'a str> { let AgenticEvent::TokenUsageUpdated { turn_id, - model_id, + model_config_id, input_tokens, output_tokens, total_tokens, @@ -108,7 +108,7 @@ impl ExecTokenUsage { } else { *aggregate = Some(round); } - Some(model_id) + Some(model_config_id) } } @@ -760,24 +760,26 @@ impl ExecMode { self.emit_stream_envelope(&envelope)?; - if let Some(model_id) = + if let Some(model_config_id) = ExecTokenUsage::accumulate_event(&mut usage, event, &turn_id) { - self.record_resolved_model_id(&session_id, model_id).await; + self.record_resolved_model_config_id(&session_id, model_config_id) + .await; } match event { AgenticEvent::ModelRoundStarted { turn_id: event_turn_id, - model_id: Some(model_id), + model_config_id, .. } | AgenticEvent::ModelRoundCompleted { turn_id: event_turn_id, - model_id: Some(model_id), + model_config_id, .. } if event_turn_id == &turn_id => { - self.record_resolved_model_id(&session_id, model_id).await; + self.record_resolved_model_config_id(&session_id, model_config_id) + .await; } AgenticEvent::TextChunk { @@ -1071,15 +1073,15 @@ impl ExecMode { .unwrap_or_else(|| Err(anyhow::anyhow!("Execution ended without a terminal event"))) } - async fn record_resolved_model_id(&self, session_id: &str, model_id: &str) { - let trimmed = model_id.trim(); + async fn record_resolved_model_config_id(&self, session_id: &str, model_config_id: &str) { + let trimmed = model_config_id.trim(); if trimmed.is_empty() || matches!(trimmed, "auto" | "default" | "primary" | "fast") { return; } if let Err(error) = self.agent.update_session_model(session_id, trimmed).await { tracing::debug!( - "Failed to persist resolved CLI model id: session_id={}, model_id={}, error={}", + "Failed to persist resolved CLI model config id: session_id={}, model_config_id={}, error={}", session_id, trimmed, error @@ -1508,7 +1510,8 @@ mod patch_tests { AgenticEvent::TokenUsageUpdated { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), - model_id: "model".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), input_tokens: 100, output_tokens: Some(25), total_tokens: 125, @@ -1520,7 +1523,8 @@ mod patch_tests { AgenticEvent::TokenUsageUpdated { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), - model_id: "model".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), input_tokens: 200, output_tokens: Some(50), total_tokens: 250, @@ -1535,7 +1539,7 @@ mod patch_tests { for event in &events { assert_eq!( ExecTokenUsage::accumulate_event(&mut usage, event, "turn-1"), - Some("model") + Some("model-config") ); } @@ -1558,7 +1562,8 @@ mod patch_tests { AgenticEvent::TokenUsageUpdated { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), - model_id: "model".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), input_tokens: 100, output_tokens: None, total_tokens: 100, @@ -1570,7 +1575,8 @@ mod patch_tests { AgenticEvent::TokenUsageUpdated { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), - model_id: "model".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), input_tokens: 50, output_tokens: Some(10), total_tokens: 60, diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index e5bc979ba6..362fb9fa57 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1432,7 +1432,6 @@ impl StartupPage { fn show_model_selector(&mut self) { self.push_current_popup_to_stack(); - let agent_type = self.agent_type.clone(); let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { let config_service = GlobalConfigManager::get_service().await.ok()?; @@ -1441,12 +1440,8 @@ impl StartupPage { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let current_model_id = global_config - .ai - .agent_models - .get(&agent_type) - .cloned() - .or_else(|| global_config.ai.default_models.primary.clone()); + let current_model_id = + crate::model_selection::resolve_mode_model_id(&global_config.ai); let model_items: Vec = models .into_iter() @@ -1476,7 +1471,6 @@ impl StartupPage { fn apply_model_selection(&mut self, selected: &ModelItem) { let selected_id = selected.id.clone(); let selected_display_name = format!("{} / {}", selected.model_name, selected.name); - let modes = self.get_mode_agents(); let success = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { @@ -1486,20 +1480,13 @@ impl StartupPage { }; if let Err(e) = config_service - .set_config("ai.default_models.primary", &selected_id) + .set_config("ai.agent_model_defaults.mode", &selected_id) .await { - tracing::error!("Failed to set default primary model: {}", e); + tracing::error!("Failed to set future mode model: {}", e); return false; } - for mode in &modes { - let path = format!("ai.agent_models.{}", mode.id); - if let Err(e) = config_service.set_config(&path, &selected_id).await { - tracing::error!("Failed to set model for mode '{}': {}", mode.id, e); - } - } - true }) }); @@ -2280,7 +2267,6 @@ impl StartupPage { } fn load_current_model_name(&mut self) { - let agent_type = self.agent_type.clone(); let result: Option = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { let config_service = GlobalConfigManager::get_service().await.ok()?; @@ -2289,13 +2275,7 @@ impl StartupPage { let global_config: bitfun_core::service::config::GlobalConfig = config_service.get_config(None).await.ok()?; - let model_id = global_config - .ai - .agent_models - .get(&agent_type) - .cloned() - .or_else(|| global_config.ai.default_models.primary.clone()) - .unwrap_or_else(|| "primary".to_string()); + let model_id = crate::model_selection::resolve_mode_model_id(&global_config.ai)?; fn provider_display_name( model: &bitfun_core::service::config::AIModelConfig, @@ -2325,18 +2305,10 @@ impl StartupPage { format!("{} / {}", model.model_name, provider_display_name(model)) } - if model_id == "primary" { - let primary_id = global_config.ai.default_models.primary.as_deref()?; - models - .iter() - .find(|m| m.id == primary_id) - .map(model_display_name) - } else { - models - .iter() - .find(|m| m.id == model_id) - .map(model_display_name) - } + models + .iter() + .find(|model| model.id == model_id) + .map(model_display_name) }) }); diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index aca03d414f..77921c0d26 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -2838,8 +2838,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -2919,8 +2919,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -2981,8 +2981,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index 7d21cda48b..1d62257b27 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -1312,50 +1312,6 @@ pub async fn list_ai_models_by_config( }) } -#[tauri::command] -pub async fn set_agent_model( - state: State<'_, AppState>, - agent_name: String, - model_id: String, -) -> Result { - let config_service = &state.config_service; - let global_config: bitfun_core::service::config::GlobalConfig = config_service - .get_config(None) - .await - .map_err(|e| e.to_string())?; - - if !global_config.ai.models.iter().any(|m| m.id == model_id) { - return Err(format!("Model does not exist: {}", model_id)); - } - - let path = format!("ai.agent_models.{}", agent_name); - config_service - .set_config(&path, model_id.clone()) - .await - .map_err(|e| e.to_string())?; - - state.ai_client_factory.invalidate_cache(); - - info!("Agent model set: agent={}, model={}", agent_name, model_id); - Ok(format!( - "Agent '{}' model has been set to: {}", - agent_name, model_id - )) -} - -#[tauri::command] -pub async fn get_agent_models( - state: State<'_, AppState>, -) -> Result, String> { - let config_service = &state.config_service; - let global_config: bitfun_core::service::config::GlobalConfig = config_service - .get_config(None) - .await - .map_err(|e| e.to_string())?; - - Ok(global_config.ai.agent_models) -} - #[tauri::command] pub async fn refresh_model_client( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/api/config_api.rs b/src/apps/desktop/src/api/config_api.rs index 94a896a288..effb50bcbd 100644 --- a/src/apps/desktop/src/api/config_api.rs +++ b/src/apps/desktop/src/api/config_api.rs @@ -188,7 +188,7 @@ pub async fn set_config( Ok(_) => { if request.path.starts_with("ai.models") || request.path.starts_with("ai.default_models") - || request.path.starts_with("ai.agent_models") + || request.path.starts_with("ai.agent_model_defaults") || request.path.starts_with("ai.stream_idle_timeout_secs") || request.path.starts_with("ai.stream_ttft_timeout_secs") || request.path.starts_with("ai.proxy") diff --git a/src/apps/desktop/src/api/custom_agent_api.rs b/src/apps/desktop/src/api/custom_agent_api.rs index b73131b799..357110a42d 100644 --- a/src/apps/desktop/src/api/custom_agent_api.rs +++ b/src/apps/desktop/src/api/custom_agent_api.rs @@ -6,7 +6,7 @@ use bitfun_core::agentic::agents::{ }; use log::{debug, warn}; use serde::Deserialize; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::PathBuf; use tauri::State; @@ -215,6 +215,10 @@ pub async fn create_custom_agent( .readonly .unwrap_or(request.kind == CustomAgentKind::Subagent) }; + let model_is_explicit = request + .model + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); let model = request .model .clone() @@ -239,7 +243,7 @@ pub async fn create_custom_agent( mode.save_to_file(None).map_err(|error| error.to_string())?; } CustomAgentKind::Subagent => { - let mut subagent = CustomSubagent::new_with_id( + let mut subagent = CustomSubagent::new_with_id_and_model_explicit( id.clone(), request.name.trim().to_string(), request.description.trim().to_string(), @@ -249,6 +253,7 @@ pub async fn create_custom_agent( path_str.clone(), level, model.clone(), + model_is_explicit, user_context_policy, ); subagent.set_review(review); @@ -368,22 +373,6 @@ pub async fn delete_custom_agent( let config_service = &state.config_service; - let mut agent_models: HashMap = config_service - .get_config(Some("ai.agent_models")) - .await - .unwrap_or_default(); - if agent_models.remove(&agent_id).is_some() { - if let Err(error) = config_service - .set_config("ai.agent_models", &agent_models) - .await - { - warn!( - "Failed to clean up ai.agent_models after custom agent deletion: agent_id={}, error={}", - agent_id, error - ); - } - } - let mut agent_profiles: serde_json::Map = config_service .get_config(Some("ai.agent_profiles")) .await diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index feec6dc002..6edf3191ca 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -385,7 +385,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "get_acp_session_options", RemoteWorkspacePolicy::LegacyUnaudited, ), - ("get_agent_models", RemoteWorkspacePolicy::LegacyUnaudited), ( "get_agent_profile_config", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1347,7 +1346,6 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_active_workspace", RemoteWorkspacePolicy::LegacyUnaudited, ), - ("set_agent_model", RemoteWorkspacePolicy::LegacyUnaudited), ( "set_agent_profile_config", RemoteWorkspacePolicy::LegacyUnaudited, @@ -1748,7 +1746,6 @@ mod tests { "get_acp_clients", "get_acp_session_commands", "get_acp_session_options", - "get_agent_models", "get_agent_profile_config", "get_agent_profile_configs", "get_all_modified_files", @@ -1980,7 +1977,6 @@ mod tests { "send_mcp_app_message", "set_acp_session_model", "set_active_workspace", - "set_agent_model", "set_agent_profile_config", "set_config", "set_miniapp_draft_storage", diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 9da0715743..2c61c8d4bd 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -5,9 +5,10 @@ use bitfun_core::agentic::agents::{ AgentInfo, CustomSubagent, CustomSubagentDetail, CustomSubagentKind, SubAgentSource, SubagentListScope, SubagentQueryContext, }; +use bitfun_core::service::config::SubagentModelSelection; use log::warn; use serde::{Deserialize, Serialize}; -use std::collections::{HashMap, HashSet}; +use std::collections::HashSet; use std::path::PathBuf; use tauri::State; @@ -143,22 +144,6 @@ pub async fn delete_subagent( } } - let config_service = &state.config_service; - let mut agent_models: HashMap = config_service - .get_config(Some("ai.agent_models")) - .await - .unwrap_or_default(); - agent_models.remove(&subagent_id); - if let Err(e) = config_service - .set_config("ai.agent_models", &agent_models) - .await - { - warn!( - "Failed to clean up ai.agent_models: subagent_id={}, error={}", - subagent_id, e - ); - } - if let Err(e) = bitfun_core::service::config::reload_global_config().await { warn!( "Failed to reload global config after subagent deletion: subagent_id={}, error={}", @@ -402,6 +387,8 @@ pub struct UpdateSubagentConfigRequest { pub parent_agent_type: Option, pub enabled: Option, pub model: Option, + #[serde(default)] + pub clear_model_override: bool, pub workspace_path: Option, } @@ -426,6 +413,10 @@ pub async fn update_subagent_config( let mut availability_updated = false; let mut model_updated = false; + if request.model.is_some() && request.clear_model_override { + return Err("model and clearModelOverride cannot be provided together".to_string()); + } + if let Some(enabled) = request.enabled { let parent_agent_type = request.parent_agent_type.as_deref().ok_or_else(|| { "parentAgentType is required when updating subagent availability".to_string() @@ -448,12 +439,13 @@ pub async fn update_subagent_config( .get_custom_subagent_config(subagent_id, workspace.as_deref()) .is_some() { - if request.model.is_some() { + if request.model.is_some() || request.clear_model_override { state .agent_registry .update_and_save_custom_subagent_config( subagent_id, request.model, + request.clear_model_override, workspace.as_deref(), ) .map_err(|e| format!("Failed to update configuration: {}", e))?; @@ -484,14 +476,29 @@ pub async fn update_subagent_config( let config_service = &state.config_service; - if let Some(model) = request.model { - let mut agent_models: HashMap = config_service - .get_config(Some("ai.agent_models")) - .await - .unwrap_or_default(); - agent_models.insert(subagent_id.clone(), model); + if request.clear_model_override || request.model.is_some() { + let mut builtin_models: std::collections::HashMap = + config_service + .get_config(Some("ai.agent_model_defaults.subagents.builtin")) + .await + .unwrap_or_default(); + if request.clear_model_override { + builtin_models.remove(subagent_id); + } else { + let model = request + .model + .as_deref() + .expect("model checked above") + .trim(); + let selection = if model == "inherit" { + SubagentModelSelection::Inherit + } else { + SubagentModelSelection::fixed(model) + }; + builtin_models.insert(subagent_id.clone(), selection); + } config_service - .set_config("ai.agent_models", &agent_models) + .set_config("ai.agent_model_defaults.subagents.builtin", &builtin_models) .await .map_err(|e| format!("Failed to update model configuration: {}", e))?; model_updated = true; diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index d9cab24c84..e144064f9e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -951,8 +951,6 @@ pub async fn run() { discover_cli_credentials, refresh_cli_credential, initialize_ai, - set_agent_model, - get_agent_models, refresh_model_client, get_app_state, update_app_status, diff --git a/src/apps/server/src/rpc_dispatcher.rs b/src/apps/server/src/rpc_dispatcher.rs index b8c31789a2..bafe7a5aee 100644 --- a/src/apps/server/src/rpc_dispatcher.rs +++ b/src/apps/server/src/rpc_dispatcher.rs @@ -13,8 +13,8 @@ use bitfun_core::agentic::core::SessionConfig; use bitfun_core::agentic::deep_review_policy::{ apply_deep_review_queue_control, DeepReviewQueueControlAction, }; +use bitfun_core::service::config::SubagentModelSelection; use bitfun_core::service::i18n::{sync_global_i18n_service_locale, LocaleId, LocaleMetadata}; -use std::collections::HashMap; use std::path::PathBuf; use std::sync::Arc; use std::time::Duration; @@ -216,6 +216,15 @@ pub async fn dispatch( .get("model") .and_then(|v| v.as_str()) .map(|value| value.to_string()); + let clear_model_override = request + .get("clearModelOverride") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + if model.is_some() && clear_model_override { + return Err(anyhow!( + "model and clearModelOverride cannot be provided together" + )); + } let workspace = workspace_root_from_request(request.get("workspacePath").and_then(|v| v.as_str())); @@ -245,12 +254,13 @@ pub async fn dispatch( .map_err(|e| anyhow!("Failed to update subagent availability: {}", e))?; } - if model.is_some() { + if model.is_some() || clear_model_override { state .agent_registry .update_and_save_custom_subagent_config( &subagent_id, model, + clear_model_override, workspace.as_deref(), ) .map_err(|e| anyhow!("Failed to update configuration: {}", e))?; @@ -291,16 +301,29 @@ pub async fn dispatch( .map_err(|e| anyhow!("Failed to update subagent availability: {}", e))?; } - if let Some(model) = model { - let mut agent_models: HashMap = state + if clear_model_override || model.is_some() { + let mut builtin_models: std::collections::HashMap< + String, + SubagentModelSelection, + > = state .config_service - .get_config(Some("ai.agent_models")) + .get_config(Some("ai.agent_model_defaults.subagents.builtin")) .await .unwrap_or_default(); - agent_models.insert(subagent_id.clone(), model); + if clear_model_override { + builtin_models.remove(&subagent_id); + } else { + let model = model.as_deref().expect("model checked above").trim(); + let selection = if model == "inherit" { + SubagentModelSelection::Inherit + } else { + SubagentModelSelection::fixed(model) + }; + builtin_models.insert(subagent_id.clone(), selection); + } state .config_service - .set_config("ai.agent_models", &agent_models) + .set_config("ai.agent_model_defaults.subagents.builtin", &builtin_models) .await .map_err(|e| anyhow!("Failed to update model configuration: {}", e))?; } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/custom/common.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/common.rs index cc8fa8e3e5..83d60342a0 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/custom/common.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/common.rs @@ -19,6 +19,7 @@ pub(crate) struct CustomAgentData { pub path: String, pub level: CustomAgentLevel, pub model: String, + pub model_is_explicit: bool, pub user_context_policy: UserContextPolicy, } @@ -36,11 +37,16 @@ impl CustomAgentData { path, level: definition.level, model: definition.model, + model_is_explicit: definition.model_is_explicit, user_context_policy: definition.user_context_policy, } } - pub(crate) fn to_definition(&self, model: Option<&str>) -> CustomAgentDefinition { + pub(crate) fn to_definition( + &self, + model: Option<&str>, + model_is_explicit: Option, + ) -> CustomAgentDefinition { CustomAgentDefinition { id: self.id.clone(), name: self.name.clone(), @@ -52,6 +58,8 @@ impl CustomAgentData { review: self.review, level: self.level, model: model.unwrap_or(&self.model).to_string(), + model_is_explicit: model_is_explicit + .unwrap_or(model.is_some() || self.model_is_explicit), user_context_policy: self.user_context_policy.clone(), } } @@ -71,8 +79,12 @@ impl CustomAgentData { .await } - pub(crate) fn save_to_file(&self, model: Option<&str>) -> BitFunResult<()> { - let definition = self.to_definition(model); + pub(crate) fn save_to_file( + &self, + model: Option<&str>, + model_is_explicit: Option, + ) -> BitFunResult<()> { + let definition = self.to_definition(model, model_is_explicit); custom_agent_save_markdown_file(&self.path, &definition).map_err(BitFunError::Agent) } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs index 99ba11b6d1..73ad73050a 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/mode.rs @@ -57,7 +57,7 @@ impl CustomMode { } pub fn save_to_file(&self, model: Option<&str>) -> BitFunResult<()> { - self.data.save_to_file(model) + self.data.save_to_file(model, None) } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs b/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs index d6cc83a1b0..7a13735d05 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/custom/subagent.rs @@ -79,6 +79,35 @@ impl CustomSubagent { kind: CustomSubagentKind, model: String, user_context_policy: UserContextPolicy, + ) -> Self { + Self::new_with_id_and_model_explicit( + id, + name, + description, + tools, + prompt, + readonly, + path, + kind, + model, + true, + user_context_policy, + ) + } + + #[allow(clippy::too_many_arguments)] + pub fn new_with_id_and_model_explicit( + id: String, + name: String, + description: String, + tools: Vec, + prompt: String, + readonly: bool, + path: String, + kind: CustomSubagentKind, + model: String, + model_is_explicit: bool, + user_context_policy: UserContextPolicy, ) -> Self { let definition = CustomAgentDefinition::new( id, @@ -93,7 +122,9 @@ impl CustomSubagent { user_context_policy, ); - Self::from_definition(path, definition) + let mut subagent = Self::from_definition(path, definition); + subagent.data.model_is_explicit = model_is_explicit; + subagent } pub fn new( @@ -106,7 +137,7 @@ impl CustomSubagent { kind: CustomSubagentKind, ) -> Self { let id = name.clone(); - Self::new_with_id( + Self::new_with_id_and_model_explicit( id, name, description, @@ -116,6 +147,7 @@ impl CustomSubagent { path, kind, "fast".to_string(), + false, default_custom_agent_user_context_policy(CustomAgentKind::Subagent), ) } @@ -138,7 +170,15 @@ impl CustomSubagent { /// /// Fields equal to default values are not saved pub fn save_to_file(&self, model: Option<&str>) -> BitFunResult<()> { - self.data.save_to_file(model) + self.data.save_to_file(model, None) + } + + pub fn save_to_file_with_model_override( + &self, + model: Option<&str>, + model_is_explicit: bool, + ) -> BitFunResult<()> { + self.data.save_to_file(model, Some(model_is_explicit)) } pub fn set_review(&mut self, review: bool) { diff --git a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs index a1cbeb3d22..a30d05cb5f 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs @@ -102,6 +102,7 @@ impl AgentRegistry { .then(|| subagent_source_from_custom_kind(definition.level)); let custom_config = CustomAgentConfig { model: definition.model.clone(), + model_is_explicit: definition.model_is_explicit, }; let entry = AgentEntry { category: match definition.kind { @@ -185,6 +186,7 @@ impl AgentRegistry { valid_models.push("primary".to_string()); valid_models.push("fast".to_string()); valid_models.push("auto".to_string()); + valid_models.push("inherit".to_string()); valid_models } @@ -308,11 +310,12 @@ impl AgentRegistry { &self, agent_id: &str, model: Option, + clear_model_override: bool, workspace_root: Option<&Path>, ) -> BitFunResult<()> { let mut map = self.write_agents(); if let Some(entry) = map.get_mut(agent_id) { - return Self::update_custom_entry_config(agent_id, entry, model); + return Self::update_custom_entry_config(agent_id, entry, model, clear_model_override); } drop(map); @@ -333,22 +336,29 @@ impl AgentRegistry { .get_mut(agent_id) .ok_or_else(|| BitFunError::agent(format!("Agent not found: {}", agent_id)))?; - Self::update_custom_entry_config(agent_id, entry, model) + Self::update_custom_entry_config(agent_id, entry, model, clear_model_override) } pub fn update_and_save_custom_subagent_config( &self, agent_id: &str, model: Option, + clear_model_override: bool, workspace_root: Option<&Path>, ) -> BitFunResult<()> { - self.update_and_save_custom_agent_config(agent_id, model, workspace_root) + self.update_and_save_custom_agent_config( + agent_id, + model, + clear_model_override, + workspace_root, + ) } fn update_custom_entry_config( agent_id: &str, entry: &mut AgentEntry, model: Option, + clear_model_override: bool, ) -> BitFunResult<()> { let config = entry.custom_config.as_mut().ok_or_else(|| { BitFunError::agent(format!( @@ -357,11 +367,24 @@ impl AgentRegistry { )) })?; + if model.is_none() && !clear_model_override { + return Err(BitFunError::agent( + "A model or clear_model_override is required".to_string(), + )); + } + let new_model = model.unwrap_or_else(|| config.model.clone()); if let Some(custom_mode) = entry.agent.as_any().downcast_ref::() { + if clear_model_override { + return Err(BitFunError::agent( + "Clearing the model override is only supported for custom subagents" + .to_string(), + )); + } custom_mode.save_to_file(Some(&new_model))?; config.model = new_model; + config.model_is_explicit = true; return Ok(()); } @@ -376,8 +399,10 @@ impl AgentRegistry { )) })?; - custom_subagent.save_to_file(Some(&new_model))?; + custom_subagent + .save_to_file_with_model_override(Some(&new_model), !clear_model_override)?; config.model = new_model; + config.model_is_explicit = !clear_model_override; Ok(()) } @@ -521,13 +546,26 @@ impl AgentRegistry { "Built-in agents cannot be edited".to_string(), )); } + let current_model_config = entry.custom_config.as_ref().ok_or_else(|| { + BitFunError::agent(format!( + "Agent '{}' is not a custom file-backed agent", + agent_id + )) + })?; + let definition_model = model + .as_deref() + .unwrap_or(current_model_config.model.as_str()); + let definition_model_is_explicit = + model.is_some() || current_model_config.model_is_explicit; let readonly_tools = get_readonly_registered_tool_names().await; let valid_tools = get_all_registered_tool_names().await; let valid_models = Self::get_valid_model_ids().await; let replacement = if let Some(old) = entry.agent.as_any().downcast_ref::() { - let mut definition = old.data.to_definition(model.as_deref()); + let mut definition = old + .data + .to_definition(Some(definition_model), Some(definition_model_is_explicit)); let used_default_tools = tools.is_none(); definition.name = name; definition.description = description; @@ -564,7 +602,9 @@ impl AgentRegistry { if review { Self::ensure_review_tools_are_readonly(agent_id, &tools, &readonly_tools)?; } - let mut definition = old.data.to_definition(model.as_deref()); + let mut definition = old + .data + .to_definition(Some(definition_model), Some(definition_model_is_explicit)); definition.name = name; definition.description = description; definition.prompt = prompt; @@ -590,7 +630,7 @@ impl AgentRegistry { custom_agent_from_definition(old.data.path.clone(), definition) }; - save_runtime_custom_agent(&replacement, model.as_deref())?; + save_runtime_custom_agent(&replacement)?; self.replace_custom_agent_entry(agent_id, workspace_root, replacement) } @@ -836,21 +876,22 @@ fn custom_agent_from_definition(path: String, definition: CustomAgentDefinition) } } -fn save_runtime_custom_agent(agent: &Arc, model: Option<&str>) -> BitFunResult<()> { +fn save_runtime_custom_agent(agent: &Arc) -> BitFunResult<()> { if let Some(custom_mode) = agent.as_any().downcast_ref::() { - return custom_mode.save_to_file(model); + return custom_mode.save_to_file(None); } let custom_subagent = agent .as_any() .downcast_ref::() .ok_or_else(|| BitFunError::agent("Failed to save custom agent".to_string()))?; - custom_subagent.save_to_file(model) + custom_subagent.save_to_file(None) } fn custom_config_from_agent(agent: &dyn Agent) -> BitFunResult { if let Some(custom_mode) = agent.as_any().downcast_ref::() { return Ok(CustomAgentConfig { model: custom_mode.data.model.clone(), + model_is_explicit: custom_mode.data.model_is_explicit, }); } let custom_subagent = agent @@ -859,6 +900,7 @@ fn custom_config_from_agent(agent: &dyn Agent) -> BitFunResult, ) -> BitFunResult { - if self.find_agent_entry(agent_type, workspace_root).is_none() { - error!("[AgentRegistry] Agent not found: {}", agent_type); - return Err(BitFunError::agent(format!( - "[AgentRegistry] Agent not found: {}", - agent_type - ))); - } - - if let Some(entry) = self.find_agent_entry(agent_type, workspace_root) { - if let Some(config) = entry.custom_config { - let model = config.model; - if !model.is_empty() { - debug!( - "[AgentRegistry] Custom agent '{}' using model from cache: {}", - agent_type, model - ); - return Ok(model); - } + let entry = self + .find_agent_entry(agent_type, workspace_root) + .ok_or_else(|| { + error!("[AgentRegistry] Agent not found: {}", agent_type); + BitFunError::agent(format!("[AgentRegistry] Agent not found: {}", agent_type)) + })?; + if let Some(config) = entry.custom_config { + let model = config.model.trim(); + if !model.is_empty() && model != "inherit" { debug!( - "[AgentRegistry] Custom agent '{}' has empty cached model, using fallback default", - agent_type + "[AgentRegistry] Custom agent '{}' using model from cache: {}", + agent_type, model ); - return Ok("fast".to_string()); + return Ok(model.to_string()); } + + debug!( + "[AgentRegistry] Custom agent '{}' has no standalone model, using fallback default", + agent_type + ); + return Ok("fast".to_string()); } - if let Ok(config_service) = GlobalConfigManager::get_service().await { - let global_config: GlobalConfig = config_service.get_config(None).await?; - if let Some(model_id) = global_config.ai.agent_models.get(agent_type) { + if entry.category == AgentCategory::Mode { + if let Ok(config_service) = GlobalConfigManager::get_service().await { + let global_config: GlobalConfig = config_service.get_config(None).await?; + let model_id = global_config.ai.agent_model_defaults.mode.trim(); if !model_id.is_empty() { - return Ok(model_id.clone()); + return Ok(model_id.to_string()); } - } - } else { - error!( + } else { + error!( "[AgentRegistry] Config service not available, cannot get model config for Agent '{}'", agent_type - ) - }; + ); + } + } let default_model_id = default_model_id_for_builtin_agent(agent_type); warn!( diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 6ca908a6e3..15ad29ae47 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -65,6 +65,7 @@ fn test_project_entry(id: &str, model: &str) -> AgentEntry { visibility_policy: SubagentVisibilityPolicy::public(), custom_config: Some(CustomSubagentConfig { model: model.to_string(), + model_is_explicit: true, }), } } @@ -89,6 +90,7 @@ fn test_project_custom_entry(id: &str, review: bool) -> AgentEntry { visibility_policy: SubagentVisibilityPolicy::public(), custom_config: Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), } } @@ -502,6 +504,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() Some(SubAgentSource::User), Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), ); registry.register_agent( @@ -513,6 +516,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() Some(SubAgentSource::User), Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), ); registry.set_user_custom_agents_loaded(true); @@ -562,6 +566,7 @@ async fn parent_subagent_overrides_follow_source_scopes() { Some(SubAgentSource::User), Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), ); registry.set_user_custom_agents_loaded(true); @@ -585,6 +590,7 @@ async fn parent_subagent_overrides_follow_source_scopes() { visibility_policy: SubagentVisibilityPolicy::public(), custom_config: Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), }, ); @@ -829,7 +835,12 @@ async fn updating_custom_mode_model_persists_and_keeps_mode_category() { .load_custom_agents_from_test_roots(None, &env.discovery_roots(None)) .await; registry - .update_and_save_custom_agent_config("PlannerPlus", Some("primary".to_string()), None) + .update_and_save_custom_agent_config( + "PlannerPlus", + Some("primary".to_string()), + false, + None, + ) .expect("mode model update should save"); let mode = registry diff --git a/src/crates/assembly/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs index f8d9e35750..371640b110 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -26,6 +26,8 @@ use std::sync::Arc; pub struct CustomAgentConfig { /// used model ID pub model: String, + /// Whether the custom agent Markdown explicitly overrides the model. + pub model_is_explicit: bool, } pub type CustomSubagentConfig = CustomAgentConfig; @@ -96,6 +98,9 @@ pub struct AgentInfo { /// model configuration, only custom subagent has value (read from file) #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Whether `model` is an explicit custom Subagent override. + #[serde(skip_serializing_if = "Option::is_none")] + pub model_is_explicit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visibility: Option, } @@ -172,6 +177,10 @@ impl AgentInfo { .custom_config .as_ref() .map(|config| config.model.clone()); + let model_is_explicit = entry + .custom_config + .as_ref() + .map(|config| config.model_is_explicit); // get path by downcast to CustomSubagent (only custom subagent has path) let path = custom_agent_path(agent); @@ -201,6 +210,7 @@ impl AgentInfo { subagent_source: entry.subagent_source, path, model, + model_is_explicit, visibility: (entry.category == AgentCategory::SubAgent) .then(|| entry.visibility_policy.summary()), } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index d61d15d902..dbf4824863 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -49,6 +49,9 @@ use crate::service::bootstrap::{ ensure_workspace_persona_files_for_prompt, is_workspace_bootstrap_pending, }; use crate::service::config::global::GlobalConfigManager; +use crate::service::config::{ + get_global_config_service, AgentModelDefaultsConfig, SubagentModelSelection, +}; use crate::service::remote_ssh::normalize_remote_workspace_path; use crate::service::session::{SessionMemoryMode, SessionRelationship, SessionRelationshipKind}; use crate::service::workspace::{ @@ -87,6 +90,76 @@ const DEFAULT_SUBAGENT_MAX_CONCURRENCY: usize = 5; const MAX_SUBAGENT_MAX_CONCURRENCY: usize = 64; const SUBAGENT_TIMEOUT_GRACE_PERIOD: Duration = Duration::from_secs(10); +fn trimmed_model_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn snapshot_normal_session_model(config: &mut SessionConfig, defaults: &AgentModelDefaultsConfig) { + config.model_id = trimmed_model_id(config.model_id.as_deref()) + .or_else(|| trimmed_model_id(Some(defaults.mode.as_str()))) + .or_else(|| Some(AgentModelDefaultsConfig::default().mode)); +} + +#[cfg(test)] +tokio::task_local! { + static TEST_AGENT_MODEL_DEFAULTS: AgentModelDefaultsConfig; +} + +async fn normalize_model_selection(model_id: &str) -> BitFunResult { + let requested_model_id = model_id.trim(); + match requested_model_id { + "" | "auto" | "default" => Ok("auto".to_string()), + "primary" | "fast" => Ok(requested_model_id.to_string()), + model_config_id => { + let config_service = get_global_config_service().await.map_err(|error| { + BitFunError::AIClient(format!( + "Failed to load AI configuration for model update: {error}" + )) + })?; + let ai_config: crate::service::config::types::AIConfig = config_service + .get_config(Some("ai")) + .await + .map_err(|error| { + BitFunError::AIClient(format!( + "Failed to read AI configuration for model update: {error}" + )) + })?; + ai_config + .resolve_model_reference(model_config_id) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Unknown or disabled model configuration ID: {model_config_id}" + )) + }) + } + } +} + +fn resolve_subagent_model_selection( + explicit_model_id: Option<&str>, + configured_selection: &SubagentModelSelection, + parent_model_id: Option<&str>, +) -> BitFunResult { + if let Some(model_id) = trimmed_model_id(explicit_model_id) { + return Ok(model_id); + } + + match configured_selection { + SubagentModelSelection::Fixed { model_id } => trimmed_model_id(Some(model_id)).ok_or_else(|| { + BitFunError::Validation("Configured subagent model must not be empty".to_string()) + }), + SubagentModelSelection::Inherit => trimmed_model_id(parent_model_id).ok_or_else(|| { + BitFunError::Validation( + "Subagent model is configured to inherit, but the parent session has no model selection" + .to_string(), + ) + }), + } +} + fn is_review_agent_type(agent_type: &str) -> bool { matches!( agent_type.to_ascii_lowercase().as_str(), @@ -1156,8 +1229,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet end_time: Some(completed_at), duration_ms: Some(outcome.duration_ms), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -1231,8 +1304,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet end_time: Some(timestamp), duration_ms: Some(0), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -1408,15 +1481,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } pub async fn update_session_model(&self, session_id: &str, model_id: &str) -> BitFunResult<()> { - let normalized_model_id = model_id.trim(); - let normalized_model_id = if normalized_model_id.is_empty() { - "auto" - } else { - normalized_model_id - }; + let normalized_model_id = normalize_model_selection(model_id).await?; self.session_manager - .update_session_model_id(session_id, normalized_model_id) + .update_session_model_id(session_id, &normalized_model_id) .await?; info!( @@ -1427,7 +1495,9 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(()) } - /// Create a new session with explicit creator identity. + /// Common creation entry point for normal persisted sessions. + /// + /// Delegated subagent sessions use the hidden-subagent creation path instead. pub async fn create_session_with_workspace_and_creator( &self, session_id: Option, @@ -1441,6 +1511,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet // consistently restore the correct workspace regardless of the entry point. config.workspace_path = Some(workspace_path.clone()); config.workspace_id = Self::resolve_workspace_id_for_config(&config).await; + let defaults = Self::agent_model_defaults().await; + snapshot_normal_session_model(&mut config, &defaults); let agent_type = Self::normalize_agent_type(&agent_type); let session = self .session_manager @@ -5198,6 +5270,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_dialog_turn_id: parent_info.dialog_turn_id.clone(), parent_tool_call_id: parent_info.tool_call_id.clone(), agent_type: Some(agent_type.clone()), + model_id: self + .session_manager + .get_session(&session_id) + .and_then(|session| session.config.model_id.clone()), }) .await; } @@ -6244,6 +6320,83 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet Ok(context_messages) } + async fn agent_model_defaults() -> AgentModelDefaultsConfig { + #[cfg(test)] + if let Ok(defaults) = TEST_AGENT_MODEL_DEFAULTS.try_with(|defaults| defaults.clone()) { + return defaults; + } + + let Ok(config_service) = GlobalConfigManager::get_service().await else { + return AgentModelDefaultsConfig::default(); + }; + + config_service + .get_config(Some("ai.agent_model_defaults")) + .await + .unwrap_or_default() + } + + fn parent_model_selection( + &self, + parent_session_id: &str, + defaults: &AgentModelDefaultsConfig, + ) -> BitFunResult { + let parent_session = self + .session_manager + .get_session(parent_session_id) + .ok_or_else(|| { + BitFunError::NotFound(format!("Parent session not found: {}", parent_session_id)) + })?; + + trimmed_model_id(parent_session.config.model_id.as_deref()) + .or_else(|| trimmed_model_id(Some(defaults.mode.as_str()))) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Parent session has no model selection: {}", + parent_session_id + )) + }) + } + + async fn resolve_fresh_subagent_model_id( + &self, + explicit_model_id: Option<&str>, + agent_type: &str, + workspace_path: &str, + parent_session_id: &str, + ) -> BitFunResult { + let defaults = Self::agent_model_defaults().await; + let registry = get_agent_registry(); + let configured_selection = registry + .get_custom_subagent_config(agent_type, Some(Path::new(workspace_path))) + .filter(|custom| custom.model_is_explicit) + .map(|custom| { + if custom.model.trim() == "inherit" { + SubagentModelSelection::Inherit + } else { + SubagentModelSelection::fixed( + trimmed_model_id(Some(custom.model.as_str())) + .unwrap_or_else(|| "fast".to_string()), + ) + } + }) + .unwrap_or_else(|| defaults.builtin_subagent_selection(agent_type)); + let parent_model_id = if explicit_model_id.is_none() + && matches!(&configured_selection, SubagentModelSelection::Inherit) + { + Some(self.parent_model_selection(parent_session_id, &defaults)?) + } else { + None + }; + + let model_selection = resolve_subagent_model_selection( + explicit_model_id, + &configured_selection, + parent_model_id.as_deref(), + )?; + normalize_model_selection(&model_selection).await + } + async fn resolve_hidden_subagent_execution_request( &self, request: SubagentExecutionRequest, @@ -6292,9 +6445,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; if let Some(model_id) = model_id.as_deref() { + let model_id = normalize_model_selection(model_id).await?; let session_id = session.session_id.clone(); self.session_manager - .update_session_model_id(&session_id, model_id) + .update_session_model_id(&session_id, &model_id) .await?; session = self.session_manager @@ -6352,6 +6506,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; } + let resolved_model_id = self + .resolve_fresh_subagent_model_id( + model_id.as_deref(), + &agent_type, + &workspace_path, + &request.subagent_parent_info.session_id, + ) + .await?; Ok(HiddenSubagentExecutionRequest { target_session_id: None, @@ -6360,7 +6522,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet agent_type, session_config: Self::build_session_config_for_workspace( workspace_path, - model_id, + Some(resolved_model_id), ) .await, initial_messages: vec![Message::user(task_description.clone())], @@ -6405,10 +6567,31 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; } + let defaults = Self::agent_model_defaults().await; + let parent_model_id = if model_id.is_none() + && matches!(&defaults.subagents.fork, SubagentModelSelection::Inherit) + { + Some( + trimmed_model_id(snapshot.session_model_id.as_deref()) + .or_else(|| trimmed_model_id(Some(defaults.mode.as_str()))) + .ok_or_else(|| { + BitFunError::Validation(format!( + "Fork parent session has no model selection: {}", + snapshot.parent_session_id + )) + })?, + ) + } else { + None + }; + let model_selection = resolve_subagent_model_selection( + model_id.as_deref(), + &defaults.subagents.fork, + parent_model_id.as_deref(), + )?; + let resolved_model_id = normalize_model_selection(&model_selection).await?; let mut session_config = snapshot.build_child_session_config(None); - if let Some(model_id) = model_id { - session_config.model_id = Some(model_id); - } + session_config.model_id = Some(resolved_model_id); let mut initial_messages = snapshot.messages.clone(); initial_messages.push(Message::internal_reminder( InternalReminderKind::ForkSubagent, @@ -8151,9 +8334,10 @@ mod tests { use super::{ background_subagent_delivery_metadata, merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, - resolve_agent_submission_turn_id, runtime_port_error_preserving_message, - should_require_tool_confirmation, turn_review_manifest_for_agent, - validate_background_subagent_delivery, ConversationCoordinator, SubagentExecutionRequest, + resolve_agent_submission_turn_id, resolve_subagent_model_selection, + runtime_port_error_preserving_message, should_require_tool_confirmation, + turn_review_manifest_for_agent, validate_background_subagent_delivery, + ConversationCoordinator, SubagentExecutionRequest, TEST_AGENT_MODEL_DEFAULTS, }; use crate::agentic::agents::{CustomSubagent, CustomSubagentKind, UserContextPolicy}; use crate::agentic::core::{ @@ -8176,6 +8360,7 @@ mod tests { use crate::agentic::tools::{ToolPipeline, ToolStateManager}; use crate::agentic::TurnSkillAgentSnapshot; use crate::infrastructure::PathManager; + use crate::service::config::{AgentModelDefaultsConfig, SubagentModelSelection}; use crate::service::remote_ssh::workspace_state::init_remote_workspace_manager; use crate::service::session::SessionMetadata; use bitfun_runtime_ports::{ @@ -9228,6 +9413,94 @@ mod tests { .session_name, "Fixed worker" ); + } + + #[tokio::test] + async fn normal_sessions_keep_the_mode_default_snapshotted_at_creation() { + let (coordinator, session_manager) = test_coordinator(); + let workspace_path = std::env::temp_dir().join(format!( + "bitfun-normal-session-model-snapshot-test-{}", + uuid::Uuid::new_v4() + )); + std::fs::create_dir_all(&workspace_path).expect("workspace dir should exist"); + let workspace_path_string = workspace_path.to_string_lossy().into_owned(); + + let first = TEST_AGENT_MODEL_DEFAULTS + .scope( + AgentModelDefaultsConfig { + mode: "model-a".to_string(), + ..Default::default() + }, + coordinator.create_session_with_workspace( + None, + "First".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path_string.clone()), + ..Default::default() + }, + workspace_path_string.clone(), + ), + ) + .await + .expect("first normal session should be created"); + + let second = TEST_AGENT_MODEL_DEFAULTS + .scope( + AgentModelDefaultsConfig { + mode: "model-b".to_string(), + ..Default::default() + }, + coordinator.create_session_with_workspace( + None, + "Second".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path_string.clone()), + ..Default::default() + }, + workspace_path_string.clone(), + ), + ) + .await + .expect("second normal session should be created"); + + assert_eq!( + session_manager + .get_session(&first.session_id) + .and_then(|session| session.config.model_id.clone()) + .as_deref(), + Some("model-a") + ); + assert_eq!( + session_manager + .get_session(&second.session_id) + .and_then(|session| session.config.model_id.clone()) + .as_deref(), + Some("model-b") + ); + + let explicit = TEST_AGENT_MODEL_DEFAULTS + .scope( + AgentModelDefaultsConfig { + mode: "model-c".to_string(), + ..Default::default() + }, + coordinator.create_session_with_workspace( + None, + "Explicit".to_string(), + "agentic".to_string(), + SessionConfig { + workspace_path: Some(workspace_path_string.clone()), + model_id: Some("explicit-model".to_string()), + ..Default::default() + }, + workspace_path_string, + ), + ) + .await + .expect("explicit-model normal session should be created"); + assert_eq!(explicit.config.model_id.as_deref(), Some("explicit-model")); let _ = std::fs::remove_dir_all(workspace_path); } @@ -9693,6 +9966,37 @@ mod tests { ); } + #[test] + fn subagent_model_resolution_prioritizes_explicit_fixed_and_inherited_values() { + assert_eq!( + resolve_subagent_model_selection( + Some("explicit-model"), + &SubagentModelSelection::fixed("configured-model"), + Some("parent-model"), + ) + .expect("explicit model should win"), + "explicit-model" + ); + assert_eq!( + resolve_subagent_model_selection( + None, + &SubagentModelSelection::fixed("configured-model"), + Some("parent-model"), + ) + .expect("configured model should win"), + "configured-model" + ); + assert_eq!( + resolve_subagent_model_selection(None, &SubagentModelSelection::Inherit, Some("auto"),) + .expect("inherit should preserve the parent selector"), + "auto" + ); + assert!( + resolve_subagent_model_selection(None, &SubagentModelSelection::Inherit, None,) + .is_err() + ); + } + #[test] fn turn_review_manifest_is_ignored_for_ordinary_agents() { let metadata = serde_json::json!({ diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 3f6f26c308..64ab6974bf 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -896,22 +896,7 @@ impl ExecutionEngine { service.get_config(Some("ai")).await.unwrap_or_default(); let resolved_id = Self::resolve_configured_model_id(&ai_config, model_id); - let model_cfg = ai_config - .models - .iter() - .find(|m| m.id == resolved_id) - .or_else(|| ai_config.models.iter().find(|m| m.name == resolved_id)) - .or_else(|| { - ai_config - .models - .iter() - .find(|m| m.model_name == resolved_id) - }) - .or_else(|| { - ai_config.models.iter().find(|m| { - m.model_name == ai_client_model && m.provider == ai_client_api_format - }) - }); + let model_cfg = ai_config.models.iter().find(|m| m.id == resolved_id); let supports = model_cfg.is_some_and(|m| { m.capabilities @@ -1379,7 +1364,8 @@ impl ExecutionEngine { available_tools: finalize_tool_names, deferred_tools: Vec::new(), loaded_deferred_tool_specs: Vec::new(), - model_name: input.ai_client.config.model.clone(), + model_config_id: input.primary_model_facts.model_id.clone(), + effective_model_name: input.ai_client.config.model.clone(), primary_model_facts: input.primary_model_facts.clone(), agent_type: input.agent_type, context_vars: input.execution_context_vars.clone(), @@ -3123,7 +3109,8 @@ impl ExecutionEngine { available_tools: available_tools.clone(), deferred_tools: deferred_tools.clone(), loaded_deferred_tool_specs, - model_name: ai_client.config.model.clone(), + model_config_id: model_id.clone(), + effective_model_name: ai_client.config.model.clone(), primary_model_facts: primary_model_facts.clone(), agent_type: agent_type.clone(), context_vars: round_context_vars, diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 7415e5bb4e..851bff2b17 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -171,7 +171,8 @@ impl RoundExecutor { round_id: round_id.clone(), round_group_id: context.round_group_id.clone(), round_index: context.round_number, - model_id: Some(context.model_name.clone()), + model_config_id: context.model_config_id.clone(), + effective_model_name: context.effective_model_name.clone(), }, EventPriority::High, ) @@ -197,7 +198,7 @@ impl RoundExecutor { let request_started_at = Instant::now(); debug!( "Sending request: model={}, messages={}, tools={}, attempt={}/{}", - context.model_name, + context.effective_model_name, ai_messages.len(), tool_definitions.as_ref().map(|t| t.len()).unwrap_or(0), attempt_index + 1, @@ -648,8 +649,8 @@ impl RoundExecutor { has_tool_calls: !stream_result.tool_calls.is_empty(), duration_ms: Some(elapsed_ms_u64(round_started_at)), provider_id: None, - model_id: Some(context.model_name.clone()), - model_alias: Some(context.model_name.clone()), + model_config_id: context.model_config_id.clone(), + effective_model_name: context.effective_model_name.clone(), first_chunk_ms: stream_result.first_chunk_ms, first_visible_output_ms: stream_result.first_visible_output_ms, stream_duration_ms: Some(stream_processing_ms), @@ -1077,7 +1078,8 @@ impl RoundExecutor { AgenticEvent::TokenUsageUpdated { session_id: context.session_id.clone(), turn_id: context.dialog_turn_id.clone(), - model_id: context.model_name.clone(), + model_config_id: context.model_config_id.clone(), + effective_model_name: context.effective_model_name.clone(), input_tokens: usage.prompt_token_count as usize, output_tokens: Some(usage.candidates_token_count as usize), total_tokens: usage.total_token_count as usize, @@ -1433,7 +1435,8 @@ mod tests { available_tools: Vec::new(), deferred_tools: Vec::new(), loaded_deferred_tool_specs: Vec::new(), - model_name: "model-1".to_string(), + model_config_id: "model-1".to_string(), + effective_model_name: "model-1".to_string(), primary_model_facts: tool_runtime::context::PrimaryModelFacts::new( "model-1", "model-1", "openai", true, ), @@ -1503,7 +1506,8 @@ mod tests { crate::agentic::events::AgenticEvent::TokenUsageUpdated { session_id, turn_id, - model_id, + model_config_id, + effective_model_name, input_tokens: 100, output_tokens: Some(20), total_tokens: 120, @@ -1511,7 +1515,10 @@ mod tests { is_subagent: false, cached_tokens: Some(30), .. - } if session_id == "session-1" && turn_id == "turn-1" && model_id == "model-1" + } if session_id == "session-1" + && turn_id == "turn-1" + && model_config_id == "model-1" + && effective_model_name == "model-1" ))); } diff --git a/src/crates/assembly/core/src/agentic/execution/types.rs b/src/crates/assembly/core/src/agentic/execution/types.rs index fb41e589c0..e5897a23ac 100644 --- a/src/crates/assembly/core/src/agentic/execution/types.rs +++ b/src/crates/assembly/core/src/agentic/execution/types.rs @@ -59,7 +59,10 @@ pub struct RoundContext { pub available_tools: Vec, pub deferred_tools: Vec, pub loaded_deferred_tool_specs: Vec, - pub model_name: String, + /// Resolved `AIModelConfig.id` used to construct the client for this round. + pub model_config_id: String, + /// Provider model name sent in the request. + pub effective_model_name: String, pub primary_model_facts: PrimaryModelFacts, pub agent_type: String, pub context_vars: HashMap, diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 2655154e0c..0e160f21a8 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -381,8 +381,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/assembly/core/src/agentic/persistence/manager.rs b/src/crates/assembly/core/src/agentic/persistence/manager.rs index d1e88b9b6f..c3fc99a148 100644 --- a/src/crates/assembly/core/src/agentic/persistence/manager.rs +++ b/src/crates/assembly/core/src/agentic/persistence/manager.rs @@ -3243,8 +3243,8 @@ mod tests { end_time: Some(0), duration_ms: Some(0), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index 273a1626e7..bc34324f48 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -404,14 +404,12 @@ impl SessionManager { return Self::context_window_for_model_selection(ai_config, configured_model_id); } - let agent_model_id = ai_config - .agent_models - .get(&session.agent_type) - .map(String::as_str) - .map(str::trim) + let fallback_model_id = (session.kind != SessionKind::Subagent) + .then(|| ai_config.agent_model_defaults.mode.trim().to_string()) .filter(|model_id| !Self::is_auto_model_selector(model_id)); - agent_model_id + fallback_model_id + .as_deref() .and_then(|model_id| Self::context_window_for_model_selection(ai_config, model_id)) .or_else(|| Self::context_window_for_model_selection(ai_config, "primary")) } @@ -4980,8 +4978,8 @@ impl SessionManager { end_time: Some(timestamp), duration_ms: Some(0), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -5143,8 +5141,8 @@ impl SessionManager { end_time: Some(completion_timestamp), duration_ms: Some(0), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -6498,7 +6496,7 @@ mod tests { } #[test] - fn sync_session_context_window_resolves_auto_through_agent_model_then_primary() { + fn sync_session_context_window_resolves_auto_through_mode_default_then_primary() { let mut ai_config = ServiceAIConfig { models: vec![ test_model("primary-model", 512_000), @@ -6507,9 +6505,7 @@ mod tests { ..Default::default() }; ai_config.default_models.primary = Some("primary-model".to_string()); - ai_config - .agent_models - .insert("agentic".to_string(), "agent-model".to_string()); + ai_config.agent_model_defaults.mode = "agent-model".to_string(); let mut session = Session::new_with_id( "session-auto".to_string(), @@ -6528,7 +6524,7 @@ mod tests { assert_eq!(resolved, Some(1_000_000)); assert_eq!(session.config.max_context_tokens, 1_000_000); - ai_config.agent_models.clear(); + ai_config.agent_model_defaults.mode = "auto".to_string(); session.config.max_context_tokens = 256_000; let resolved = @@ -6538,6 +6534,37 @@ mod tests { assert_eq!(session.config.max_context_tokens, 512_000); } + #[test] + fn sync_session_context_window_resolves_subagent_auto_through_primary() { + let mut ai_config = ServiceAIConfig { + models: vec![ + test_model("primary-model", 512_000), + test_model("mode-model", 1_000_000), + ], + ..Default::default() + }; + ai_config.default_models.primary = Some("primary-model".to_string()); + ai_config.agent_model_defaults.mode = "mode-model".to_string(); + + let mut session = Session::new_with_id( + "subagent-auto".to_string(), + "Auto subagent".to_string(), + "Explore".to_string(), + SessionConfig { + model_id: Some("auto".to_string()), + max_context_tokens: 256_000, + ..Default::default() + }, + ); + session.kind = SessionKind::Subagent; + + let resolved = + SessionManager::sync_session_context_window_from_ai_config(&mut session, &ai_config); + + assert_eq!(resolved, Some(512_000)); + assert_eq!(session.config.max_context_tokens, 512_000); + } + #[tokio::test] async fn auto_save_interval_waits_before_first_tick() { let mut ticker = SessionManager::auto_save_interval(Duration::from_millis(40)); @@ -7562,8 +7589,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index d1cff6e353..874c9644ef 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -968,6 +968,7 @@ async fn prompt_stability_description_with_context_renders_available_agents_in_s SubAgentSource::Project, Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), ); register_prompt_order_test_subagent( @@ -975,6 +976,7 @@ async fn prompt_stability_description_with_context_renders_available_agents_in_s SubAgentSource::Project, Some(CustomSubagentConfig { model: "fast".to_string(), + model_is_explicit: true, }), ); diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index 252fe0e64c..fc6e48635e 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -25,6 +25,17 @@ pub struct AIClientFactory { client_cache: RwLock>>, } +fn functional_agent_model_selector<'a>( + ai_config: &'a crate::service::config::types::AIConfig, + func_agent_name: &str, +) -> &'a str { + ai_config + .func_agent_models + .get(func_agent_name) + .map(String::as_str) + .unwrap_or("fast") +} + impl AIClientFactory { fn new(config_service: Arc) -> Self { Self { @@ -33,31 +44,12 @@ impl AIClientFactory { } } - /// Get the main agent's AI client - /// Falls back to primary when no dedicated model is configured - pub async fn get_client_by_agent(&self, agent_name: &str) -> Result> { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - - match global_config.ai.agent_models.get(agent_name) { - Some(model_id) => self.get_client_resolved(model_id).await, - None => self.get_client_resolved("primary").await, - } - } - - /// Get a functional agent's AI client - /// Prefer func_agent_models, fall back to agent_models (legacy), then fast + /// Get a functional agent's AI client using its dedicated mapping or fast. pub async fn get_client_by_func_agent(&self, func_agent_name: &str) -> Result> { let global_config: crate::service::config::GlobalConfig = self.config_service.get_config(None).await?; - let model_id = global_config - .ai - .func_agent_models - .get(func_agent_name) - .or_else(|| global_config.ai.agent_models.get(func_agent_name)) - .map(String::as_str) - .unwrap_or("fast"); + let model_id = functional_agent_model_selector(&global_config.ai, func_agent_name); self.get_client_resolved(model_id).await } @@ -125,6 +117,19 @@ impl AIClientFactory { |selector| global_config.ai.resolve_model_selection(selector), |model_ref| global_config.ai.resolve_model_reference(model_ref), ); + if global_config + .ai + .models + .iter() + .filter(|model| model.id == normalized_model_id) + .nth(1) + .is_some() + { + return Err(anyhow!( + "Multiple model configurations use the same ID: {}", + normalized_model_id + )); + } { let cache = match self.client_cache.read() { @@ -142,16 +147,20 @@ impl AIClientFactory { } debug!("Creating new AI client: model_id={}", normalized_model_id); - let model_config = global_config + let mut matching_models = global_config .ai .models .iter() - .find(|m| { - m.id == normalized_model_id - || m.name == normalized_model_id - || m.model_name == normalized_model_id - }) + .filter(|m| m.id == normalized_model_id); + let model_config = matching_models + .next() .ok_or_else(|| anyhow!("Model configuration not found: {}", normalized_model_id))?; + if matching_models.next().is_some() { + return Err(anyhow!( + "Multiple model configurations use the same ID: {}", + normalized_model_id + )); + } if !model_config.enabled { return Err(anyhow!( @@ -334,7 +343,7 @@ mod tests { } #[test] - fn resolve_model_reference_supports_id_name_and_model_name() { + fn resolve_model_reference_requires_a_config_id() { let mut config = GlobalConfig::default(); config.ai.models = vec![build_model( "model-123", @@ -346,14 +355,15 @@ mod tests { config.ai.resolve_model_reference("model-123"), Some("model-123".to_string()) ); - assert_eq!( - config.ai.resolve_model_reference("Primary Chat"), - Some("model-123".to_string()) - ); - assert_eq!( - config.ai.resolve_model_reference("claude-sonnet-4.5"), - Some("model-123".to_string()) - ); + assert_eq!(config.ai.resolve_model_reference("Primary Chat"), None); + assert_eq!(config.ai.resolve_model_reference("claude-sonnet-4.5"), None); + + config.ai.models.push(build_model( + "model-123", + "Duplicate Config", + "claude-sonnet-4.5-duplicate", + )); + assert_eq!(config.ai.resolve_model_reference("model-123"), None); } #[test] diff --git a/src/crates/assembly/core/src/service/config/global.rs b/src/crates/assembly/core/src/service/config/global.rs index ddffab44c1..cede1d798f 100644 --- a/src/crates/assembly/core/src/service/config/global.rs +++ b/src/crates/assembly/core/src/service/config/global.rs @@ -60,11 +60,11 @@ pub enum ConfigUpdateEvent { /// Whether logs may include prompts, payloads, and other sensitive diagnostics. include_sensitive_diagnostics: bool, }, - /// AI models / default-model slots / agent-model mappings were reconciled + /// AI models / default-model slots / agent-model defaults were reconciled /// after a model became unavailable (disabled, deleted, or otherwise /// invalid). Emitted whenever the config layer had to silently rewrite - /// `ai.default_models`, `ai.agent_models`, or `ai.func_agent_models` so they - /// only reference enabled models. + /// `ai.default_models`, `ai.agent_model_defaults`, or `ai.func_agent_models` + /// so they only reference enabled models. ModelsReconciled { /// Model ids that just became unusable (disabled or deleted) and that /// any active session, default slot, or agent mapping was pointing at @@ -72,9 +72,10 @@ pub enum ConfigUpdateEvent { invalidated_model_ids: Vec, /// Whether `ai.default_models` was rewritten as part of the reconcile. default_models_changed: bool, - /// Whether `ai.agent_models` or `ai.func_agent_models` were rewritten - /// as part of the reconcile. - agent_models_changed: bool, + /// Whether `ai.func_agent_models` was rewritten as part of the reconcile. + func_agent_models_changed: bool, + /// Whether `ai.agent_model_defaults` was rewritten as part of the reconcile. + agent_model_defaults_changed: bool, }, } diff --git a/src/crates/assembly/core/src/service/config/manager.rs b/src/crates/assembly/core/src/service/config/manager.rs index 16b3a51923..be64f9263e 100644 --- a/src/crates/assembly/core/src/service/config/manager.rs +++ b/src/crates/assembly/core/src/service/config/manager.rs @@ -82,6 +82,49 @@ pub(crate) fn normalize_legacy_theme_config_value(mut config: Value) -> Value { config } +/// Moves the only trustworthy legacy mode choice into the new default domain. +/// +/// Historical global model switching rewrote every `ai.agent_models` entry, +/// including builtin subagents. Only the `agentic` mode entry is used as a +/// migration hint when the new defaults are absent. The entire legacy mapping +/// is removed after normalization. +pub(crate) fn normalize_legacy_agent_model_defaults_config_value(mut config: Value) -> Value { + let Some(root) = config.as_object_mut() else { + return config; + }; + let ai = root + .entry("ai".to_string()) + .or_insert_with(|| serde_json::json!({})); + let Some(ai) = ai.as_object_mut() else { + return config; + }; + + if !ai.contains_key("agent_model_defaults") { + let mode = ai + .get("agent_models") + .and_then(Value::as_object) + .and_then(|models| models.get("agentic")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|model| !model.is_empty()) + .unwrap_or("auto") + .to_string(); + + let defaults = AgentModelDefaultsConfig { + mode, + ..Default::default() + }; + ai.insert( + "agent_model_defaults".to_string(), + serde_json::to_value(defaults).expect("agent model defaults should always serialize"), + ); + } + + ai.remove("agent_models"); + + config +} + fn config_value_for_persistence(config: &GlobalConfig) -> BitFunResult { let mut value = serde_json::to_value(config) .map_err(|e| BitFunError::config(format!("Failed to serialize config: {}", e)))?; @@ -199,7 +242,6 @@ impl ConfigManager { /// Creates the first config file using the already initialized defaults. async fn create_default_config(&mut self) -> BitFunResult<()> { - Self::add_default_agent_models_config(&mut self.config.ai.agent_models); Self::add_default_func_agent_models_config(&mut self.config.ai.func_agent_models); self.config.version = env!("CARGO_PKG_VERSION").to_string(); self.save_config().await?; @@ -216,8 +258,10 @@ impl ConfigManager { let mut config_value: Value = serde_json::from_str(&content).map_err(|e| { BitFunError::config(format!("Failed to parse config file as JSON: {}", e)) })?; - let normalized_config_value = normalize_legacy_theme_config_value(config_value.clone()); - let legacy_theme_normalized = normalized_config_value != config_value; + let normalized_config_value = normalize_legacy_agent_model_defaults_config_value( + normalize_legacy_theme_config_value(config_value.clone()), + ); + let legacy_config_normalized = normalized_config_value != config_value; config_value = normalized_config_value; let file_version = config_value @@ -249,12 +293,11 @@ impl ConfigManager { match serde_json::from_value::(config_value.clone()) { Ok(mut config) => { Self::ensure_models_config(&mut config.ai.models); - Self::add_default_agent_models_config(&mut config.ai.agent_models); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); self.config = config; - if needs_migration || legacy_theme_normalized { + if needs_migration || legacy_config_normalized { self.config.version = current_version; self.save_config().await?; info!("Config normalized and saved"); @@ -277,7 +320,9 @@ impl ConfigManager { /// Performs a smart merge from a JSON value. async fn smart_merge_config_from_value(&mut self, user_value: Value) -> BitFunResult<()> { - let user_value = normalize_legacy_theme_config_value(user_value); + let user_value = normalize_legacy_agent_model_defaults_config_value( + normalize_legacy_theme_config_value(user_value), + ); let base_config = self.providers.get_default_config(); let base_value = serde_json::to_value(&base_config).map_err(|e| { @@ -290,7 +335,6 @@ impl ConfigManager { })?; Self::ensure_models_config(&mut config.ai.models); - Self::add_default_agent_models_config(&mut config.ai.agent_models); Self::add_default_func_agent_models_config(&mut config.ai.func_agent_models); self.config = config; @@ -314,18 +358,6 @@ impl ConfigManager { ); } - /// Adds default configuration for the primary agents (`agent_models`). - fn add_default_agent_models_config( - agent_models: &mut std::collections::HashMap, - ) { - let agents_using_fast = vec!["Explore", "FileFinder", "GenerateDoc", "CodeReview"]; - for key in agents_using_fast { - if !agent_models.contains_key(key) { - agent_models.insert(key.to_string(), "fast".to_string()); - } - } - } - /// Adds default configuration for functional agents (`func_agent_models`). fn add_default_func_agent_models_config( func_agent_models: &mut std::collections::HashMap, @@ -482,7 +514,9 @@ impl ConfigManager { /// Imports configuration. pub async fn import_config(&mut self, config_data: serde_json::Value) -> BitFunResult<()> { let old_config = self.config.clone(); - let config_data = normalize_legacy_theme_config_value(config_data); + let config_data = normalize_legacy_agent_model_defaults_config_value( + normalize_legacy_theme_config_value(config_data), + ); let imported_config: GlobalConfig = serde_json::from_value(config_data) .map_err(|e| BitFunError::config(format!("Failed to parse imported config: {}", e)))?; @@ -804,23 +838,6 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { if !ai.contains_key("sub_agent_models") { ai.insert("sub_agent_models".to_string(), serde_json::json!({})); } - if !ai.contains_key("func_agent_models") { - let func_keys = [ - "compression", - "startchat-func-agent", - "session-title-func-agent", - "git-func-agent", - ]; - let mut fa = serde_json::Map::new(); - if let Some(am) = ai.get("agent_models").and_then(|v| v.as_object()) { - for k in func_keys { - if let Some(v) = am.get(k) { - fa.insert(k.to_string(), v.clone()); - } - } - } - ai.insert("func_agent_models".to_string(), Value::Object(fa)); - } } debug!("Migration 0.0.0 -> 1.0.0 completed"); @@ -830,7 +847,8 @@ pub(crate) fn migrate_0_0_0_to_1_0_0(mut config: Value) -> BitFunResult { #[cfg(test)] mod tests { use super::{ - canonical_config_path, config_value_for_persistence, normalize_legacy_theme_config_value, + canonical_config_path, config_value_for_persistence, + normalize_legacy_agent_model_defaults_config_value, normalize_legacy_theme_config_value, }; use crate::service::config::types::GlobalConfig; @@ -895,6 +913,78 @@ mod tests { assert!(normalized.get("theme").is_none()); } + #[test] + fn legacy_agent_models_only_seed_the_shared_mode_default() { + let normalized = normalize_legacy_agent_model_defaults_config_value(serde_json::json!({ + "ai": { + "agent_models": { + "agentic": "primary", + "Explore": "expensive-model" + } + } + })); + + assert_eq!(normalized["ai"]["agent_model_defaults"]["mode"], "primary"); + assert_eq!( + normalized["ai"]["agent_model_defaults"]["subagents"]["default"], + serde_json::json!({ "kind": "fixed", "model_id": "fast" }) + ); + assert_eq!( + normalized["ai"]["agent_model_defaults"]["subagents"]["builtin"], + serde_json::json!({ + "GeneralPurpose": { "kind": "fixed", "model_id": "primary" } + }) + ); + assert_eq!( + normalized["ai"]["agent_model_defaults"]["subagents"]["fork"], + serde_json::json!({ "kind": "inherit" }) + ); + assert!(normalized["ai"].get("agent_models").is_none()); + } + + #[test] + fn current_agent_model_defaults_win_before_legacy_mapping_is_removed() { + let normalized = normalize_legacy_agent_model_defaults_config_value(serde_json::json!({ + "ai": { + "agent_models": { + "agentic": "legacy-model" + }, + "agent_model_defaults": { + "mode": "current-model", + "subagents": { + "default": { "kind": "fixed", "model_id": "fast" }, + "builtin": { + "GeneralPurpose": { "kind": "fixed", "model_id": "primary" } + }, + "fork": { "kind": "inherit" } + } + } + } + })); + + assert_eq!( + normalized["ai"]["agent_model_defaults"]["mode"], + "current-model" + ); + assert!(normalized["ai"].get("agent_models").is_none()); + } + + #[test] + fn current_config_without_legacy_mapping_is_unchanged() { + let config = serde_json::json!({ + "ai": { + "agent_model_defaults": { + "mode": "current-model" + } + } + }); + + assert_eq!( + normalize_legacy_agent_model_defaults_config_value(config.clone()), + config + ); + } + #[test] fn persistence_omits_default_memories_config() { let config = GlobalConfig::default(); @@ -902,6 +992,7 @@ mod tests { config_value_for_persistence(&config).expect("config should serialize for persistence"); assert!(value.get("memories").is_none()); + assert!(value["ai"].get("agent_models").is_none()); } #[test] diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index a7a05591c5..338eefbe92 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -97,18 +97,6 @@ impl ConfigProvider for AIConfigProvider { } } - for (agent_name, model_id) in &ai_config.agent_models { - if !ai_config.models.iter().any(|m| m.id == *model_id) - && model_id != "auto" - && model_id != "primary" - && model_id != "fast" - { - return Err(BitFunError::validation(format!( - "Primary Agent '{}' configured model '{}' does not exist", - agent_name, model_id - ))); - } - } for (func_agent_name, model_id) in &ai_config.func_agent_models { if !ai_config.models.iter().any(|m| m.id == *model_id) && model_id != "primary" diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 67309f5fa6..a538444080 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -116,7 +116,7 @@ impl ConfigService { path == "ai" || path.starts_with("ai.models") || path.starts_with("ai.default_models") - || path.starts_with("ai.agent_models") + || path.starts_with("ai.agent_model_defaults") || path.starts_with("ai.func_agent_models") } @@ -337,12 +337,12 @@ impl ConfigService { self.set_config("ai.models", &config.ai.models).await } - /// Bring `ai.default_models`, `ai.agent_models`, and `ai.func_agent_models` - /// back into a consistent state with `ai.models`. + /// Bring `ai.default_models`, `ai.agent_model_defaults`, and + /// `ai.func_agent_models` back into a consistent state with `ai.models`. /// /// This is the single integrity guard the rest of the system relies on: - /// - any agent / func-agent mapping pointing at a model that no longer - /// exists or that became disabled is dropped; + /// - any func-agent mapping pointing at a model that no longer exists or + /// that became disabled is dropped; /// - `default_models.primary` / `.fast` are repointed to the first enabled /// model when their current target is missing or disabled (or cleared /// when no enabled model exists at all); @@ -364,60 +364,30 @@ impl ConfigService { .filter(|m| m.enabled) .map(|m| m.id.clone()) .collect(); - let known_ids: HashSet = config.ai.models.iter().map(|m| m.id.clone()).collect(); - - // Precompute lookup tables so the closures below do not need to - // borrow `config.ai` (which would conflict with the later mutations - // of `config.ai.agent_models` / `config.ai.default_models`). - let mut active_refs: HashSet = HashSet::new(); - let mut any_ref_to_id: std::collections::HashMap = - std::collections::HashMap::new(); - for m in &config.ai.models { - any_ref_to_id - .entry(m.id.clone()) - .or_insert_with(|| m.id.clone()); - any_ref_to_id - .entry(m.name.clone()) - .or_insert_with(|| m.id.clone()); - any_ref_to_id - .entry(m.model_name.clone()) - .or_insert_with(|| m.id.clone()); - if m.enabled { - active_refs.insert(m.id.clone()); - active_refs.insert(m.name.clone()); - active_refs.insert(m.model_name.clone()); - } - } let is_active = |reference: &str| -> bool { // Special selectors are always considered active; their actual // resolution happens at runtime against the (already reconciled) // default slots. - matches!(reference, "auto" | "primary" | "fast") || active_refs.contains(reference) + matches!(reference, "auto" | "primary" | "fast") || enabled_ids.contains(reference) }; let classify_invalid = |reference: &str, invalidated: &mut HashSet| -> bool { if is_active(reference) { return false; } - // Resolve back to the canonical id (if the reference is by - // name / model_name pointing at a now-disabled model) so we - // can report a stable identifier. - let canonical = any_ref_to_id - .get(reference) - .cloned() - .unwrap_or_else(|| reference.to_string()); - invalidated.insert(canonical); + invalidated.insert(reference.to_string()); true }; let mut invalidated: HashSet = HashSet::new(); - let mut agent_models_changed = false; + let mut func_agent_models_changed = false; + let mut agent_model_defaults_changed = false; let mut default_models_changed = false; - // 1. agent_models - let agent_keys_to_remove: Vec = config + // 1. func_agent_models + let func_keys_to_remove: Vec = config .ai - .agent_models + .func_agent_models .iter() .filter_map(|(agent, model_ref)| { if classify_invalid(model_ref, &mut invalidated) { @@ -427,33 +397,81 @@ impl ConfigService { } }) .collect(); - for agent in agent_keys_to_remove { + for agent in func_keys_to_remove { warn!( - "Reconcile ({caller}): clearing ai.agent_models[{agent}] because target model is missing or disabled" + "Reconcile ({caller}): clearing ai.func_agent_models[{agent}] because target model is missing or disabled" ); - config.ai.agent_models.remove(&agent); - agent_models_changed = true; + config.ai.func_agent_models.remove(&agent); + func_agent_models_changed = true; } - // 2. func_agent_models - let func_keys_to_remove: Vec = config + // 2. future mode and delegated-subagent defaults + if classify_invalid( + config.ai.agent_model_defaults.mode.as_str(), + &mut invalidated, + ) { + warn!( + "Reconcile ({caller}): resetting ai.agent_model_defaults.mode because target model is missing or disabled" + ); + config.ai.agent_model_defaults.mode = "auto".to_string(); + agent_model_defaults_changed = true; + } + + if config .ai - .func_agent_models + .agent_model_defaults + .subagents + .default_selection + .fixed_model_id() + .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) + { + warn!( + "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.default because target model is missing or disabled" + ); + config.ai.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("fast"); + agent_model_defaults_changed = true; + } + + let builtin_keys_to_remove: Vec = config + .ai + .agent_model_defaults + .subagents + .builtin .iter() - .filter_map(|(agent, model_ref)| { - if classify_invalid(model_ref, &mut invalidated) { - Some(agent.clone()) - } else { - None - } + .filter_map(|(subagent_id, selection)| { + selection + .fixed_model_id() + .filter(|model_id| classify_invalid(model_id, &mut invalidated)) + .map(|_| subagent_id.clone()) }) .collect(); - for agent in func_keys_to_remove { + for subagent_id in builtin_keys_to_remove { warn!( - "Reconcile ({caller}): clearing ai.func_agent_models[{agent}] because target model is missing or disabled" + "Reconcile ({caller}): clearing ai.agent_model_defaults.subagents.builtin[{subagent_id}] because target model is missing or disabled" ); - config.ai.func_agent_models.remove(&agent); - agent_models_changed = true; + config + .ai + .agent_model_defaults + .subagents + .builtin + .remove(&subagent_id); + agent_model_defaults_changed = true; + } + + if config + .ai + .agent_model_defaults + .subagents + .fork + .fixed_model_id() + .is_some_and(|model_id| classify_invalid(model_id, &mut invalidated)) + { + warn!( + "Reconcile ({caller}): resetting ai.agent_model_defaults.subagents.fork because target model is missing or disabled" + ); + config.ai.agent_model_defaults.subagents.fork = SubagentModelSelection::Inherit; + agent_model_defaults_changed = true; } // 3. default model slots @@ -503,19 +521,9 @@ impl ConfigService { let image_understanding_needs_fix = match config.ai.default_models.image_understanding.as_deref() { Some("") => true, - Some(value) => { - let canonical = any_ref_to_id - .get(value) - .map(String::as_str) - .unwrap_or(value); - !config.ai.models.iter().any(|model| { - model.enabled - && model.supports_image_understanding() - && (model.id == canonical - || model.name == value - || model.model_name == value) - }) - } + Some(value) => !config.ai.models.iter().any(|model| { + model.enabled && model.supports_image_understanding() && model.id == value + }), None => false, }; if image_understanding_needs_fix { @@ -542,20 +550,21 @@ impl ConfigService { default_models_changed = true; } - // Ensure `invalidated` doesn't contain a still-existing-and-enabled id - // (defensive: classify_invalid only inserts for inactive refs, but a - // callsite could have re-resolved via name). + // Ensure `invalidated` doesn't contain a still-existing-and-enabled ID. invalidated.retain(|id| !enabled_ids.contains(id)); // Persist any changes. We deliberately use the inner manager (and not // `self.set_config`) to avoid triggering a recursive reconcile pass. - if agent_models_changed { + if func_agent_models_changed { let mut manager = self.manager.write().await; manager - .set("ai.agent_models", &config.ai.agent_models) + .set("ai.func_agent_models", &config.ai.func_agent_models) .await?; + } + if agent_model_defaults_changed { + let mut manager = self.manager.write().await; manager - .set("ai.func_agent_models", &config.ai.func_agent_models) + .set("ai.agent_model_defaults", &config.ai.agent_model_defaults) .await?; } if default_models_changed { @@ -565,28 +574,29 @@ impl ConfigService { .await?; } - let _ = known_ids; // currently unused, kept for future diagnostics - let report = ReconcileModelsReport { invalidated_model_ids: invalidated.into_iter().collect(), default_models_changed, - agent_models_changed, + func_agent_models_changed, + agent_model_defaults_changed, }; if report.is_noop() { log::debug!("Reconcile ({caller}): no changes"); } else { info!( - "Reconcile ({caller}): invalidated={:?}, default_changed={}, agent_changed={}", + "Reconcile ({caller}): invalidated={:?}, default_changed={}, func_agent_changed={}, agent_defaults_changed={}", report.invalidated_model_ids, report.default_models_changed, - report.agent_models_changed + report.func_agent_models_changed, + report.agent_model_defaults_changed ); super::global::GlobalConfigManager::broadcast_update( super::global::ConfigUpdateEvent::ModelsReconciled { invalidated_model_ids: report.invalidated_model_ids.clone(), default_models_changed: report.default_models_changed, - agent_models_changed: report.agent_models_changed, + func_agent_models_changed: report.func_agent_models_changed, + agent_model_defaults_changed: report.agent_model_defaults_changed, }, ) .await; @@ -619,14 +629,16 @@ impl bitfun_runtime_ports::ConfigReadPort for ConfigService { pub struct ReconcileModelsReport { pub invalidated_model_ids: Vec, pub default_models_changed: bool, - pub agent_models_changed: bool, + pub func_agent_models_changed: bool, + pub agent_model_defaults_changed: bool, } impl ReconcileModelsReport { pub fn is_noop(&self) -> bool { self.invalidated_model_ids.is_empty() && !self.default_models_changed - && !self.agent_models_changed + && !self.func_agent_models_changed + && !self.agent_model_defaults_changed } } @@ -634,6 +646,7 @@ impl ReconcileModelsReport { mod tests { use super::*; use crate::infrastructure::PathManager; + use std::collections::HashMap; use std::sync::Arc; fn model(id: &str, enabled: bool, category: ModelCategory) -> AIModelConfig { @@ -712,6 +725,55 @@ mod tests { ); } + #[tokio::test] + async fn reconcile_models_resets_invalid_agent_model_defaults() { + let (service, _dir) = test_service("agent-model-defaults-repair").await; + service + .set_config( + "ai.models", + &vec![model("old-model", true, ModelCategory::GeneralChat)], + ) + .await + .expect("initial model should save"); + service + .set_config( + "ai.agent_model_defaults", + &AgentModelDefaultsConfig { + mode: "old-model".to_string(), + subagents: SubagentModelDefaultsConfig { + default_selection: SubagentModelSelection::fixed("old-model"), + builtin: HashMap::from([( + "Explore".to_string(), + SubagentModelSelection::fixed("old-model"), + )]), + fork: SubagentModelSelection::fixed("old-model"), + }, + }, + ) + .await + .expect("agent model defaults should save"); + + service + .set_config( + "ai.models", + &vec![model("new-model", true, ModelCategory::GeneralChat)], + ) + .await + .expect("model replacement should reconcile defaults"); + + let defaults: AgentModelDefaultsConfig = service + .get_config(Some("ai.agent_model_defaults")) + .await + .expect("agent model defaults should load"); + assert_eq!(defaults.mode, "auto"); + assert_eq!( + defaults.subagents.default_selection, + SubagentModelSelection::fixed("fast") + ); + assert!(defaults.subagents.builtin.is_empty()); + assert_eq!(defaults.subagents.fork, SubagentModelSelection::Inherit); + } + #[tokio::test] async fn legacy_theme_id_path_writes_themes_current_only() { let (service, _dir) = test_service("legacy-theme-id-path").await; diff --git a/src/crates/assembly/core/src/service/config/types.rs b/src/crates/assembly/core/src/service/config/types.rs index bbc6fd82a5..508414ee36 100644 --- a/src/crates/assembly/core/src/service/config/types.rs +++ b/src/crates/assembly/core/src/service/config/types.rs @@ -428,6 +428,98 @@ pub struct DefaultModelsConfig { pub speech_recognition: Option, } +/// Model choice for a subagent created in the context of a parent session. +/// +/// `Inherit` is intentionally distinct from a model ID so a user-configured +/// model named `inherit` can never be interpreted as a control value. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum SubagentModelSelection { + Fixed { model_id: String }, + Inherit, +} + +impl SubagentModelSelection { + pub fn fixed(model_id: impl Into) -> Self { + Self::Fixed { + model_id: model_id.into(), + } + } + + pub fn fixed_model_id(&self) -> Option<&str> { + match self { + Self::Fixed { model_id } => Some(model_id.as_str()), + Self::Inherit => None, + } + } +} + +impl Default for SubagentModelSelection { + fn default() -> Self { + Self::Inherit + } +} + +/// Model defaults for subagents created through user-visible delegation. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct SubagentModelDefaultsConfig { + /// Shared fallback for normal subagents without an explicit override. + #[serde(rename = "default", default = "default_subagent_model_selection")] + pub default_selection: SubagentModelSelection, + /// Per-builtin defaults and user overrides. Missing entries use `default`. + pub builtin: HashMap, + /// Default choice for a child created from the parent's context. + pub fork: SubagentModelSelection, +} + +impl Default for SubagentModelDefaultsConfig { + fn default() -> Self { + Self { + default_selection: default_subagent_model_selection(), + builtin: HashMap::from([( + "GeneralPurpose".to_string(), + SubagentModelSelection::fixed("primary"), + )]), + fork: SubagentModelSelection::Inherit, + } + } +} + +fn default_subagent_model_selection() -> SubagentModelSelection { + SubagentModelSelection::fixed("fast") +} + +/// Defaults used when the product creates an agent session without an explicit +/// per-session model choice. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct AgentModelDefaultsConfig { + /// Shared model selector for future mode sessions. + pub mode: String, + /// User-visible delegated subagent model choices. + pub subagents: SubagentModelDefaultsConfig, +} + +impl AgentModelDefaultsConfig { + pub fn builtin_subagent_selection(&self, agent_id: &str) -> SubagentModelSelection { + self.subagents + .builtin + .get(agent_id) + .cloned() + .unwrap_or_else(|| self.subagents.default_selection.clone()) + } +} + +impl Default for AgentModelDefaultsConfig { + fn default() -> Self { + Self { + mode: "auto".to_string(), + subagents: SubagentModelDefaultsConfig::default(), + } + } +} + /// Default review-team execution policy and membership configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default)] @@ -481,10 +573,6 @@ pub struct AIConfig { /// All configured models. pub models: Vec, - /// Model mapping for primary agents (e.g. Explore, FileFinder). - /// agent_type -> model_id - pub agent_models: HashMap, - /// Model mapping for functional agents (e.g. startchat-func-agent, session-title-func-agent). /// func_agent_name -> model_id #[serde(default)] @@ -494,6 +582,10 @@ pub struct AIConfig { #[serde(default)] pub default_models: DefaultModelsConfig, + /// Default selectors for future mode and delegated-subagent sessions. + #[serde(default)] + pub agent_model_defaults: AgentModelDefaultsConfig, + /// Shared agent-profile configuration. /// profile_id -> AgentProfileConfig #[serde(default, deserialize_with = "deserialize_agent_profiles")] @@ -626,29 +718,25 @@ pub struct MemoriesConfig { } impl AIConfig { - /// Resolves a configured model reference by `id`, `name`, or `model_name`. + /// Resolves a canonical configured model ID. /// /// Returns the model id only when the matched model is `enabled`. This is the /// single source of truth for "is this model usable right now?" and is the /// variant every runtime path (client factory, execution engine, etc.) should /// use. UI / migration code that needs to look up disabled entries should call /// [`Self::resolve_model_reference_any`] instead. - pub fn resolve_model_reference(&self, model_ref: &str) -> Option { - self.models - .iter() - .find(|m| { - m.enabled && (m.id == model_ref || m.name == model_ref || m.model_name == model_ref) - }) - .map(|m| m.id.clone()) + pub fn resolve_model_reference(&self, model_id: &str) -> Option { + let mut matches = self.models.iter().filter(|m| m.enabled && m.id == model_id); + let model = matches.next()?; + (matches.next().is_none()).then(|| model.id.clone()) } - /// Resolves a model reference regardless of `enabled` state. UI / migration - /// only — never use this on the runtime model-selection path. - pub fn resolve_model_reference_any(&self, model_ref: &str) -> Option { - self.models - .iter() - .find(|m| m.id == model_ref || m.name == model_ref || m.model_name == model_ref) - .map(|m| m.id.clone()) + /// Resolves a canonical configured model ID regardless of `enabled` state. + /// UI / migration only — never use this on the runtime model-selection path. + pub fn resolve_model_reference_any(&self, model_id: &str) -> Option { + let mut matches = self.models.iter().filter(|m| m.id == model_id); + let model = matches.next()?; + (matches.next().is_none()).then(|| model.id.clone()) } /// Returns true if the given reference points to a model that exists and is @@ -669,9 +757,9 @@ impl AIConfig { /// - `primary`: must resolve to a valid (enabled) primary model /// - `fast`: first tries the configured fast model, then falls back to primary /// - /// Regular values are resolved by `id`, `name`, or `model_name`. All lookups - /// require the target model to be enabled — disabled models are treated as if - /// they did not exist. + /// Regular values must be canonical configured model IDs. All lookups require + /// the target model to be enabled — disabled models are treated as if they did + /// not exist. pub fn resolve_model_selection(&self, model_ref: &str) -> Option { match model_ref { "primary" => self @@ -697,7 +785,7 @@ impl AIConfig { /// Shared agent-profile configuration. /// -/// Model mapping has moved to `AIConfig.agent_models`, keyed by agent id. +/// Tool and skill configuration shared by compatible mode profiles. #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(default)] pub struct AgentProfileConfig { @@ -1507,9 +1595,9 @@ impl Default for AIConfig { fn default() -> Self { Self { models: vec![], - agent_models: std::collections::HashMap::new(), func_agent_models: std::collections::HashMap::new(), default_models: DefaultModelsConfig::default(), + agent_model_defaults: AgentModelDefaultsConfig::default(), agent_profiles: std::collections::HashMap::new(), review_teams: default_review_team_configs(), review_team_rate_limit_status: default_review_team_rate_limit_status(), @@ -1762,9 +1850,10 @@ impl AIModelConfig { #[cfg(test)] mod tests { use super::{ - AIConfig, AIExperienceConfig, AIModelConfig, AgentProfileConfig, AgentProfileView, - AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy, ModelExchangeTracingMode, - ReasoningMode, SubagentBatchExecutionPolicy, + AIConfig, AIExperienceConfig, AIModelConfig, AgentModelDefaultsConfig, AgentProfileConfig, + AgentProfileView, AppLoggingConfig, GlobalConfig, MemoryExternalContextPolicy, + ModelExchangeTracingMode, ReasoningMode, SubagentBatchExecutionPolicy, + SubagentModelSelection, }; #[test] @@ -2097,6 +2186,68 @@ mod tests { assert_eq!(review_team.strategy_level, "normal"); assert!(review_team.member_strategy_overrides.is_empty()); assert_eq!(config.review_team_rate_limit_status, serde_json::json!({})); + assert_eq!(config.agent_model_defaults.mode, "auto"); + assert_eq!( + config.agent_model_defaults.subagents.default_selection, + SubagentModelSelection::fixed("fast") + ); + assert_eq!( + config + .agent_model_defaults + .subagents + .builtin + .get("GeneralPurpose"), + Some(&SubagentModelSelection::fixed("primary")) + ); + assert_eq!( + config.agent_model_defaults.subagents.fork, + SubagentModelSelection::Inherit + ); + } + + #[test] + fn subagent_model_selection_uses_a_tagged_persistent_shape() { + let selection = SubagentModelSelection::fixed("fast"); + assert_eq!( + serde_json::to_value(selection).expect("selection should serialize"), + serde_json::json!({ "kind": "fixed", "model_id": "fast" }) + ); + + let inherited: SubagentModelSelection = serde_json::from_value(serde_json::json!({ + "kind": "inherit" + })) + .expect("inherit selection should deserialize"); + assert_eq!(inherited, SubagentModelSelection::Inherit); + } + + #[test] + fn builtin_subagent_without_override_uses_the_shared_default() { + let mut defaults = AgentModelDefaultsConfig::default(); + defaults.subagents.default_selection = SubagentModelSelection::fixed("primary"); + + assert_eq!( + defaults.builtin_subagent_selection("Explore"), + SubagentModelSelection::fixed("primary") + ); + } + + #[test] + fn general_purpose_uses_primary_unless_explicitly_overridden() { + let mut defaults = AgentModelDefaultsConfig::default(); + + assert_eq!( + defaults.builtin_subagent_selection("GeneralPurpose"), + SubagentModelSelection::fixed("primary") + ); + + defaults.subagents.builtin.insert( + "GeneralPurpose".to_string(), + SubagentModelSelection::fixed("fast"), + ); + assert_eq!( + defaults.builtin_subagent_selection("GeneralPurpose"), + SubagentModelSelection::fixed("fast") + ); } #[test] @@ -2179,7 +2330,6 @@ mod tests { fn deserializes_missing_stream_timeouts_as_generous_defaults() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": {}, @@ -2204,7 +2354,6 @@ mod tests { fn deserializes_explicit_null_stream_ttft_timeout_as_none() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": {}, @@ -2238,7 +2387,6 @@ mod tests { fn deserializes_explicit_subagent_max_concurrency() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": {}, @@ -2257,7 +2405,6 @@ mod tests { fn deserializes_explicit_subagent_batch_execution_policy() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": {}, @@ -2279,7 +2426,6 @@ mod tests { fn deserializes_mode_profiles_with_null_entries() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": { @@ -2311,7 +2457,6 @@ mod tests { fn deserializes_explicit_default_review_team_config() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "func_agent_models": {}, "default_models": {}, "agent_profiles": {}, @@ -2368,7 +2513,6 @@ mod tests { fn review_team_auxiliary_config_is_not_stored_inside_review_team_map() { let config: AIConfig = serde_json::from_value(serde_json::json!({ "models": [], - "agent_models": {}, "review_teams": { "default": { "strategy_level": "normal" diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index b1fe869811..8903cad48d 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -702,9 +702,9 @@ fn build_model_breakdown( let token_model_ids_by_turn = build_token_model_ids_by_turn(token_records); for record in token_records { let row = by_model - .entry(record.model_id.clone()) + .entry(record.effective_model_name.clone()) .or_insert_with(|| UsageModelBreakdown { - model_id: record.model_id.clone(), + model_id: record.effective_model_name.clone(), call_count: 0, input_tokens: Some(0), output_tokens: Some(0), @@ -776,7 +776,7 @@ fn build_model_breakdown( let mut records_by_model: HashMap<&str, Vec<&TokenUsageRecord>> = HashMap::new(); for record in token_records { records_by_model - .entry(record.model_id.as_str()) + .entry(record.effective_model_name.as_str()) .or_default() .push(record); } @@ -799,7 +799,7 @@ fn build_token_model_ids_by_turn( by_turn .entry(record.turn_id.clone()) .or_default() - .insert(record.model_id.clone()); + .insert(record.effective_model_name.clone()); } by_turn } @@ -1289,9 +1289,8 @@ fn model_round_duration_ms(round: &ModelRoundData) -> Option { fn model_round_label(round: &ModelRoundData) -> String { round - .model_id + .effective_model_name .as_deref() - .or(round.model_alias.as_deref()) .map(|value| redact_usage_label(value, 80).value) .unwrap_or_else(|| "unknown_model".to_string()) } @@ -1961,8 +1960,8 @@ mod tests { fn report_merges_legacy_model_timing_into_token_model_row_for_same_turn() { let request = test_request(None); let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); - turn.model_rounds[0].model_id = None; - turn.model_rounds[0].model_alias = None; + turn.model_rounds[0].model_config_id = None; + turn.model_rounds[0].effective_model_name = None; turn.model_rounds[0].duration_ms = Some(180); let token_record = test_token_record("gpt-5.4", 120, 30, 0); @@ -1997,8 +1996,8 @@ mod tests { fn report_uses_clear_label_when_model_identity_is_missing() { let request = test_request(None); let mut turn = test_turn("turn-1", 0, DialogTurnKind::UserDialog); - turn.model_rounds[0].model_id = None; - turn.model_rounds[0].model_alias = None; + turn.model_rounds[0].model_config_id = None; + turn.model_rounds[0].effective_model_name = None; turn.model_rounds[0].duration_ms = Some(180); let report = @@ -2088,8 +2087,8 @@ mod tests { "D:/workspace/bitfun/src/main.rs", )], ); - failed_turn.model_rounds[0].model_id = Some("model-a".to_string()); - failed_turn.model_rounds[0].model_alias = Some("model-a".to_string()); + failed_turn.model_rounds[0].model_config_id = Some("config-model-a".to_string()); + failed_turn.model_rounds[0].effective_model_name = Some("model-a".to_string()); failed_turn.model_rounds[0].duration_ms = Some(220); let mut model_error_turn = test_turn_with_tools("turn-4", 4, DialogTurnKind::UserDialog, vec![]); @@ -2695,8 +2694,8 @@ mod tests { end_time: Some(1_200 + turn_index as u64), duration_ms: Some(200), provider_id: None, - model_id: Some("model-a".to_string()), - model_alias: Some("model-a".to_string()), + model_config_id: Some("model-config-a".to_string()), + effective_model_name: Some("model-a".to_string()), first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, @@ -2735,8 +2734,8 @@ mod tests { end_time: Some(1_000 + round_index as u64 + duration_ms), duration_ms: Some(duration_ms), provider_id: Some("test-provider".to_string()), - model_id: Some(model_id.to_string()), - model_alias: Some(model_id.to_string()), + model_config_id: Some(format!("config-{}", model_id)), + effective_model_name: Some(model_id.to_string()), first_chunk_ms: Some(5), first_visible_output_ms: Some(8), stream_duration_ms: Some(duration_ms.saturating_sub(10)), @@ -2822,7 +2821,8 @@ mod tests { cached_tokens: u32, ) -> TokenUsageRecord { TokenUsageRecord { - model_id: model_id.to_string(), + model_config_id: format!("config-{}", model_id), + effective_model_name: model_id.to_string(), session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), timestamp: Utc.timestamp_millis_opt(1_778_347_200_000).unwrap(), diff --git a/src/crates/assembly/core/src/service/token_usage/service.rs b/src/crates/assembly/core/src/service/token_usage/service.rs index 0153e41177..9cf267d4cb 100644 --- a/src/crates/assembly/core/src/service/token_usage/service.rs +++ b/src/crates/assembly/core/src/service/token_usage/service.rs @@ -35,7 +35,8 @@ impl TokenUsageService { #[allow(clippy::too_many_arguments)] pub async fn record_usage( &self, - model_id: String, + model_config_id: String, + effective_model_name: String, session_id: String, turn_id: String, input_tokens: u32, @@ -46,7 +47,8 @@ impl TokenUsageService { ) -> Result<()> { self.inner .record_usage( - model_id, + model_config_id, + effective_model_name, session_id, turn_id, input_tokens, diff --git a/src/crates/assembly/core/src/service/token_usage/subscriber.rs b/src/crates/assembly/core/src/service/token_usage/subscriber.rs index 9b363e49e7..73c290173e 100644 --- a/src/crates/assembly/core/src/service/token_usage/subscriber.rs +++ b/src/crates/assembly/core/src/service/token_usage/subscriber.rs @@ -27,7 +27,8 @@ impl EventSubscriber for TokenUsageSubscriber { if let AgenticEvent::TokenUsageUpdated { session_id, turn_id, - model_id, + model_config_id, + effective_model_name, input_tokens, output_tokens, total_tokens, @@ -40,8 +41,9 @@ impl EventSubscriber for TokenUsageSubscriber { let output = output_tokens.unwrap_or(0); debug!( - "Recording token usage: model={}, session={}, turn={}, input={}, output={}, total={}, cached_available={}, is_subagent={}", - model_id, + "Recording token usage: model_config_id={}, effective_model_name={}, session={}, turn={}, input={}, output={}, total={}, cached_available={}, is_subagent={}", + model_config_id, + effective_model_name, session_id, turn_id, input_tokens, @@ -54,7 +56,8 @@ impl EventSubscriber for TokenUsageSubscriber { if let Err(e) = self .token_usage_service .record_usage( - model_id.clone(), + model_config_id.clone(), + effective_model_name.clone(), session_id.clone(), turn_id.clone(), *input_tokens as u32, diff --git a/src/crates/assembly/core/src/service_agent_runtime.rs b/src/crates/assembly/core/src/service_agent_runtime.rs index c23aa58274..9c6660c8c4 100644 --- a/src/crates/assembly/core/src/service_agent_runtime.rs +++ b/src/crates/assembly/core/src/service_agent_runtime.rs @@ -45,6 +45,7 @@ use crate::agentic::coordination::{ get_global_coordinator, get_global_scheduler, ConversationCoordinator, DialogQueuePriority, DialogScheduler, DialogSubmissionPolicy, DialogSubmitOutcome, DialogTriggerSource, }; +use crate::agentic::core::{Session, SessionKind}; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::session::session_store_port::CoreSessionStorePort; use crate::agentic::workspace::WorkspaceBinding; @@ -211,6 +212,10 @@ fn normalize_remote_model_selection( }) } +fn session_uses_shared_mode_default(session: &Session) -> bool { + session.kind == SessionKind::Standard +} + fn remote_model_capability_fact(capability: ModelCapability) -> RemoteModelCapabilityFact { match capability { ModelCapability::TextChat => RemoteModelCapabilityFact::TextChat, @@ -746,45 +751,27 @@ impl CoreServiceAgentRuntime { .await .map_err(|e| e.to_string())?; - // Propagate the model choice to every agent type already present in - // `ai.agent_models` so that newly created sessions of any type - // (including different agent types like Cowork/Claw) inherit it. - // Also ensure the current session's agent type is present. This - // covers mobile-web and IM-bot paths; the desktop client handles - // its own `ai.agent_models` writing in the frontend. - Self::persist_model_for_all_agents(&normalized_model_id, || { - coordinator - .get_session_manager() - .get_session(session_id) - .map(|s| s.agent_type.clone()) - }) - .await; + if coordinator + .get_session_manager() + .get_session(session_id) + .is_some_and(|session| session_uses_shared_mode_default(&session)) + { + // New sessions of every mode share one selector. Delegated + // subagents intentionally keep their own defaults. + Self::persist_mode_model(&normalized_model_id).await; + } Ok(normalized_model_id) } - /// Write `model_id` to `ai.agent_models` for **every** agent type already - /// present in the config, plus the current session's agent type if it is - /// not yet listed. This ensures newly created sessions of any type pick - /// up the same model without hardcoding a fixed list of agent types. - async fn persist_model_for_all_agents(model_id: &str, current_agent_type: F) - where - F: FnOnce() -> Option, - { + /// Persist the shared selector used by future mode sessions. + async fn persist_mode_model(model_id: &str) { let Ok(config_service) = crate::service::config::get_global_config_service().await else { return; }; - let mut current: std::collections::HashMap = config_service - .get_config(Some("ai.agent_models")) - .await - .unwrap_or_default(); - for value in current.values_mut() { - *value = model_id.to_string(); - } - if let Some(agent_type) = current_agent_type() { - current.insert(agent_type, model_id.to_string()); - } - let _ = config_service.set_config("ai.agent_models", ¤t).await; + let _ = config_service + .set_config("ai.agent_model_defaults.mode", model_id) + .await; } pub(crate) fn remote_control_state_port( @@ -1817,6 +1804,24 @@ mod tests { ); } + #[test] + fn core_service_agent_runtime_only_shares_model_defaults_for_standard_sessions() { + let mut session = Session::new_with_id( + "session-model-scope".to_string(), + "Model scope".to_string(), + "agentic".to_string(), + Default::default(), + ); + + assert!(session_uses_shared_mode_default(&session)); + + session.kind = SessionKind::Subagent; + assert!(!session_uses_shared_mode_default(&session)); + + session.kind = SessionKind::EphemeralChild; + assert!(!session_uses_shared_mode_default(&session)); + } + #[test] fn core_service_agent_runtime_owner_preserves_remote_chat_history_shape() { let turn = remote_history_test_turn( @@ -1976,8 +1981,8 @@ mod tests { end_time: Some(1_200), duration_ms: Some(100), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 788d411199..871a39e5df 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -132,6 +132,9 @@ pub enum AgenticEvent { parent_tool_call_id: String, #[serde(skip_serializing_if = "Option::is_none")] agent_type: Option, + /// Resolved model selector stored on the child session. + #[serde(skip_serializing_if = "Option::is_none")] + model_id: Option, }, DialogTurnCompleted { @@ -173,7 +176,10 @@ pub enum AgenticEvent { TokenUsageUpdated { session_id: String, turn_id: String, - model_id: String, + /// Resolved `AIModelConfig.id` used for this request. + model_config_id: String, + /// Provider model name sent on the request. + effective_model_name: String, input_tokens: usize, output_tokens: Option, total_tokens: usize, @@ -228,8 +234,10 @@ pub enum AgenticEvent { #[serde(default, skip_serializing_if = "Option::is_none")] round_group_id: Option, round_index: usize, - #[serde(default, skip_serializing_if = "Option::is_none")] - model_id: Option, + /// Resolved `AIModelConfig.id` used for this round. + model_config_id: String, + /// Provider model name sent on the request. + effective_model_name: String, }, ModelRoundCompleted { @@ -241,10 +249,10 @@ pub enum AgenticEvent { duration_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] provider_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - model_alias: Option, + /// Resolved `AIModelConfig.id` used for this round. + model_config_id: String, + /// Provider model name sent on the request. + effective_model_name: String, #[serde(default, skip_serializing_if = "Option::is_none")] first_chunk_ms: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -663,8 +671,8 @@ mod tests { has_tool_calls: false, duration_ms: Some(123), provider_id: Some("provider".to_string()), - model_id: Some("model".to_string()), - model_alias: Some("alias".to_string()), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), first_chunk_ms: Some(10), first_visible_output_ms: Some(12), stream_duration_ms: Some(100), @@ -676,21 +684,25 @@ mod tests { let json = serde_json::to_value(&event).expect("serialize event"); assert_eq!(json["duration_ms"], 123); + assert_eq!(json["model_config_id"], "model-config"); + assert_eq!(json["effective_model_name"], "provider-model"); assert_eq!(json["first_chunk_ms"], 10); assert_eq!(json["token_details"]["reasoningTokens"], 7); } #[test] - fn model_round_completed_deserializes_legacy_payload_without_timing_fields() { + fn model_round_completed_deserializes_required_identity_without_timing_fields() { let json = serde_json::json!({ "type": "ModelRoundCompleted", "session_id": "session-1", "turn_id": "turn-1", "round_id": "round-1", - "has_tool_calls": false + "has_tool_calls": false, + "model_config_id": "model-config", + "effective_model_name": "provider-model" }); - let event: AgenticEvent = serde_json::from_value(json).expect("legacy event"); + let event: AgenticEvent = serde_json::from_value(json).expect("event"); match event { AgenticEvent::ModelRoundCompleted { duration_ms, .. } => { @@ -705,7 +717,8 @@ mod tests { let event = AgenticEvent::TokenUsageUpdated { session_id: "session-1".to_string(), turn_id: "turn-1".to_string(), - model_id: "model".to_string(), + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), input_tokens: 10, output_tokens: Some(5), total_tokens: 15, @@ -851,6 +864,7 @@ mod tests { parent_dialog_turn_id: "turn-1".to_string(), parent_tool_call_id: "tool-1".to_string(), agent_type: Some("GeneralPurpose".to_string()), + model_id: Some("fast".to_string()), }; assert_eq!(event.session_id(), Some("child-session")); @@ -864,5 +878,6 @@ mod tests { assert_eq!(serialized["parent_dialog_turn_id"], "turn-1"); assert_eq!(serialized["parent_tool_call_id"], "tool-1"); assert_eq!(serialized["agent_type"], "GeneralPurpose"); + assert_eq!(serialized["model_id"], "fast"); } } diff --git a/src/crates/contracts/events/src/frontend_projection.rs b/src/crates/contracts/events/src/frontend_projection.rs index 9ce8ae09ca..09d04c6813 100644 --- a/src/crates/contracts/events/src/frontend_projection.rs +++ b/src/crates/contracts/events/src/frontend_projection.rs @@ -97,6 +97,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Some(AgenticFrontendEvent::new( "agentic://subagent-session-linked", json!({ @@ -106,6 +107,7 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Option Some(AgenticFrontendEvent::new( "agentic://model-round-started", json!({ @@ -123,7 +126,8 @@ pub fn project_agentic_frontend_event(event: AgenticEvent) -> Option Option Option Option Option bool { - custom_agent_model_should_save(self.kind, &self.model) + match self.kind { + CustomAgentKind::Mode => custom_agent_model_should_save(self.kind, &self.model), + CustomAgentKind::Subagent => self.model_is_explicit, + } } pub fn should_save_user_context_policy(&self) -> bool { @@ -785,6 +796,7 @@ mod tests { review: false, level: CustomAgentLevel::User, model: "auto".to_string(), + model_is_explicit: false, user_context_policy: UserContextPolicy::empty() .with_workspace_context() .with_workspace_instructions() diff --git a/src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs b/src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs index eee729b5ae..840f9df49e 100644 --- a/src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/custom_subagent_contracts.rs @@ -129,6 +129,53 @@ fn custom_subagent_definition_from_front_matter_preserves_schema_and_defaults() assert!(definition.should_save_model()); } +#[test] +fn custom_subagent_model_presence_distinguishes_default_from_fast_override() { + let implicit = build_definition(BuildDefinitionInput( + Some("ImplicitModel"), + Some("Implicit model"), + Some("Uses the shared Subagent default"), + None, + None, + None, + None, + CustomSubagentKind::User, + )) + .expect("definition without a model should parse"); + assert_eq!(implicit.model, "fast"); + assert!(!implicit.model_is_explicit); + assert!(!implicit.should_save_model()); + + let explicit_fast = build_definition(BuildDefinitionInput( + Some("ExplicitFast"), + Some("Explicit fast"), + Some("Keeps a fast override"), + None, + None, + None, + Some("fast"), + CustomSubagentKind::User, + )) + .expect("definition with fast should parse"); + assert!(explicit_fast.model_is_explicit); + assert!(explicit_fast.should_save_model()); + + let dir = TestTempDir::new("bitfun-agent-runtime-explicit-model"); + let implicit_path = dir.join("implicit.md"); + let explicit_path = dir.join("explicit.md"); + custom_subagent_save_markdown_file(&implicit_path, &implicit) + .expect("implicit definition should save"); + custom_subagent_save_markdown_file(&explicit_path, &explicit_fast) + .expect("explicit definition should save"); + + let implicit_markdown = + fs::read_to_string(&implicit_path).expect("implicit markdown should read"); + let explicit_markdown = + fs::read_to_string(&explicit_path).expect("explicit markdown should read"); + assert!(!implicit_markdown.contains("model:")); + assert!(explicit_markdown.contains("model: fast")); +} + #[test] fn custom_subagent_definition_reports_legacy_missing_field_errors() { let missing_name = build_definition(BuildDefinitionInput( diff --git a/src/crates/interfaces/acp/src/runtime/model.rs b/src/crates/interfaces/acp/src/runtime/model.rs index 9dd5cc5385..990d2f16db 100644 --- a/src/crates/interfaces/acp/src/runtime/model.rs +++ b/src/crates/interfaces/acp/src/runtime/model.rs @@ -248,18 +248,14 @@ mod tests { } #[test] - fn current_model_resolves_name_to_model_id() { + fn current_model_accepts_enabled_canonical_model_id() { let mut ai_config = AIConfig::default(); ai_config.models.push(AIModelConfig { id: "model-a".to_string(), - name: "Readable Model".to_string(), enabled: true, ..Default::default() }); - assert_eq!( - current_model_id(&ai_config, Some("Readable Model")), - "model-a" - ); + assert_eq!(current_model_id(&ai_config, Some("model-a")), "model-a"); } } diff --git a/src/crates/interfaces/acp/src/runtime/replay.rs b/src/crates/interfaces/acp/src/runtime/replay.rs index a2488c2f6f..d2dc7f6e67 100644 --- a/src/crates/interfaces/acp/src/runtime/replay.rs +++ b/src/crates/interfaces/acp/src/runtime/replay.rs @@ -339,8 +339,8 @@ mod tests { end_time: None, duration_ms: None, provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index cc6278e4e6..36bdd0601e 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -375,8 +375,8 @@ mod tests { end_time: Some(2), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 4289b3f7b9..8d28f4c7f0 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -517,14 +517,10 @@ pub struct ModelRoundData { alias = "provider_id" )] pub provider_id: Option, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "model_id")] - pub model_id: Option, - #[serde( - default, - skip_serializing_if = "Option::is_none", - alias = "model_alias" - )] - pub model_alias: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_config_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_model_name: Option, #[serde( default, skip_serializing_if = "Option::is_none", @@ -1287,7 +1283,7 @@ mod tests { let legacy_round: ModelRoundData = serde_json::from_value(legacy_round_payload).expect("legacy round should deserialize"); assert_eq!(legacy_round.duration_ms, None); - assert_eq!(legacy_round.model_id, None); + assert_eq!(legacy_round.model_config_id, None); assert_eq!(legacy_round.first_chunk_ms, None); let round_payload = serde_json::json!({ @@ -1302,8 +1298,8 @@ mod tests { "endTime": 121, "durationMs": 120, "providerId": "provider-a", - "modelId": "model-a", - "modelAlias": "Model A", + "modelConfigId": "model-config-a", + "effectiveModelName": "model-a", "firstChunkMs": 10, "firstVisibleOutputMs": 12, "streamDurationMs": 90, @@ -1317,14 +1313,16 @@ mod tests { serde_json::from_value(round_payload).expect("P1 round should deserialize"); assert_eq!(round.duration_ms, Some(120)); assert_eq!(round.provider_id.as_deref(), Some("provider-a")); - assert_eq!(round.model_id.as_deref(), Some("model-a")); + assert_eq!(round.model_config_id.as_deref(), Some("model-config-a")); + assert_eq!(round.effective_model_name.as_deref(), Some("model-a")); assert_eq!(round.first_visible_output_ms, Some(12)); assert_eq!(round.attempt_count, Some(2)); assert_eq!(round.failure_category.as_deref(), Some("rate_limit")); let encoded = serde_json::to_value(&round).expect("round should serialize"); assert_eq!(encoded["durationMs"], 120); - assert_eq!(encoded["modelId"], "model-a"); + assert_eq!(encoded["modelConfigId"], "model-config-a"); + assert_eq!(encoded["effectiveModelName"], "model-a"); assert_eq!(encoded["firstChunkMs"], 10); let tool_payload = serde_json::json!({ diff --git a/src/crates/services/services-core/src/token_usage/service.rs b/src/crates/services/services-core/src/token_usage/service.rs index 83fa401a0a..9a90e9f91e 100644 --- a/src/crates/services/services-core/src/token_usage/service.rs +++ b/src/crates/services/services-core/src/token_usage/service.rs @@ -133,7 +133,8 @@ impl TokenUsageService { #[allow(clippy::too_many_arguments)] pub async fn record_usage( &self, - model_id: String, + model_config_id: String, + effective_model_name: String, session_id: String, turn_id: String, input_tokens: u32, @@ -155,7 +156,8 @@ impl TokenUsageService { .unwrap_or(0); let record = TokenUsageRecord { - model_id: model_id.clone(), + model_config_id: model_config_id.clone(), + effective_model_name: effective_model_name.clone(), session_id: session_id.clone(), turn_id, timestamp: now, @@ -174,8 +176,14 @@ impl TokenUsageService { self.persist_record(&record).await?; debug!( - "Recorded token usage: model={}, session={}, input={}, output={}, total={}, is_subagent={}", - model_id, session_id, input_tokens, output_tokens, total_tokens, is_subagent + "Recorded token usage: model_config_id={}, effective_model_name={}, session={}, input={}, output={}, total={}, is_subagent={}", + model_config_id, + effective_model_name, + session_id, + input_tokens, + output_tokens, + total_tokens, + is_subagent ); Ok(()) @@ -185,9 +193,9 @@ impl TokenUsageService { let mut model_stats = self.model_stats.write().await; let stats = model_stats - .entry(record.model_id.clone()) + .entry(record.effective_model_name.clone()) .or_insert_with(|| ModelTokenStats { - model_id: record.model_id.clone(), + model_id: record.effective_model_name.clone(), ..Default::default() }); @@ -221,7 +229,7 @@ impl TokenUsageService { .entry(record.session_id.clone()) .or_insert_with(|| SessionTokenStats { session_id: record.session_id.clone(), - model_id: record.model_id.clone(), + model_id: record.effective_model_name.clone(), total_input: 0, total_output: 0, total_cached: 0, @@ -401,7 +409,7 @@ impl TokenUsageService { if query .model_id .as_ref() - .is_some_and(|model_id| &record.model_id != model_id) + .is_some_and(|model_id| &record.effective_model_name != model_id) { continue; } @@ -535,13 +543,12 @@ impl TokenUsageService { } total_tokens += record.total_tokens as u64; - let model_stats = - by_model - .entry(record.model_id.clone()) - .or_insert_with(|| ModelTokenStats { - model_id: record.model_id.clone(), - ..Default::default() - }); + let model_stats = by_model + .entry(record.effective_model_name.clone()) + .or_insert_with(|| ModelTokenStats { + model_id: record.effective_model_name.clone(), + ..Default::default() + }); model_stats.total_input += record.input_tokens as u64; model_stats.total_output += record.output_tokens as u64; @@ -565,7 +572,7 @@ impl TokenUsageService { .entry(record.session_id.clone()) .or_insert_with(|| SessionTokenStats { session_id: record.session_id.clone(), - model_id: record.model_id.clone(), + model_id: record.effective_model_name.clone(), total_input: 0, total_output: 0, total_cached: 0, @@ -668,6 +675,7 @@ mod tests { .expect("token usage service"); service .record_usage( + "model-config-a".to_string(), "model-a".to_string(), "parent-session".to_string(), "parent-turn".to_string(), @@ -681,6 +689,7 @@ mod tests { .expect("parent record"); service .record_usage( + "model-config-b".to_string(), "model-b".to_string(), "unrelated-session".to_string(), "unrelated-turn".to_string(), diff --git a/src/crates/services/services-core/src/token_usage/types.rs b/src/crates/services/services-core/src/token_usage/types.rs index c52e2a5fdc..8a7ea43076 100644 --- a/src/crates/services/services-core/src/token_usage/types.rs +++ b/src/crates/services/services-core/src/token_usage/types.rs @@ -7,7 +7,10 @@ use std::collections::{HashMap, HashSet}; /// Single token usage record for a specific API call #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TokenUsageRecord { - pub model_id: String, + /// Resolved `AIModelConfig.id` used for the request. + pub model_config_id: String, + /// Provider model name sent on the request. + pub effective_model_name: String, pub session_id: String, pub turn_id: String, pub timestamp: DateTime, diff --git a/src/crates/services/services-core/tests/session_metadata_contracts.rs b/src/crates/services/services-core/tests/session_metadata_contracts.rs index fda19cc932..1c5480eb9b 100644 --- a/src/crates/services/services-core/tests/session_metadata_contracts.rs +++ b/src/crates/services/services-core/tests/session_metadata_contracts.rs @@ -89,8 +89,8 @@ fn round(turn_id: &str, text_count: usize, tool_count: usize) -> ModelRoundData end_time: Some(1), duration_ms: Some(1), provider_id: None, - model_id: None, - model_alias: None, + model_config_id: None, + effective_model_name: None, first_chunk_ms: None, first_visible_output_ms: None, stream_duration_ms: None, diff --git a/src/crates/services/services-core/tests/storage_owner_contracts.rs b/src/crates/services/services-core/tests/storage_owner_contracts.rs index 619f11d0bc..3fdf2695ad 100644 --- a/src/crates/services/services-core/tests/storage_owner_contracts.rs +++ b/src/crates/services/services-core/tests/storage_owner_contracts.rs @@ -199,6 +199,7 @@ async fn token_usage_service_persists_records_and_filters_subagents_by_default() service .record_usage( + "model-config-a".to_string(), "model-a".to_string(), "session-a".to_string(), "turn-a".to_string(), @@ -212,6 +213,7 @@ async fn token_usage_service_persists_records_and_filters_subagents_by_default() .expect("record main"); service .record_usage( + "model-config-a".to_string(), "model-a".to_string(), "session-a".to_string(), "turn-sub".to_string(), @@ -263,6 +265,7 @@ async fn token_usage_clear_does_not_replay_cached_record_batches() { service .record_usage( + "model-config-old".to_string(), "model-old".to_string(), "session-old".to_string(), "turn-old".to_string(), @@ -277,6 +280,7 @@ async fn token_usage_clear_does_not_replay_cached_record_batches() { service.clear_all_stats().await.expect("clear usage"); service .record_usage( + "model-config-new".to_string(), "model-new".to_string(), "session-new".to_string(), "turn-new".to_string(), @@ -324,6 +328,7 @@ async fn token_usage_all_range_ignores_non_date_record_files() { service .record_usage( + "model-config-a".to_string(), "model-a".to_string(), "session-a".to_string(), "turn-a".to_string(), diff --git a/src/crates/services/services-core/tests/token_usage_contracts.rs b/src/crates/services/services-core/tests/token_usage_contracts.rs index e73219df4c..bc7cfae851 100644 --- a/src/crates/services/services-core/tests/token_usage_contracts.rs +++ b/src/crates/services/services-core/tests/token_usage_contracts.rs @@ -4,9 +4,10 @@ use bitfun_services_core::token_usage::{ use chrono::Utc; #[test] -fn token_usage_record_preserves_cached_availability_default() { +fn token_usage_record_preserves_model_identity_and_cached_availability_default() { let record: TokenUsageRecord = serde_json::from_value(serde_json::json!({ - "model_id": "model-a", + "model_config_id": "model-config-a", + "effective_model_name": "model-a", "session_id": "session-1", "turn_id": "turn-1", "timestamp": Utc::now(), @@ -15,8 +16,10 @@ fn token_usage_record_preserves_cached_availability_default() { "cached_tokens": 0, "total_tokens": 15 })) - .expect("legacy token usage record should deserialize"); + .expect("token usage record should deserialize"); + assert_eq!(record.model_config_id, "model-config-a"); + assert_eq!(record.effective_model_name, "model-a"); assert!(!record.cached_tokens_available); } diff --git a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs index 9644b0200f..1f92d90a6b 100644 --- a/src/crates/services/services-integrations/tests/remote_connect_contracts.rs +++ b/src/crates/services/services-integrations/tests/remote_connect_contracts.rs @@ -2206,7 +2206,8 @@ fn remote_connect_tracker_preserves_streaming_snapshot_contract() { round_id: "round-1".to_string(), round_group_id: None, round_index: 3, - model_id: None, + model_config_id: "model-config".to_string(), + effective_model_name: "provider-model".to_string(), }); tracker.handle_agentic_event(&AgenticEvent::ThinkingChunk { session_id: "session-1".to_string(), diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.scss b/src/web-ui/src/app/scenes/agents/AgentsScene.scss index 1679004f14..edf1e6ec9f 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.scss +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.scss @@ -38,6 +38,31 @@ color: var(--color-text-muted); white-space: nowrap; } + + &__subagent-model-select { + width: min(280px, 100%); + max-width: 100%; + + &.select--open .select__dropdown { + position: static; + max-height: min(240px, 40vh); + border: 1px solid var(--border-medium); + border-top: none; + border-radius: 0 0 var(--size-radius-sm) var(--size-radius-sm); + box-shadow: 0 4px 12px var(--color-overlay-black-40); + + &::before { + top: 0; + bottom: auto; + } + } + + &.select--open.select--placement-top .select__trigger { + border-top-color: var(--border-medium); + border-bottom-color: transparent; + border-radius: var(--size-radius-sm) var(--size-radius-sm) 0 0; + } + } } @media (max-width: 1080px) { diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index ca03961846..49192fdc09 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -12,7 +12,7 @@ import { Wrench, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, IconButton, Search, Switch, confirmDanger } from '@/component-library'; +import { Badge, Button, IconButton, Search, Select, Switch, confirmDanger } from '@/component-library'; import { GalleryDetailModal, GalleryEmpty, @@ -41,11 +41,16 @@ import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefre import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; import { CustomAgentAPI } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { ModeSkillInfo } from '@/infrastructure/config/types'; +import type { ModeSkillInfo, SubagentModelSelection } from '@/infrastructure/config/types'; import type { SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import { useNotification } from '@/shared/notification-system'; +import { + type ModelSelectOption, + useModelSelectPresentation, +} from '@/infrastructure/config/components/ModelSelectPresentation'; const UNGROUPED_SKILL_GROUP = '__ungrouped__'; +const DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE = '__default_subagent_model__'; const SKILL_GROUP_ORDER: Record = { office: 0, @@ -64,7 +69,27 @@ interface SkillGroup { totalCount: number; } -type CapabilityTab = 'tools' | 'skills' | 'subagents'; +type CapabilityTab = 'model' | 'tools' | 'skills' | 'subagents'; + +function normalizeSelectValue(value: string | number | (string | number)[]): string { + return String(Array.isArray(value) ? (value[0] ?? '') : value); +} + +function subagentModelOverrideValue(selection: SubagentModelSelection | undefined): string { + if (!selection) { + return DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE; + } + return selection.kind === 'inherit' ? 'inherit' : selection.model_id; +} + +function subagentModelSelectionFromValue(value: string): SubagentModelSelection | undefined { + if (value === DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE) { + return undefined; + } + return value === 'inherit' + ? { kind: 'inherit' } + : { kind: 'fixed', model_id: value }; +} function getConfiguredEnabledSkillKeys(skills: ModeSkillInfo[]): string[] { return skills.filter((skill) => skill.effectiveEnabled).map((skill) => skill.key); @@ -190,7 +215,7 @@ const AgentsHomeView: React.FC = () => { openEditAgent, } = useAgentsStore(); const [selectedAgentId, setSelectedAgentId] = React.useState(null); - const [activeCapabilityTab, setActiveCapabilityTab] = React.useState('tools'); + const [activeCapabilityTab, setActiveCapabilityTab] = React.useState(null); const [toolsEditing, setToolsEditing] = React.useState(false); const [skillsEditing, setSkillsEditing] = React.useState(false); const [subagentsEditing, setSubagentsEditing] = React.useState(false); @@ -200,13 +225,16 @@ const AgentsHomeView: React.FC = () => { const [savingTools, setSavingTools] = React.useState(false); const [savingSkills, setSavingSkills] = React.useState(false); const [savingSubagents, setSavingSubagents] = React.useState(false); + const [savingSubagentModel, setSavingSubagentModel] = React.useState(false); const [computerUseEnabled, setComputerUseEnabled] = useState(true); + const { buildModelOption, renderModelOption, renderModelValue } = useModelSelectPresentation(); const { allAgents, filteredAgents, loading, availableTools, + configuredModels = [], getModeProfile, getModeSkills, getModeManageableSubagents, @@ -219,6 +247,7 @@ const AgentsHomeView: React.FC = () => { handleSetSkills, handleResetSkills, handleSetSubagentEnabled, + handleSetSubagentModel, } = useAgentsList({ searchQuery, filterLevel: agentFilterLevel, @@ -384,14 +413,60 @@ const AgentsHomeView: React.FC = () => { return agent.toolCount ?? 0; }, [getModeConfig]); const selectedAgentToolCount = selectedAgent ? getDisplayedToolCount(selectedAgent) : 0; + const selectedSubagentModelValue = selectedAgent?.agentKind === 'subagent' + ? subagentModelOverrideValue(selectedAgent.subagentModelOverride) + : DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE; + const subagentModelOptions = useMemo(() => [ + { + label: t('agentCard.modelSelector.default'), + value: DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE, + }, + { label: t('agentCard.modelSelector.inherit'), value: 'inherit' }, + { label: t('agentCard.modelSelector.fast'), value: 'fast' }, + { label: t('agentCard.modelSelector.primary'), value: 'primary' }, + { label: t('agentCard.modelSelector.auto'), value: 'auto' }, + ...configuredModels + .filter((model): model is typeof model & { id: string } => ( + typeof model.id === 'string' + && model.id.trim().length > 0 + && model.enabled !== false + && (model.capabilities ?? []).includes('text_chat') + )) + .map(buildModelOption), + ], [buildModelOption, configuredModels, t]); + const handleSubagentModelChange = useCallback(async ( + value: string | number | (string | number)[], + ) => { + if (!selectedAgent || selectedAgent.agentKind !== 'subagent' || savingSubagentModel) { + return; + } + + setSavingSubagentModel(true); + try { + await handleSetSubagentModel( + selectedAgent.id, + subagentModelSelectionFromValue(normalizeSelectValue(value)), + ); + } finally { + setSavingSubagentModel(false); + } + }, [handleSetSubagentModel, savingSubagentModel, selectedAgent]); const selectedAgentCapabilityTabs = useMemo(() => { const tabs: Array<{ key: CapabilityTab; icon: typeof Wrench; label: string; - count: string; + count?: string; }> = []; + if (selectedAgent?.agentKind === 'subagent') { + tabs.push({ + key: 'model', + icon: Cpu, + label: t('agentCard.modelSelector.label'), + }); + } + if (selectedAgentTools.length > 0) { const currentToolCount = selectedAgent?.agentKind === 'mode' ? (toolsEditing @@ -463,7 +538,9 @@ const AgentsHomeView: React.FC = () => { ? toolsEditing : currentCapabilityTab === 'skills' ? skillsEditing - : subagentsEditing; + : currentCapabilityTab === 'subagents' + ? subagentsEditing + : false; const resetEditState = useCallback(() => { setToolsEditing(false); setSkillsEditing(false); @@ -506,19 +583,19 @@ const AgentsHomeView: React.FC = () => { const openAgentDetails = useCallback((agent: AgentWithCapabilities) => { setSelectedAgentId(agent.id); - setActiveCapabilityTab('tools'); + setActiveCapabilityTab(null); resetEditState(); }, [resetEditState]); const closeAgentDetails = useCallback(() => { setSelectedAgentId(null); - setActiveCapabilityTab('tools'); + setActiveCapabilityTab(null); resetEditState(); }, [resetEditState]); useEffect(() => { if (!selectedAgentCapabilityTabs.some((tab) => tab.key === activeCapabilityTab)) { - setActiveCapabilityTab(selectedAgentCapabilityTabs[0]?.key ?? 'tools'); + setActiveCapabilityTab(selectedAgentCapabilityTabs[0]?.key ?? null); } }, [activeCapabilityTab, selectedAgentCapabilityTabs]); @@ -773,7 +850,6 @@ const AgentsHomeView: React.FC = () => { ).label } - {selectedAgent.model ? {selectedAgent.model} : null} ) : null} description={selectedAgent @@ -858,7 +934,7 @@ const AgentsHomeView: React.FC = () => { > {tab.label} - {isActive ? ( + {isActive && tab.count ? ( {tab.count} ) : null} @@ -1040,6 +1116,21 @@ const AgentsHomeView: React.FC = () => { ) : null} + {currentCapabilityTab === 'model' && selectedAgent.agentKind === 'subagent' ? ( + setModel(event.target.value)} - placeholder={t('agentsOverview.form.modelPlaceholder')} - inputSize="small" - /> - - ) : null} - {selectableTools.length > 0 ? (
- -
- -
-
- )} - > - {null} - - diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx index 3311b0f960..4ebcf427b7 100644 --- a/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx +++ b/src/web-ui/src/app/scenes/profile/views/NurseryGallery.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Plus, Egg, Settings, Star, Wrench } from 'lucide-react'; +import { Plus, Egg, Puzzle, Settings, Wrench } from 'lucide-react'; import { GalleryLayout, GalleryPageHeader, @@ -13,9 +13,6 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; import type { WorkspaceInfo } from '@/shared/types'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; -import { getModelDisplayName } from '@/infrastructure/config/services/modelConfigs'; import { createLogger } from '@/shared/utils/logger'; import AssistantCard from './AssistantCard'; import { useNurseryStore } from '../nurseryStore'; @@ -29,8 +26,8 @@ const log = createLogger('NurseryGallery'); const ASSISTANT_MODE_ID = 'Claw'; interface TemplateStats { - defaultModelName: string; enabledToolCount: number; + enabledSkillCount: number; } const NurseryGallery: React.FC = () => { @@ -47,44 +44,14 @@ const NurseryGallery: React.FC = () => { useEffect(() => { (async () => { try { - const [allModels, defaultModels, agentModels, modeConf] = await Promise.all([ - configManager.getConfig('ai.models').catch(() => [] as AIModelConfig[]), - configManager.getConfig('ai.default_models').catch(() => ({} as DefaultModelsConfig)), - configManager.getConfig>('ai.agent_models').catch(() => ({} as Record)), + const [modeConf, skills] = await Promise.all([ configAPI.getAgentProfileConfig(ASSISTANT_MODE_ID).catch(() => null), + configAPI.getModeSkillConfigs({ modeId: ASSISTANT_MODE_ID }).catch(() => []), ]); - const models = allModels ?? []; - const defaults = defaultModels ?? {}; - const configuredAgentModels = agentModels ?? {}; - - const findEnabledModelByRef = (modelRef?: string | null): AIModelConfig | null => { - const trimmed = modelRef?.trim(); - if (!trimmed) return null; - return models.find((model) => model.enabled && model.id === trimmed) ?? null; - }; - - const resolveClawDefaultModelName = (): string => { - const configuredModel = configuredAgentModels[ASSISTANT_MODE_ID]?.trim() || 'auto'; - if (configuredModel === 'auto') { - return t('nursery.template.stats.autoDefault'); - } - if (configuredModel === 'primary') { - return findEnabledModelByRef(defaults.primary) - ? getModelDisplayName(findEnabledModelByRef(defaults.primary)!) - : t('nursery.template.stats.primaryDefault'); - } - if (configuredModel === 'fast') { - const fastModel = findEnabledModelByRef(defaults.fast) ?? findEnabledModelByRef(defaults.primary); - return fastModel ? getModelDisplayName(fastModel) : t('nursery.template.stats.fastDefault'); - } - - const explicitModel = findEnabledModelByRef(configuredModel); - return explicitModel ? getModelDisplayName(explicitModel) : configuredModel; - }; setTemplateStats({ - defaultModelName: resolveClawDefaultModelName(), enabledToolCount: modeConf?.enabled_tools?.length ?? 0, + enabledSkillCount: skills.filter((skill) => skill.effectiveEnabled).length, }); } catch (e) { log.error('Failed to load template stats', e); @@ -201,14 +168,14 @@ const NurseryGallery: React.FC = () => { {/* Key stats */} {templateStats && (
- - - {templateStats.defaultModelName} - {t('nursery.template.stats.tools', { count: templateStats.enabledToolCount })} + + + {t('nursery.template.stats.skills', { count: templateStats.enabledSkillCount })} +
)} diff --git a/src/web-ui/src/app/scenes/profile/views/NurseryView.scss b/src/web-ui/src/app/scenes/profile/views/NurseryView.scss index a7633b0ce1..90535e3888 100644 --- a/src/web-ui/src/app/scenes/profile/views/NurseryView.scss +++ b/src/web-ui/src/app/scenes/profile/views/NurseryView.scss @@ -1471,22 +1471,6 @@ $nursery-scene-gutter: clamp(40px, 6vw, 80px); padding: 0; } -.tc-template-model-panel { - min-width: 0; -} - -// Template selector stack -.tc-template-model-zone { - .gallery-zone__header { - align-items: flex-start; - } - - .gallery-zone__tools { - flex: 0 0 min(360px, 100%); - width: min(360px, 100%); - } -} - .tc-template-detail { display: flex; flex-direction: column; @@ -2229,81 +2213,6 @@ $nursery-scene-gutter: clamp(40px, 6vw, 80px); } } -// ── Inline model slot (horizontal pair inside tc-hero__models) ───────────── - -.tc-model-slot { - flex: 1; - min-width: 0; - display: flex; - align-items: center; - gap: $size-gap-2; - - &--header { - width: 100%; - justify-content: flex-end; - } - - &__label { - flex-shrink: 0; - font-size: var(--font-size-sm); - font-weight: $font-weight-semibold; - color: var(--color-text-secondary); - } - - &__select { - flex: 1; - min-width: 0; - } - - &__select--model-selector { - display: flex; - align-items: center; - } - - &__selector { - width: 100%; - - .bitfun-model-selector__trigger { - width: 100%; - justify-content: space-between; - min-height: 32px; - padding: 0 10px; - border: 1px solid var(--border-subtle); - border-radius: $size-radius-base; - background: var(--element-bg-medium); - color: var(--color-text-primary); - opacity: 1; - font-size: var(--font-size-sm); - } - - .bitfun-model-selector__name { - max-width: none; - flex: 1; - text-align: left; - } - } -} - -@media (max-width: 800px) { - .tc-template-model-zone { - .gallery-zone__header { - align-items: stretch; - flex-direction: column; - } - - .gallery-zone__tools { - width: 100%; - flex-basis: auto; - margin-left: 0; - justify-content: stretch; - } - } - - .tc-model-slot--header { - justify-content: stretch; - } -} - // ── Context window visualization ─────────────────────────────────────────── .tc-ctx { diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 43be17ecb8..cde3433974 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -4242,6 +4242,7 @@ export const ChatInput: React.FC = ({ = ({ className = '', dropdownPlacement = 'top', sessionId, + isSubagentSession = false, currentTokens = 0, maxTokens = 0, contextUsageSource, @@ -172,7 +174,7 @@ export const ModelSelector: React.FC = ({ const { t } = useTranslation('flow-chat'); const [allModels, setAllModels] = useState([]); const [defaultModels, setDefaultModels] = useState({}); - const [agentModels, setAgentModels] = useState>({}); // mode_id -> model_id + const [modeModel, setModeModel] = useState('auto'); const [acpOptions, setAcpOptions] = useState(null); const [dropdownOpen, setDropdownOpen] = useState(false); const [loading, setLoading] = useState(false); @@ -195,6 +197,7 @@ export const ModelSelector: React.FC = ({ acpClientIdFromAgentType(activeSession?.config.agentType) ?? acpClientIdFromAgentType(activeSession?.mode); const isAcpSession = Boolean(acpClientId && sessionId); + const targetIsSubagent = isSubagentSession || activeSession?.sessionKind === 'subagent'; // Load configuration data. const loadConfigData = useCallback(async () => { @@ -202,15 +205,15 @@ export const ModelSelector: React.FC = ({ const configData = await configManager.getConfigs([ 'ai.models', 'ai.default_models', - 'ai.agent_models', + 'ai.agent_model_defaults', ]); const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; const defaultModelsData = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const agentModelsData = (configData['ai.agent_models'] as Record | undefined) || {}; + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; setAllModels(models); setDefaultModels(defaultModelsData); - setAgentModels(agentModelsData); + setModeModel(agentModelDefaults?.mode?.trim() || 'auto'); log.debug('Configuration loaded', { modelsCount: models.length @@ -391,19 +394,28 @@ export const ModelSelector: React.FC = ({ // Session-owned model takes priority so that each session remembers // its own model selection independently. const sessionModelName = activeSession?.config.modelName?.trim(); - if (sessionModelName && sessionModelName !== 'auto') { - if (sessionModelName === 'primary' || sessionModelName === 'fast') { + if (sessionModelName) { + if (isSpecialModel(sessionModelName)) { + if (sessionModelName === 'auto') { + return 'auto'; + } const actualModelId = defaultModels[sessionModelName]; - const model = allModels.find(m => m.id === actualModelId); - if (model) return sessionModelName; - } else { - const model = allModels.find(m => m.id === sessionModelName); - if (model) return sessionModelName; + return allModels.some(model => model.id === actualModelId) + ? sessionModelName + : 'auto'; } + return allModels.some(model => model.id === sessionModelName) + ? sessionModelName + : 'auto'; + } + + if (targetIsSubagent) { + return 'auto'; } - // Fall back to global per-mode configuration. - const configuredModelId = agentModels[currentMode] || 'auto'; + // Legacy sessions created without a model selector fall back to the current + // mode default until they are migrated by the send path. + const configuredModelId = modeModel; if (configuredModelId === 'auto') return 'auto'; if (configuredModelId === 'primary' || configuredModelId === 'fast') { const actualModelId = defaultModels[configuredModelId]; @@ -412,7 +424,7 @@ export const ModelSelector: React.FC = ({ } const model = allModels.find(m => m.id === configuredModelId); return model ? configuredModelId : 'auto'; - }, [allModels, currentMode, agentModels, defaultModels, activeSession?.config.modelName]); + }, [allModels, modeModel, defaultModels, activeSession?.config.modelName, targetIsSubagent]); const currentModel = useMemo((): ModelInfo | null => { const modelId = getCurrentModelId(); @@ -498,23 +510,9 @@ export const ModelSelector: React.FC = ({ return; } - const currentAgentModels = await configManager.getConfig>('ai.agent_models') || {}; - - // Refresh **every** agent mapping already present in the config so - // all configured agents pick up the new model. Also ensure the - // current session's agent type is included. No hardcoded list — - // any agent type added to the config in the future is covered - // automatically. - const updatedAgentModels = { ...currentAgentModels }; - for (const agentType of Object.keys(updatedAgentModels)) { - updatedAgentModels[agentType] = modelId; - } - updatedAgentModels[currentMode] = modelId; - - await configManager.setConfig('ai.agent_models', updatedAgentModels); - setAgentModels(updatedAgentModels); + const updateTargetSessionModel = async () => { + if (!sessionId) return; - if (sessionId) { const store = FlowChatStore.getInstance(); // Update the frontend session model immediately so the UI reflects the // switch without waiting for the backend IPC round-trip. @@ -528,8 +526,18 @@ export const ModelSelector: React.FC = ({ modelName: modelId, }); } + }; + + if (targetIsSubagent) { + await updateTargetSessionModel(); + log.info('Subagent session model updated', { sessionId, modelId }); + return; } + await configManager.setConfig('ai.agent_model_defaults.mode', modelId); + setModeModel(modelId); + await updateTargetSessionModel(); + log.info('Mode model updated', { mode: currentMode, modelId }); globalEventBus.emit('mode:config:updated'); @@ -548,6 +556,7 @@ export const ModelSelector: React.FC = ({ isAcpSession, loading, sessionId, + targetIsSubagent, ]); const tokenPercentage = useMemo(() => { @@ -718,9 +727,6 @@ export const ModelSelector: React.FC = ({ >
{t('modelSelector.modelSelection')} - - {t('modelSelector.currentMode')}: {currentMode} -
diff --git a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx index 54505c93b3..3d85cadd40 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModelRoundItem.tsx @@ -606,8 +606,8 @@ export const ModelRoundItem = React.memo( data-turn-id={turnId} data-round-id={round.id} data-status={round.status} - data-model-id={round.modelId || ''} - data-model-alias={round.modelAlias || ''} + data-model-config-id={round.modelConfigId || ''} + data-effective-model-name={round.effectiveModelName || ''} data-streaming={round.isStreaming ? 'true' : 'false'} > {renderTraceEnabled && renderTraceStartedAtMs !== null && allGroupSummary && visibleGroupSummary && ( diff --git a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts index 01cdbf5197..350a0ada8b 100644 --- a/src/web-ui/src/flow_chat/services/AgenticEventListener.ts +++ b/src/web-ui/src/flow_chat/services/AgenticEventListener.ts @@ -16,6 +16,7 @@ import type { SessionTitleGeneratedEvent, SessionModelAutoMigratedEvent, ImageAnalysisEvent, + ModelRoundStartedEvent, ModelRoundCompletedEvent, UserSteeringInjectedEvent, DeepReviewQueueStateChangedEvent, @@ -35,7 +36,7 @@ export interface AgenticEventCallbacks { onImageAnalysisStarted?: (event: ImageAnalysisEvent) => void; onImageAnalysisCompleted?: (event: ImageAnalysisEvent) => void; onDialogTurnStarted?: (event: AgenticEvent) => void; - onModelRoundStarted?: (event: AgenticEvent) => void; + onModelRoundStarted?: (event: ModelRoundStartedEvent) => void; onModelRoundCompleted?: (event: ModelRoundCompletedEvent) => void; onTextChunk?: (event: TextChunkEvent) => void; onToolEvent?: (event: ToolEvent) => void; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts index b0e98dce35..efeb233e71 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.test.ts @@ -94,7 +94,7 @@ describe('mergeParamsPartialEventData', () => { }); -describe('subagent model display helpers', () => { +describe('subagent parent helpers', () => { beforeEach(() => { resetFlowChatStore(); }); @@ -103,47 +103,6 @@ describe('subagent model display helpers', () => { resetFlowChatStore(); }); - it('resolves model refs to configured request model names', () => { - const models = [ - { - id: 'model-primary', - name: 'Primary Config', - model_name: 'gpt-primary', - }, - { - id: 'model-fast', - name: 'Fast Config', - model_name: 'gpt-fast', - }, - { - id: 'model-custom', - name: 'Custom Config', - model_name: 'gpt-custom', - }, - ] as any[]; - - expect(__test_only__.resolveModelDisplayNameFromConfig( - 'primary', - models, - { primary: 'model-primary', fast: 'model-fast' }, - )).toBe('gpt-primary'); - expect(__test_only__.resolveModelDisplayNameFromConfig( - 'fast', - models, - { primary: 'model-primary', fast: 'model-fast' }, - )).toBe('gpt-fast'); - expect(__test_only__.resolveModelDisplayNameFromConfig( - 'Custom Config', - models, - { primary: 'model-primary', fast: 'model-fast' }, - )).toBe('gpt-custom'); - expect(__test_only__.resolveModelDisplayNameFromConfig( - 'missing-model', - models, - { primary: 'model-primary', fast: 'model-fast' }, - )).toBe('missing-model'); - }); - it('finds the parent task card by subagent session and dialog turn', () => { const firstTask = makeTaskTool('task-1', { subagentSessionId: 'subagent-1', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 7af369f57b..754624b26c 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -27,14 +27,13 @@ import { effectiveToolInvocation, getEffectiveToolName } from '../../utils/toolI import type { DeepReviewQueueStateChangedEvent, ImageAnalysisEvent, + ModelRoundStartedEvent, ModelRoundCompletedEvent, OpenBuiltInBrowserEvent, AcpContextUsageUpdatedEvent, SessionModelAutoMigratedEvent, SubagentSessionLinkedEvent, } from '@/infrastructure/api/service-api/AgentAPI'; -import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { i18nService } from '@/infrastructure/i18n/core/I18nService'; import { MCPAPI } from '@/infrastructure/api/service-api/MCPAPI'; import { ACPClientAPI, type AcpPermissionRequestEvent } from '@/infrastructure/api/service-api/ACPClientAPI'; @@ -154,7 +153,6 @@ export const __test_only__ = { resolveDialogTurnDisplayContent, mergeParamsPartialEventData, findSubagentParentInfoByRound, - resolveModelDisplayNameFromConfig, }; function shouldMarkUnreadCompletion(sessionId: string): boolean { @@ -487,6 +485,7 @@ function handleSubagentSessionLinked( const subagentDialogTurnId = event?.subagentDialogTurnId ?? (event as any)?.subagent_dialog_turn_id; const agentType = event?.agentType ?? (event as any)?.agent_type; + const modelId = event?.modelId ?? (event as any)?.model_id; if (!childSessionId || !parentSessionId || !parentDialogTurnId || !parentToolCallId) { log.warn('SubagentSessionLinked missing required fields', { event }); @@ -501,6 +500,9 @@ function handleSubagentSessionLinked( attachSubagentSessionToParentTool(parentInfo, childSessionId, subagentDialogTurnId); ensureSubagentSession(context, parentInfo, childSessionId, event as Record, agentType); + if (typeof modelId === 'string' && modelId.trim()) { + FlowChatStore.getInstance().updateSessionModelName(childSessionId, modelId.trim()); + } reconcileBackgroundSubagentSession(childSessionId); } @@ -561,108 +563,25 @@ function findSubagentParentInfoByRound( return undefined; } -function readConfigString(value: unknown): string { - return typeof value === 'string' ? value.trim() : ''; -} - -function findConfiguredModel( - models: AIModelConfig[], - modelRef: string | null | undefined, -): AIModelConfig | null { - const value = modelRef?.trim(); - if (!value) { - return null; - } - - return models.find(model => - model.id === value || - model.name === value || - model.model_name === value - ) ?? null; -} - -function resolveModelDisplayNameFromConfig( - modelId: string, - models: AIModelConfig[], - defaultModels: DefaultModelsConfig, -): string { - const fallback = modelId.trim(); - if (!fallback) { - return ''; - } - - let modelRef = fallback; - if (fallback === 'primary') { - modelRef = readConfigString(defaultModels.primary) || fallback; - } else if (fallback === 'fast') { - modelRef = - readConfigString(defaultModels.fast) || - readConfigString(defaultModels.primary) || - fallback; - } - - const model = findConfiguredModel(models, modelRef); - return readConfigString(model?.model_name) || fallback; -} - -async function resolveSubagentModelDisplayName(modelId: string): Promise { - try { - const configData = await configManager.getConfigs([ - 'ai.models', - 'ai.default_models', - ]); - const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; - const defaultModels = - (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - - return resolveModelDisplayNameFromConfig(modelId, models, defaultModels); - } catch (error) { - log.warn('Failed to resolve subagent model display name', { modelId, error }); - return modelId; - } -} - function updateSubagentParentTaskModel( context: FlowChatContext, parentInfo: SubagentParentInfo, - modelId: string, - modelDisplayName: string, + modelConfigId: string | undefined, + effectiveModelName: string, ): void { const store = FlowChatStore.getInstance(); store.updateModelRoundItem( parentInfo.sessionId, parentInfo.dialogTurnId, parentInfo.toolCallId, - { subagentModelId: modelId, subagentModelDisplayName: modelDisplayName } as Partial, + { + subagentModelId: modelConfigId, + subagentModelDisplayName: effectiveModelName, + } as Partial, ); debouncedSaveDialogTurn(context, parentInfo.sessionId, parentInfo.dialogTurnId, 800); } -function patchSubagentParentTaskModelDisplayName( - context: FlowChatContext, - parentInfo: SubagentParentInfo, - modelId: string, - displayName: string, -): void { - const store = FlowChatStore.getInstance(); - const currentItem = store.findToolItem( - parentInfo.sessionId, - parentInfo.dialogTurnId, - parentInfo.toolCallId, - ); - - if (currentItem?.type !== 'tool') { - return; - } - - const currentTool = currentItem as FlowToolItem; - if (currentTool.subagentModelId !== modelId) { - return; - } - - updateSubagentParentTaskModel(context, parentInfo, modelId, displayName); -} - /** * Event filtering mechanism: determines if an event should be processed */ @@ -1857,7 +1776,7 @@ function handleToolEvent( /** * Handle model round started event */ -function handleModelRoundStart(context: FlowChatContext, event: any): void { +function handleModelRoundStart(context: FlowChatContext, event: ModelRoundStartedEvent): void { const { sessionId, turnId, roundId, roundIndex, roundGroupId } = event; if (!shouldProcessEvent(sessionId, turnId, 'data', 'ModelRoundStarted')) { @@ -1895,6 +1814,8 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { event.renderHints?.disableExploreGrouping === true || event.metadata?.disableExploreGrouping === true || event.disableExploreGrouping === true; + const modelConfigId = event.modelConfigId.trim(); + const effectiveModelName = event.effectiveModelName.trim(); const modelRound: ModelRound = { id: roundId, @@ -1905,6 +1826,8 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { isComplete: false, status: 'streaming', startTime: Date.now(), + modelConfigId, + effectiveModelName, ...(disableExploreGrouping ? { renderHints: { disableExploreGrouping: true } } : {}), @@ -1913,22 +1836,16 @@ function handleModelRoundStart(context: FlowChatContext, event: any): void { context.flowChatStore.addModelRound(sessionId, turnId, modelRound); scheduleModelResponseStatus(context, sessionId, turnId, roundId); - const modelIdRaw = event.modelId ?? (event as any).model_id; - const modelId = typeof modelIdRaw === 'string' ? modelIdRaw.trim() : ''; const linkedParentInfo = findSubagentParentInfoByRound(sessionId, turnId) || getLinkedSubagentParentInfo(sessionId); - if (linkedParentInfo && modelId) { - updateSubagentParentTaskModel(context, linkedParentInfo, modelId, modelId); - void resolveSubagentModelDisplayName(modelId) - .then(displayName => { - if (displayName && displayName !== modelId) { - patchSubagentParentTaskModelDisplayName(context, linkedParentInfo, modelId, displayName); - } - }) - .catch(error => { - log.warn('Failed to patch subagent model display name', { modelId, error }); - }); + if (linkedParentInfo && effectiveModelName) { + updateSubagentParentTaskModel( + context, + linkedParentInfo, + modelConfigId, + effectiveModelName, + ); } immediateSaveDialogTurn(context, sessionId, turnId); @@ -1978,8 +1895,8 @@ function handleModelRoundComplete(context: FlowChatContext, event: ModelRoundCom endTime, durationMs, providerId: event.providerId ?? (event as any).provider_id, - modelId: event.modelId ?? (event as any).model_id, - modelAlias: event.modelAlias ?? (event as any).model_alias, + modelConfigId: event.modelConfigId, + effectiveModelName: event.effectiveModelName, firstChunkMs: optionalNumber(event.firstChunkMs ?? (event as any).first_chunk_ms), firstVisibleOutputMs: optionalNumber(event.firstVisibleOutputMs ?? (event as any).first_visible_output_ms), streamDurationMs: optionalNumber(event.streamDurationMs ?? (event as any).stream_duration_ms), diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts index 1ee02510d7..e740cc72a8 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.test.ts @@ -1,9 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { cancelSessionTask } from './MessageModule'; +import { cancelSessionTask, syncSessionModelSelection } from './MessageModule'; import { SessionExecutionEvent } from '../../state-machine/types'; const mockCancelTransientBtwSession = vi.fn(); const mockTransition = vi.fn(); +const mockUpdateSessionModel = vi.fn(); +const mockGetConfigs = vi.fn(); vi.mock('../BtwThreadService', () => ({ cancelTransientBtwSession: (...args: any[]) => mockCancelTransientBtwSession(...args), @@ -29,7 +31,9 @@ vi.mock('../../state-machine', () => ({ })); vi.mock('@/infrastructure/api/service-api/AgentAPI', () => ({ - agentAPI: {}, + agentAPI: { + updateSessionModel: (...args: unknown[]) => mockUpdateSessionModel(...args), + }, })); vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ @@ -37,7 +41,9 @@ vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ })); vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ - configManager: {}, + configManager: { + getConfigs: (...args: unknown[]) => mockGetConfigs(...args), + }, })); vi.mock('../../../shared/notification-system', () => ({ @@ -98,3 +104,70 @@ describe('MessageModule cancellation', () => { expect(activeTextItems.has('btw-child')).toBe(false); }); }); + +describe('MessageModule model synchronization', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetConfigs.mockResolvedValue({ + 'ai.agent_model_defaults': { mode: 'model-b' }, + 'ai.models': [ + { id: 'primary-model', enabled: true, context_window: 32000 }, + { id: 'model-b', enabled: true, context_window: 64000 }, + ], + 'ai.default_models': { primary: 'primary-model' }, + }); + mockUpdateSessionModel.mockResolvedValue(undefined); + }); + + it('keeps an explicit auto selector when synchronizing before send', async () => { + const session = { + sessionId: 'session-auto', + config: { modelName: 'auto' }, + maxContextTokens: 64000, + }; + const updateSessionModelName = vi.fn(); + const updateSessionMaxContextTokens = vi.fn(); + const context: any = { + flowChatStore: { + getState: () => ({ sessions: new Map([['session-auto', session]]) }), + updateSessionModelName, + updateSessionMaxContextTokens, + }, + }; + + await syncSessionModelSelection(context, 'session-auto', 'agentic'); + + expect(updateSessionModelName).not.toHaveBeenCalled(); + expect(updateSessionMaxContextTokens).toHaveBeenCalledWith('session-auto', 32000); + expect(mockUpdateSessionModel).toHaveBeenCalledWith({ + sessionId: 'session-auto', + modelName: 'auto', + }); + }); + + it('migrates a legacy session without a model to the current mode default', async () => { + const session = { + sessionId: 'legacy-session', + config: {}, + maxContextTokens: 32000, + }; + const updateSessionModelName = vi.fn(); + const updateSessionMaxContextTokens = vi.fn(); + const context: any = { + flowChatStore: { + getState: () => ({ sessions: new Map([['legacy-session', session]]) }), + updateSessionModelName, + updateSessionMaxContextTokens, + }, + }; + + await syncSessionModelSelection(context, 'legacy-session', 'agentic'); + + expect(updateSessionModelName).toHaveBeenCalledWith('legacy-session', 'model-b'); + expect(updateSessionMaxContextTokens).toHaveBeenCalledWith('legacy-session', 64000); + expect(mockUpdateSessionModel).toHaveBeenCalledWith({ + sessionId: 'legacy-session', + modelName: 'model-b', + }); + }); +}); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts index 598314dae8..23c72cddfc 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/MessageModule.ts @@ -6,7 +6,7 @@ import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; import { ACPClientAPI } from '@/infrastructure/api/service-api/ACPClientAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; -import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; +import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import { notificationService } from '../../../shared/notification-system'; import { stateMachineManager } from '../../state-machine'; import { SessionExecutionEvent, SessionExecutionState } from '../../state-machine/types'; @@ -57,7 +57,7 @@ function normalizeModelSelection( return matchedModel ? value : 'auto'; } -async function syncSessionModelSelection( +export async function syncSessionModelSelection( context: FlowChatContext, sessionId: string, agentType: string, @@ -67,46 +67,37 @@ async function syncSessionModelSelection( throw new Error(`Session does not exist: ${sessionId}`); } - const currentModelId = (session.config.modelName || 'auto').trim() || 'auto'; + const sessionModelId = session.config.modelName?.trim(); - // When the session already has an explicit model selected, keep it — - // do not overwrite with the global per-mode default. Still sync to - // the backend in case a previous update_session_model call silently - // failed (e.g. the session had been evicted from memory on the Rust - // side and the restore path did not have a workspace index entry). - if (currentModelId !== 'auto') { - const desiredMaxContextTokens = await getModelMaxTokens(currentModelId, agentType); + // Any stored selector, including "auto", belongs to the session. Still sync + // it to the backend in case the restored runtime session lost that state. + if (sessionModelId) { + const desiredMaxContextTokens = await getModelMaxTokens(sessionModelId, agentType); if (session.maxContextTokens !== desiredMaxContextTokens) { context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); } await agentAPI.updateSessionModel({ sessionId, - modelName: currentModelId, + modelName: sessionModelId, }); return; } const configData = await configManager.getConfigs([ - 'ai.agent_models', + 'ai.agent_model_defaults', 'ai.models', 'ai.default_models', ]); - const agentModels = (configData['ai.agent_models'] as Record | undefined) || {}; + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; const allModels = (configData['ai.models'] as AIModelConfig[] | undefined) || []; const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const desiredModelId = normalizeModelSelection(agentModels[agentType], allModels, defaultModels); + const desiredModelId = normalizeModelSelection(agentModelDefaults?.mode, allModels, defaultModels); const shouldForceAutoSync = desiredModelId === 'auto'; const desiredMaxContextTokens = await getModelMaxTokens(desiredModelId, agentType); const shouldSyncContextWindow = session.maxContextTokens !== desiredMaxContextTokens; - if (!shouldForceAutoSync && desiredModelId === currentModelId && !shouldSyncContextWindow) { - return; - } - - if (currentModelId !== desiredModelId) { - context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); - } + context.flowChatStore.updateSessionModelName(sessionId, desiredModelId); if (shouldSyncContextWindow) { context.flowChatStore.updateSessionMaxContextTokens(sessionId, desiredMaxContextTokens); } @@ -118,7 +109,7 @@ async function syncSessionModelSelection( log.info('Session model synchronized before send', { sessionId, agentType, - previousModelId: currentModelId, + previousModelId: null, nextModelId: desiredModelId, forcedAutoSync: shouldForceAutoSync, }); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index e4c71e31fa..654898fadd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -27,6 +27,10 @@ const configApiMocks = vi.hoisted(() => ({ getConfig: vi.fn(), })); +const configManagerMocks = vi.hoisted(() => ({ + getConfigs: vi.fn(), +})); + const sessionApiMocks = vi.hoisted(() => ({ archiveSession: vi.fn(), })); @@ -49,6 +53,10 @@ vi.mock('@/infrastructure/api/service-api/ConfigAPI', () => ({ configAPI: configApiMocks, })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: configManagerMocks, +})); + vi.mock('@/infrastructure/api/service-api/SessionAPI', () => ({ sessionAPI: sessionApiMocks, })); @@ -279,6 +287,13 @@ describe('resolveAgentTypeForSessionCreation', () => { }); describe('createChatSession', () => { + beforeEach(() => { + configApiMocks.getConfig.mockResolvedValue(null); + configManagerMocks.getConfigs.mockResolvedValue({}); + agentApiMocks.getAvailableModes.mockResolvedValue([{ id: 'agentic' }]); + agentApiMocks.createSession.mockResolvedValue({ sessionId: 'created-1' }); + }); + afterEach(() => { vi.clearAllMocks(); }); @@ -287,15 +302,10 @@ describe('createChatSession', () => { // This keeps the first create suspended in the model config path while the // second create enters with the same creation key. const modelConfig = createDeferred>(); - configApiMocks.getConfig.mockImplementation(async (key: string) => { - if (key === 'chat.default_mode') { - return null; - } + configManagerMocks.getConfigs.mockImplementation(async () => { await modelConfig.promise; - return key === 'ai.models' ? [] : {}; + return {}; }); - agentApiMocks.getAvailableModes.mockResolvedValue([{ id: 'agentic' }]); - agentApiMocks.createSession.mockResolvedValue({ sessionId: 'created-1' }); const { context } = createContext(createSession({ workspacePath: '/home/wsp/projects/Test', @@ -315,6 +325,81 @@ describe('createChatSession', () => { expect(agentApiMocks.createSession).toHaveBeenCalledTimes(1); }); + + it('snapshots the current mode model into a newly created session', async () => { + configManagerMocks.getConfigs.mockImplementation(async (paths: string[]) => { + if (paths.length === 1 && paths[0] === 'ai.agent_model_defaults') { + return { 'ai.agent_model_defaults': { mode: 'model-b' } }; + } + return { + 'ai.agent_model_defaults': { mode: 'model-b' }, + 'ai.models': [{ id: 'model-b', enabled: true, context_window: 64000 }], + 'ai.default_models': { primary: 'model-b' }, + }; + }); + const { context, flowChatStore } = createContext(createSession({ + workspacePath: '/home/wsp/projects/Test', + })); + + await createChatSession(context, { workspacePath: '/home/wsp/projects/Test' }, 'agentic'); + + expect(agentApiMocks.createSession).toHaveBeenCalledWith(expect.objectContaining({ + config: expect.objectContaining({ + modelName: 'model-b', + maxContextTokens: 64000, + }), + })); + expect(flowChatStore.createSession).toHaveBeenCalledWith( + 'created-1', + expect.objectContaining({ modelName: 'model-b' }), + undefined, + expect.any(String), + 64000, + 'agentic', + '/home/wsp/projects/Test', + undefined, + undefined, + expect.any(Object), + ); + }); + + it('preserves an explicit session model instead of applying the mode default', async () => { + configManagerMocks.getConfigs.mockResolvedValue({ + 'ai.agent_model_defaults': { mode: 'model-b' }, + 'ai.models': [ + { id: 'model-a', enabled: true, context_window: 32000 }, + { id: 'model-b', enabled: true, context_window: 64000 }, + ], + 'ai.default_models': { primary: 'model-b' }, + }); + const { context, flowChatStore } = createContext(createSession({ + workspacePath: '/home/wsp/projects/Test', + })); + + await createChatSession(context, { + workspacePath: '/home/wsp/projects/Test', + modelName: 'model-a', + }, 'agentic'); + + expect(agentApiMocks.createSession).toHaveBeenCalledWith(expect.objectContaining({ + config: expect.objectContaining({ + modelName: 'model-a', + maxContextTokens: 32000, + }), + })); + expect(flowChatStore.createSession).toHaveBeenCalledWith( + 'created-1', + expect.objectContaining({ modelName: 'model-a' }), + undefined, + expect.any(String), + 32000, + 'agentic', + '/home/wsp/projects/Test', + undefined, + undefined, + expect.any(Object), + ); + }); }); describe('SessionModule historical session coordination', () => { diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 7b2fd74e7e..c44e0b6b84 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -14,7 +14,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { workspaceManager } from '@/infrastructure/services/business/workspaceManager'; import { normalizeRemoteWorkspacePath } from '@/shared/utils/pathUtils'; import { WorkspaceKind, type WorkspaceInfo } from '@/shared/types'; -import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; +import type { AIModelConfig, AgentModelDefaultsConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; import type { FlowChatContext, SessionConfig } from './types'; import type { Session } from '../../types/flow-chat'; import { touchSessionActivity, cleanupSaveState } from './PersistenceModule'; @@ -479,24 +479,29 @@ export async function getModelMaxTokens(modelName?: string, agentType?: string): const configData = await configManager.getConfigs([ 'ai.models', 'ai.default_models', - 'ai.agent_models', + 'ai.agent_model_defaults', ]); const models = (configData['ai.models'] as AIModelConfig[] | undefined) || []; const defaultModels = (configData['ai.default_models'] as DefaultModelsConfig | undefined) || {}; - const agentModels = (configData['ai.agent_models'] as Record | undefined) || {}; - + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + + const normalizedModelName = modelName?.trim(); const explicitModel = resolveModelForContextWindow(modelName, models, defaultModels); if (explicitModel?.context_window) { return explicitModel.context_window; } - const agentModel = resolveModelForContextWindow( - agentType ? agentModels[agentType] : undefined, - models, - defaultModels, - ); - if (agentModel?.context_window) { - return agentModel.context_window; + // Only legacy sessions without a model selector inherit the current mode + // default. Explicit symbolic selectors such as "auto" remain session-owned. + if (!normalizedModelName) { + const modeModel = resolveModelForContextWindow( + agentModelDefaults?.mode, + models, + defaultModels, + ); + if (modeModel?.context_window) { + return modeModel.context_window; + } } const primaryModel = resolveModelForContextWindow('primary', models, defaultModels); @@ -512,6 +517,23 @@ export async function getModelMaxTokens(modelName?: string, agentType?: string): } } +async function resolveModelForSessionCreation(modelName?: string): Promise { + const explicitModelName = modelName?.trim(); + if (explicitModelName) { + return explicitModelName; + } + + try { + const configManager = await import('@/infrastructure/config/services/ConfigManager').then(m => m.configManager); + const configData = await configManager.getConfigs(['ai.agent_model_defaults']); + const agentModelDefaults = configData['ai.agent_model_defaults'] as AgentModelDefaultsConfig | undefined; + return agentModelDefaults?.mode?.trim() || 'auto'; + } catch (error) { + log.warn('Failed to resolve model default during session creation', { error }); + return 'auto'; + } +} + /** * Create new chat session (managed by backend) */ @@ -567,11 +589,13 @@ export async function createChatSession( (key, options) => i18nService.t(key, options), ); const sessionName = titleDescriptor.text; - - const maxContextTokens = await getModelMaxTokens(config.modelName, agentType); + + const sessionModelName = await resolveModelForSessionCreation(config.modelName); + const maxContextTokens = await getModelMaxTokens(sessionModelName, agentType); const mergedConfig: SessionConfig = { ...config, + modelName: sessionModelName, workspaceId: workspace?.id ?? config.workspaceId, }; @@ -583,7 +607,7 @@ export async function createChatSession( remoteConnectionId, remoteSshHost, config: { - modelName: config.modelName || 'auto', + modelName: sessionModelName, enableTools: true, safeMode: true, autoCompact: true, diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts index 5bd1ffbd3e..e8339af712 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.test.ts @@ -673,6 +673,24 @@ describe('FlowChatStore ACP context usage', () => { }); }); +describe('FlowChatStore session model selection', () => { + afterEach(() => { + resetStore(); + }); + + it('stores an explicit auto selector on a legacy session without a model', () => { + const session = createSession({ config: { agentType: 'agentic' } }); + flowChatStore.setState(() => ({ + sessions: new Map([[session.sessionId, session]]), + activeSessionId: session.sessionId, + })); + + flowChatStore.updateSessionModelName(session.sessionId, 'auto'); + + expect(flowChatStore.getState().sessions.get(session.sessionId)?.config.modelName).toBe('auto'); + }); +}); + describe('FlowChatStore historical session hydration state', () => { beforeEach(() => { vi.stubGlobal('CustomEvent', class { diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 6298281a4e..d34b0f2d40 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -1751,7 +1751,7 @@ export class FlowChatStore { if (!session) return prev; const normalizedModelName = modelName.trim() || 'auto'; - if ((session.config.modelName || 'auto') === normalizedModelName) { + if (session.config.modelName?.trim() === normalizedModelName) { return prev; } @@ -3223,8 +3223,8 @@ export class FlowChatStore { endTime: round.endTime || Date.now(), durationMs: round.durationMs, providerId: round.providerId, - modelId: round.modelId, - modelAlias: round.modelAlias, + modelConfigId: round.modelConfigId, + effectiveModelName: round.effectiveModelName, firstChunkMs: round.firstChunkMs, firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, @@ -4252,7 +4252,7 @@ export class FlowChatStore { this.setState(prev => { const session = prev.sessions.get(sessionId); if (!session) return prev; - + const updatedSession = { ...session, dialogTurns, @@ -4526,8 +4526,8 @@ export class FlowChatStore { endTime: round.endTime, durationMs: round.durationMs, providerId: round.providerId, - modelId: round.modelId, - modelAlias: round.modelAlias, + modelConfigId: round.modelConfigId, + effectiveModelName: round.effectiveModelName, firstChunkMs: round.firstChunkMs, firstVisibleOutputMs: round.firstVisibleOutputMs, streamDurationMs: round.streamDurationMs, diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index ecb83c9eda..10364f7180 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -96,8 +96,9 @@ export interface FlowToolItem extends FlowItem { confirmationWaitMs?: number; executionMs?: number; - /** Subagent model identity captured on the parent Task tool. */ + /** Resolved subagent AI model configuration ID captured on the parent Task tool. */ subagentModelId?: string; + /** Provider model name used by the subagent's round. */ subagentModelDisplayName?: string; /** Child dialog turn produced by this parent Task call. */ @@ -180,8 +181,8 @@ export interface ModelRound { endTime?: number; durationMs?: number; providerId?: string; - modelId?: string; - modelAlias?: string; + modelConfigId?: string; + effectiveModelName?: string; firstChunkMs?: number; firstVisibleOutputMs?: number; streamDurationMs?: number; diff --git a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts index 53583891e1..e9849057bb 100644 --- a/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/AgentAPI.ts @@ -335,6 +335,7 @@ export interface SubagentSessionLinkedEvent extends AgenticEvent { parentDialogTurnId: string; parentToolCallId: string; agentType?: string; + modelId?: string; } export type DeepReviewQueueStatus = @@ -405,8 +406,10 @@ export interface ModelRoundCompletedEvent extends AgenticEvent { hasToolCalls?: boolean; durationMs?: number; providerId?: string; - modelId?: string; - modelAlias?: string; + /** Resolved AI model configuration ID. */ + modelConfigId: string; + /** Provider model name sent on the request. */ + effectiveModelName: string; firstChunkMs?: number; firstVisibleOutputMs?: number; streamDurationMs?: number; @@ -420,7 +423,10 @@ export interface ModelRoundStartedEvent extends AgenticEvent { roundId: string; roundGroupId?: string; roundIndex: number; - modelId?: string; + /** Resolved AI model configuration ID. */ + modelConfigId: string; + /** Provider model name sent on the request. */ + effectiveModelName: string; } export interface AcpContextUsageUpdatedEvent extends AgenticEvent { @@ -886,8 +892,8 @@ export class AgentAPI { } - onModelRoundStarted(callback: (event: AgenticEvent) => void): () => void { - return api.listen('agentic://model-round-started', callback); + onModelRoundStarted(callback: (event: ModelRoundStartedEvent) => void): () => void { + return api.listen('agentic://model-round-started', callback); } onModelRoundCompleted(callback: (event: ModelRoundCompletedEvent) => void): () => void { diff --git a/src/web-ui/src/infrastructure/api/service-api/SubagentAPI.ts b/src/web-ui/src/infrastructure/api/service-api/SubagentAPI.ts index 1d5cd5ab80..f24ed0de74 100644 --- a/src/web-ui/src/infrastructure/api/service-api/SubagentAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/SubagentAPI.ts @@ -41,8 +41,8 @@ export interface SubagentInfo { source?: SubagentSource; subagentSource?: SubagentSource; path?: string; - model?: string; + modelIsExplicit?: boolean; visibility?: SubagentVisibilitySummary; configProfileId?: string; configProfileLabel?: string; @@ -87,6 +87,7 @@ export interface UpdateSubagentConfigPayload { parentAgentType?: string; enabled?: boolean; model?: string; + clearModelOverride?: boolean; workspacePath?: string; } diff --git a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx index 36991106eb..72fff39dea 100644 --- a/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AIModelConfig.tsx @@ -17,6 +17,7 @@ import type { DiscoveredCliCredential } from '@/infrastructure/api/service-api/A import { useNotification } from '@/shared/notification-system'; import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSection, ConfigPageRow, ConfigCollectionItem } from './common'; import DefaultModelConfig from './DefaultModelConfig'; +import SubagentModelConfig from './SubagentModelConfig'; import { createLogger } from '@/shared/utils/logger'; import { translateConnectionTestMessage } from '@/shared/utils/aiConnectionTestMessages'; import { i18nService } from '@/infrastructure/i18n'; @@ -2580,6 +2581,10 @@ const AIModelConfig: React.FC = () => { + + + + Array.isArray(value) ? (value[0] ?? '') : value; -type ModelSelectOption = SelectOption & { - meta?: string; - enableThinking?: boolean; -}; - type DefaultModelSlot = 'primary' | 'fast' | 'image_understanding'; export const DefaultModelConfig: React.FC = () => { const { t } = useTranslation('settings/default-model'); + const { buildModelOption, renderModelOption, renderModelValue } = useModelSelectPresentation(); const renderOptionalLabel = (text: string) => ( <> {text} @@ -96,73 +88,6 @@ export const DefaultModelConfig: React.FC = () => { return model?.model_name; }, [models]); - const formatContextWindow = useCallback((contextWindow?: number) => { - if (!contextWindow) return null; - return `${Math.round(contextWindow / 1000)}k`; - }, []); - - const buildModelMeta = useCallback((model: AIModelConfig) => { - const parts = [getProviderDisplayName(model)]; - const contextWindow = formatContextWindow(model.context_window); - - if (contextWindow) { - parts.push(contextWindow); - } - - if (model.reasoning_effort) { - parts.push(model.reasoning_effort); - } - - return parts.join(' · '); - }, [formatContextWindow]); - - const buildModelOption = useCallback((model: AIModelConfig): ModelSelectOption => ({ - label: model.model_name, - value: model.id!, - meta: buildModelMeta(model), - enableThinking: isReasoningVisiblyEnabled(getEffectiveReasoningMode(model)), - }), [buildModelMeta]); - - const renderModelOption = useCallback((option: SelectOption) => { - const modelOption = option as ModelSelectOption; - - return ( -
-
- {modelOption.label} - {modelOption.enableThinking && ( - - )} -
- {modelOption.meta && ( -
{modelOption.meta}
- )} -
- ); - }, []); - - const renderModelValue = useCallback((option?: SelectOption | SelectOption[]) => { - const selectedOption = Array.isArray(option) ? option[0] : option; - if (!selectedOption) return null; - - const modelOption = selectedOption as ModelSelectOption; - return ( - - - - {modelOption.label} - {modelOption.enableThinking && ( - - )} - - {modelOption.meta && ( - {modelOption.meta} - )} - - - ); - }, []); - const slotLabel = useCallback((slot: DefaultModelSlot): string => { switch (slot) { @@ -248,7 +173,7 @@ export const DefaultModelConfig: React.FC = () => { options={enabledModels.map(buildModelOption)} renderOption={renderModelOption} renderValue={renderModelValue} - className="default-model-config__model-select" + className="model-select-presentation__select" disabled={enabledModels.length === 0} size="small" /> @@ -269,7 +194,7 @@ export const DefaultModelConfig: React.FC = () => { ]} renderOption={renderModelOption} renderValue={renderModelValue} - className="default-model-config__model-select" + className="model-select-presentation__select" size="small" /> @@ -289,7 +214,7 @@ export const DefaultModelConfig: React.FC = () => { ]} renderOption={renderModelOption} renderValue={renderModelValue} - className="default-model-config__model-select" + className="model-select-presentation__select" size="small" /> diff --git a/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss new file mode 100644 index 0000000000..54b73bae8b --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.scss @@ -0,0 +1,79 @@ +@use '../../../component-library/styles/tokens.scss' as *; + +.model-select-presentation { + &__select { + .select__trigger { + min-height: 48px; + align-items: flex-start; + padding-top: 8px; + padding-bottom: 8px; + } + + .select__placeholder, + .select__suffix { + align-self: center; + } + + .select__option { + padding: 8px 10px; + border-radius: 6px; + } + } + + &__value { + min-width: 0; + width: 100%; + + &--single-line { + align-self: center; + } + } + + &__value-text, + &__option { + display: flex; + flex-direction: column; + min-width: 0; + width: 100%; + } + + &__value-title, + &__option-title { + display: inline-flex; + align-items: center; + gap: 4px; + min-width: 0; + } + + &__value-name, + &__option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-primary); + font-size: 10px; + font-weight: $font-weight-medium; + } + + &__value-meta, + &__option-meta { + min-width: 0; + margin-top: 2px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-text-muted); + font-size: 8px; + line-height: 1.4; + } + + &__thinking { + --model-select-thinking-rgb: 180, 160, 255; + + flex-shrink: 0; + width: 11px; + height: 11px; + color: rgba(var(--model-select-thinking-rgb), 0.9); + } +} diff --git a/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.tsx b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.tsx new file mode 100644 index 0000000000..1baf4e6f07 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/ModelSelectPresentation.tsx @@ -0,0 +1,86 @@ +import { useCallback } from 'react'; +import { Sparkles } from 'lucide-react'; +import { type SelectOption } from '@/component-library'; +import { getProviderDisplayName } from '../services/modelConfigs'; +import { getEffectiveReasoningMode, isReasoningVisiblyEnabled } from '../utils/reasoning'; +import type { AIModelConfig } from '../types'; +import './ModelSelectPresentation.scss'; + +export type ModelSelectOption = SelectOption & { + meta?: string; + enableThinking?: boolean; +}; + +export function useModelSelectPresentation() { + const formatContextWindow = useCallback((contextWindow?: number) => { + if (!contextWindow) return null; + return `${Math.round(contextWindow / 1000)}k`; + }, []); + + const buildModelOption = useCallback((model: AIModelConfig): ModelSelectOption => { + const meta = [getProviderDisplayName(model)]; + const contextWindow = formatContextWindow(model.context_window); + + if (contextWindow) { + meta.push(contextWindow); + } + if (model.reasoning_effort) { + meta.push(model.reasoning_effort); + } + + return { + label: model.model_name || model.name || model.id || '', + value: model.id || '', + meta: meta.join(' · '), + enableThinking: isReasoningVisiblyEnabled(getEffectiveReasoningMode(model)), + }; + }, [formatContextWindow]); + + const renderModelOption = useCallback((option: SelectOption) => { + const modelOption = option as ModelSelectOption; + + return ( +
+
+ {modelOption.label} + {modelOption.enableThinking && ( + + )} +
+ {modelOption.meta && ( +
{modelOption.meta}
+ )} +
+ ); + }, []); + + const renderModelValue = useCallback((option?: SelectOption | SelectOption[]) => { + const selectedOption = Array.isArray(option) ? option[0] : option; + if (!selectedOption) return null; + + const modelOption = selectedOption as ModelSelectOption; + return ( + + + + {modelOption.label} + {modelOption.enableThinking && ( + + )} + + {modelOption.meta && ( + {modelOption.meta} + )} + + + ); + }, []); + + return { buildModelOption, renderModelOption, renderModelValue }; +} diff --git a/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.scss b/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.scss new file mode 100644 index 0000000000..72bb61084c --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.scss @@ -0,0 +1,25 @@ +.subagent-model-config__label { + display: inline-flex; + align-items: center; + gap: var(--size-gap-1); + min-width: 0; +} + +.subagent-model-config__configure { + opacity: 0; + pointer-events: none; + transition: opacity var(--motion-fast) var(--easing-standard); +} + +.subagent-model-config__row:hover .subagent-model-config__configure, +.subagent-model-config__row:focus-within .subagent-model-config__configure { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .subagent-model-config__configure { + opacity: 1; + pointer-events: auto; + } +} diff --git a/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.tsx b/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.tsx new file mode 100644 index 0000000000..2e26b3de11 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/SubagentModelConfig.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Settings } from 'lucide-react'; +import { IconButton, Select } from '@/component-library'; +import { useSceneStore } from '@/app/stores/sceneStore'; +import { useNotification } from '@/shared/notification-system'; +import { configManager } from '../services/ConfigManager'; +import type { AgentModelDefaultsConfig, AIModelConfig, SubagentModelSelection } from '../types'; +import { ConfigPageRow } from './common'; +import { type ModelSelectOption, useModelSelectPresentation } from './ModelSelectPresentation'; +import './SubagentModelConfig.scss'; + +const DEFAULT_SUBAGENT_SELECTION: SubagentModelSelection = { kind: 'fixed', model_id: 'fast' }; + +function normalizeSelectValue(value: string | number | (string | number)[]): string { + return String(Array.isArray(value) ? (value[0] ?? '') : value); +} + +function selectionFromValue(value: string): SubagentModelSelection { + return value === 'inherit' + ? { kind: 'inherit' } + : { kind: 'fixed', model_id: value }; +} + +function selectionValue(selection: SubagentModelSelection): string { + return selection.kind === 'inherit' ? 'inherit' : selection.model_id; +} + +export const SubagentModelConfig: React.FC = () => { + const { t } = useTranslation('settings/ai-model'); + const { error: notifyError } = useNotification(); + const [isLoading, setIsLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [models, setModels] = useState([]); + const [selection, setSelection] = useState(DEFAULT_SUBAGENT_SELECTION); + const { buildModelOption, renderModelOption, renderModelValue } = useModelSelectPresentation(); + const openScene = useSceneStore((state) => state.openScene); + + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const [configuredModels, defaults] = await Promise.all([ + configManager.getConfig('ai.models'), + configManager.getConfig('ai.agent_model_defaults'), + ]); + setModels(configuredModels ?? []); + setSelection(defaults?.subagents?.default ?? DEFAULT_SUBAGENT_SELECTION); + } catch { + notifyError(t('subagentModels.loadFailed')); + } finally { + setIsLoading(false); + } + }, [notifyError, t]); + + useEffect(() => { + void loadData(); + const unwatchModels = configManager.watch('ai.models', () => void loadData()); + const unwatchDefaults = configManager.watch('ai.agent_model_defaults', () => void loadData()); + return () => { + unwatchModels(); + unwatchDefaults(); + }; + }, [loadData]); + + const modelOptions = useMemo(() => [ + { label: t('subagentModels.options.inherit'), value: 'inherit' }, + { label: t('subagentModels.options.fast'), value: 'fast' }, + { label: t('subagentModels.options.primary'), value: 'primary' }, + { label: t('subagentModels.options.auto'), value: 'auto' }, + ...models + .filter((model): model is AIModelConfig & { id: string } => ( + typeof model.id === 'string' + && model.id.trim().length > 0 + && model.enabled !== false + && (model.capabilities ?? []).includes('text_chat') + )) + .map(buildModelOption), + ], [buildModelOption, models, t]); + + const handleChange = useCallback(async ( + value: string | number | (string | number)[], + ) => { + const nextSelection = selectionFromValue(normalizeSelectValue(value)); + setIsSaving(true); + try { + await configManager.setConfig('ai.agent_model_defaults.subagents.default', nextSelection); + setSelection(nextSelection); + } catch { + notifyError(t('subagentModels.default.updateFailed')); + } finally { + setIsSaving(false); + } + }, [notifyError, t]); + + const openSubagentCustomization = useCallback(() => { + openScene('agents'); + }, [openScene]); + + return ( + + {t('subagentModels.default.label')} + + + + )} + align="center" + > +