From b8dd9e79df253734141aa24c9ec736a4c8031d4d Mon Sep 17 00:00:00 2001 From: czs914893385 Date: Sun, 15 Nov 2026 22:28:10 +0800 Subject: [PATCH] feat(agent): add a subagent model enforcement option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delegated subagents normally resolve to the configured subagent default (ai.agent_model_defaults.subagents.default, product default: the fast slot), and that default works. A Task call can override it per invocation, including model_id: "inherit", which pins the child to the parent session's model. That makes it impossible for a user to keep an expensive model on the planning session while guaranteeing every subagent runs on a cheaper one: the planning model decides, and observed sessions show it choosing inherit. Add ai.agent_model_defaults.subagents.enforce_default (runtime peer: ai.agent_model_selections.subagents.enforce_default), default false. When true, every internally created subagent session -- fresh spawns, forked children and reuse/send_input -- resolves to subagents.default, ignoring an explicit model_id, "inherit", the fork default, per-builtin overrides and custom-agent declared models. Approved external immutable bindings keep their approved model and their existing validation error. Resolution goes through one helper shared by the three creation paths, and the flag is exposed in Settings -> 运行设置 as a switch on the existing subagent default model row (en-US / zh-CN / zh-TW). --- .../src/agentic/coordination/coordinator.rs | 523 +++++++++++++++++- .../core/src/service/config/providers.rs | 42 ++ .../core/src/service/config/service.rs | 103 ++++ .../config-contracts/src/model_pool.rs | 60 ++ .../contracts/config-contracts/src/types.rs | 52 ++ .../pages/ai/ExecutionSettingsPage.tsx | 42 ++ .../pages/shared/RuntimeSettings.test.tsx | 71 ++- .../config/services/modelSelections.test.ts | 6 + .../config/services/modelSelections.ts | 3 + .../src/infrastructure/config/types/index.ts | 4 + .../locales/en-US/settings/agentic-tools.json | 5 +- .../locales/zh-CN/settings/agentic-tools.json | 5 +- .../locales/zh-TW/settings/agentic-tools.json | 5 +- 13 files changed, 903 insertions(+), 18 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 25acfb1d0a..e7908ee3a6 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -565,6 +565,30 @@ fn resolve_subagent_model_target( } } +/// Resolve the model target for an internally created (or reused) subagent. +/// +/// `subagents.enforce_default` is the user's hard guarantee that a delegated +/// child never lands on another model: it wins over the Task tool's explicit +/// `model_id`, over `model_id: "inherit"`, over `subagents.fork` and over every +/// per-builtin or custom-agent override. When the flag is off this is exactly +/// [`resolve_subagent_model_target`]. +/// +/// Callers must skip this helper for a session that carries an approved +/// external immutable model binding: such a session keeps its approved model +/// (the fresh path returns before reaching it, the fork and reuse paths guard +/// on the binding policy). +fn resolve_internal_subagent_model_target( + defaults: &AgentModelSelections, + explicit: Option<&str>, + configured: &AgentModelSelection, + parent: Option, +) -> OpenBitFunResult { + if defaults.subagents.enforce_default { + return resolve_subagent_model_target(None, &defaults.subagents.default, parent); + } + resolve_subagent_model_target(explicit, configured, parent) +} + /// Whether a turn belongs to the review phase of a review child session. /// /// Only `CodeReview`/`DeepReview` receive the `deep_review_run_manifest` @@ -12516,7 +12540,8 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .map(AgentModelSelection::from) .unwrap_or(defaults.builtin(agent_type)) }; - let target = resolve_subagent_model_target( + let target = resolve_internal_subagent_model_target( + &defaults, if inherit_parent_model { None } else { @@ -12721,7 +12746,44 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet task_description = text.clone(); } } - let requested_target = if inherit_parent_model { + // A reused child is still an internally created subagent: + // `subagents.enforce_default` also stops `send_input` from + // switching an existing child onto another model. Sessions + // that hold an approved external immutable binding keep + // their own approved model. The probe is best-effort so that + // reading the flag never turns a previously working + // follow-up into a failure. + let enforced_target = match Self::agent_model_defaults().await.ok().as_ref() { + Some(defaults) + if defaults.subagents.enforce_default + && session.config.model_binding_policy + != SessionModelBindingPolicy::ApprovedImmutable => + { + Some( + Self::normalize_model_target( + resolve_internal_subagent_model_target( + defaults, + None, + &defaults.subagents.default, + // An `inherit` default resolves against + // the parent session, exactly like the + // fresh and fork paths: the child may + // still hold the model this flag exists + // to exclude. + Some(Self::model_target_from_config( + &parent_session.config, + defaults, + )?), + )?, + ) + .await?, + ) + } + _ => None, + }; + let requested_target = if let Some(enforced_target) = enforced_target { + Some(enforced_target) + } else if inherit_parent_model { Some( Self::normalize_model_target(self.parent_model_target( &parent_session_id, @@ -12931,18 +12993,33 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } else { defaults.subagents.fork.clone() }; - let target = resolve_subagent_model_target( - if inherit_parent_model { - None - } else { - model_id.as_deref() - }, - &configured, - Some(Self::model_target_from_config( - &snapshot.session_config, + let explicit = if inherit_parent_model { + None + } else { + model_id.as_deref() + }; + let forked_parent_target = + Self::model_target_from_config(&snapshot.session_config, &defaults)?; + // A fork child clones the parent's session config, so it can + // carry an approved external immutable binding and that + // binding's fingerprint. Enforcement must not re-pin it: the + // approved model would then fail the later binding check. + let target = if snapshot.session_config.model_binding_policy + == SessionModelBindingPolicy::ApprovedImmutable + { + resolve_subagent_model_target( + explicit, + &configured, + Some(forked_parent_target), + )? + } else { + resolve_internal_subagent_model_target( &defaults, - )?), - )?; + explicit, + &configured, + Some(forked_parent_target), + )? + }; let target = Self::normalize_model_target(target).await?; let mut session_config = snapshot.build_child_session_config(None); apply_session_model_target(&mut session_config, target); @@ -24371,6 +24448,426 @@ pub(crate) mod tests { .await; } + #[tokio::test] + async fn enforced_subagent_default_wins_over_explicit_inherit_fork_and_builtin_choices() { + assert_subagent_default_enforcement(true).await; + } + + #[tokio::test] + async fn disabled_subagent_default_enforcement_keeps_todays_resolution() { + assert_subagent_default_enforcement(false).await; + } + + /// `subagents.enforce_default` must pin every internally created (or reused) + /// subagent to `subagents.default`; with the flag off every existing rule + /// keeps winning instead. + async fn assert_subagent_default_enforcement(enforce_default: bool) { + let (coordinator, manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().unwrap(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let parent = manager + .create_session( + "Parent".into(), + "Standard".into(), + SessionConfig { + model_id: Some("primary".into()), + workspace_id: Some(record.id), + workspace_path: Some(workspace_path.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + manager + .replace_context_messages( + &parent.session_id, + vec![Message::user("Inspect the workspace".into())], + ) + .await; + let child = coordinator + .create_hidden_agent_session( + None, + "Reused subagent".to_string(), + "Standard".to_string(), + SessionConfig { + model_id: Some("primary".to_string()), + workspace_path: Some(workspace_path), + ..Default::default() + }, + Some(format!("session-{}", parent.session_id)), + SessionKind::Subagent, + ) + .await + .unwrap(); + + let request = |target_session_id: Option<&str>, + fork: bool, + model_id: Option<&str>, + inherit_parent_model: bool| SubagentExecutionRequest { + task_description: "Inspect files".into(), + requested_agent_id: None, + context_mode: if fork { + SubagentContextMode::Fork + } else { + SubagentContextMode::Fresh + }, + target_session_id: target_session_id.map(str::to_string), + subagent_type: if fork || target_session_id.is_some() { + None + } else { + Some("Explore".into()) + }, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: None, + model_id: model_id.map(str::to_string), + inherit_parent_model, + subagent_parent_info: SubagentParentInfo { + session_id: parent.session_id.clone(), + dialog_turn_id: "turn".into(), + tool_call_id: "task".into(), + }, + context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }; + + let mut defaults = AgentModelSelections::default(); + defaults.subagents.default = AgentModelSelection::Model { + model_ref: "fast".into(), + }; + // Both the per-builtin override and the fork default name the model the + // user wants kept away from subagents. + defaults.subagents.builtin.insert( + "Explore".into(), + AgentModelSelection::Model { + model_ref: "primary".into(), + }, + ); + defaults.subagents.fork = AgentModelSelection::Model { + model_ref: "primary".into(), + }; + defaults.subagents.enforce_default = enforce_default; + + let expected = if enforce_default { "fast" } else { "primary" }; + let expected_target = || ModelSelection::Model { + model_ref: expected.into(), + }; + + TEST_AGENT_MODEL_DEFAULTS + .scope(defaults, async { + // (a) An explicit Task model_id is ignored. + assert_eq!( + coordinator + .resolve_fresh_subagent_model_target( + Some("primary"), + false, + "Explore", + &parent.session_id, + ) + .await + .unwrap(), + expected_target() + ); + // (b) `model_id: "inherit"` is ignored. + assert_eq!( + coordinator + .resolve_fresh_subagent_model_target( + None, + true, + "Explore", + &parent.session_id, + ) + .await + .unwrap(), + expected_target() + ); + // (c) The per-builtin override is ignored. + assert_eq!( + coordinator + .resolve_fresh_subagent_model_target( + None, + false, + "Explore", + &parent.session_id, + ) + .await + .unwrap(), + expected_target() + ); + // (d) A fork child ignores `subagents.fork` and its own model_id. + let forked = coordinator + .resolve_hidden_subagent_execution_request(request( + None, + true, + Some("primary"), + false, + )) + .await + .unwrap(); + assert_eq!(forked.session_config.model_id.as_deref(), Some(expected)); + let inherited_fork = coordinator + .resolve_hidden_subagent_execution_request(request(None, true, None, true)) + .await + .unwrap(); + assert_eq!( + inherited_fork.session_config.model_id.as_deref(), + Some(expected) + ); + // (e) `send_input` must not hand an existing child another model. + let reused = coordinator + .resolve_hidden_subagent_execution_request(request( + Some(&child.session_id), + false, + Some("primary"), + false, + )) + .await + .unwrap(); + assert_eq!(reused.session_config.model_id.as_deref(), Some(expected)); + let reused_without_switch = coordinator + .resolve_hidden_subagent_execution_request(request( + Some(&child.session_id), + false, + None, + false, + )) + .await + .unwrap(); + assert_eq!( + reused_without_switch.session_config.model_id.as_deref(), + Some(expected) + ); + assert_eq!( + manager + .get_session(&child.session_id) + .and_then(|session| session.config.model_id.clone()) + .as_deref(), + Some(expected) + ); + }) + .await; + } + + #[tokio::test] + async fn fork_child_with_an_approved_immutable_binding_skips_default_enforcement() { + let (coordinator, manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().unwrap(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; + let workspace_path = workspace.path().to_string_lossy().into_owned(); + // A fork child clones its parent's session config, including this + // approved external immutable binding and its fingerprint. + let parent = manager + .create_session( + "Approved parent".into(), + "Standard".into(), + SessionConfig { + model_id: Some("approved-model".into()), + model_binding_policy: SessionModelBindingPolicy::ApprovedImmutable, + model_binding_fingerprint: Some("approved-fingerprint".into()), + workspace_id: Some(record.id), + workspace_path: Some(workspace_path), + ..Default::default() + }, + ) + .await + .unwrap(); + manager + .replace_context_messages( + &parent.session_id, + vec![Message::user("Inspect the workspace".into())], + ) + .await; + + let mut defaults = AgentModelSelections::default(); + defaults.subagents.default = AgentModelSelection::Model { + model_ref: "fast".into(), + }; + defaults.subagents.fork = AgentModelSelection::Model { + model_ref: "approved-model".into(), + }; + defaults.subagents.enforce_default = true; + + // An approved external binding always names a concrete configured + // model, so the fork target must resolve against a real catalog entry. + use crate::service::config::types::{AIConfig, AIModelConfig}; + let mut ai_config = AIConfig::default(); + ai_config.models = ["approved-model"] + .into_iter() + .map(|id| AIModelConfig { + id: id.into(), + enabled: true, + ..Default::default() + }) + .collect(); + + TEST_MODEL_RESOLUTION_AI_CONFIG + .scope( + ai_config, + TEST_AGENT_MODEL_DEFAULTS.scope(defaults, async { + let forked = coordinator + .resolve_hidden_subagent_execution_request(SubagentExecutionRequest { + task_description: "Inspect files".into(), + requested_agent_id: None, + context_mode: SubagentContextMode::Fork, + target_session_id: None, + subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: None, + model_id: None, + inherit_parent_model: false, + subagent_parent_info: SubagentParentInfo { + session_id: parent.session_id.clone(), + dialog_turn_id: "turn".into(), + tool_call_id: "task".into(), + }, + context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }) + .await + .unwrap(); + // The approved binding keeps its own model: pinning the + // child to `subagents.default` would fail later with + // "Approved model binding changed before execution". + assert_eq!( + forked.session_config.model_id.as_deref(), + Some("approved-model") + ); + assert_ne!(forked.session_config.model_id.as_deref(), Some("fast")); + assert_eq!( + forked.session_config.model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ); + assert_eq!( + forked.session_config.model_binding_fingerprint.as_deref(), + Some("approved-fingerprint") + ); + }), + ) + .await; + } + + #[tokio::test] + async fn enforced_inherit_default_resolves_a_reused_child_against_the_parent_model() { + let (coordinator, manager) = test_persistent_coordinator(); + let workspace = tempfile::tempdir().unwrap(); + let record = crate::service::workspace::legacy_compat::register_local_fixture( + workspace.path(), + None, + ) + .await; + let workspace_path = workspace.path().to_string_lossy().into_owned(); + let parent = manager + .create_session( + "Parent".into(), + "Standard".into(), + SessionConfig { + model_id: Some("primary".into()), + workspace_id: Some(record.id), + workspace_path: Some(workspace_path.clone()), + ..Default::default() + }, + ) + .await + .unwrap(); + let child = coordinator + .create_hidden_agent_session( + None, + "Expensive reused subagent".to_string(), + "Standard".to_string(), + SessionConfig { + model_id: Some("expensive-model".to_string()), + workspace_path: Some(workspace_path), + ..Default::default() + }, + Some(format!("session-{}", parent.session_id)), + SessionKind::Subagent, + ) + .await + .unwrap(); + + let mut defaults = AgentModelSelections::default(); + defaults.subagents.default = AgentModelSelection::Inherit; + defaults.subagents.enforce_default = true; + + // The child's previous model is a concrete catalog entry, so resolving + // it against the child must succeed before it can be detected. + use crate::service::config::types::{AIConfig, AIModelConfig}; + let mut ai_config = AIConfig::default(); + ai_config.models = ["expensive-model"] + .into_iter() + .map(|id| AIModelConfig { + id: id.into(), + enabled: true, + ..Default::default() + }) + .collect(); + + TEST_MODEL_RESOLUTION_AI_CONFIG + .scope( + ai_config, + TEST_AGENT_MODEL_DEFAULTS.scope(defaults, async { + let reused = coordinator + .resolve_hidden_subagent_execution_request(SubagentExecutionRequest { + task_description: "Continue the investigation".into(), + requested_agent_id: None, + context_mode: SubagentContextMode::Fresh, + target_session_id: Some(child.session_id.clone()), + subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + workspace_path: None, + model_id: None, + inherit_parent_model: false, + subagent_parent_info: SubagentParentInfo { + session_id: parent.session_id.clone(), + dialog_turn_id: "turn".into(), + tool_call_id: "task".into(), + }, + context: HashMap::new(), + permission_runtime_ceiling: PermissionRuntimeCeiling::default(), + delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, + }) + .await + .unwrap(); + // `inherit` resolves against the parent session, exactly + // like the fresh and fork paths; resolving it against the + // child would keep the expensive model the flag exists to + // exclude. + assert_eq!(reused.session_config.model_id.as_deref(), Some("primary")); + assert_ne!( + reused.session_config.model_id.as_deref(), + Some("expensive-model") + ); + assert_eq!( + manager + .get_session(&child.session_id) + .and_then(|session| session.config.model_id.clone()) + .as_deref(), + Some("primary") + ); + }), + ) + .await; + } + #[tokio::test] async fn fresh_subagent_request_can_explicitly_inherit_parent_model() { let (coordinator, session_manager) = test_coordinator(); diff --git a/src/crates/assembly/core/src/service/config/providers.rs b/src/crates/assembly/core/src/service/config/providers.rs index 087787cdf6..8b2405c700 100644 --- a/src/crates/assembly/core/src/service/config/providers.rs +++ b/src/crates/assembly/core/src/service/config/providers.rs @@ -1012,6 +1012,48 @@ mod tests { assert!(error.to_string().contains("agent_model_defaults.mode")); } + #[tokio::test] + async fn enforced_subagent_default_flag_validates_and_is_never_dropped() { + // The legacy surface only validates while no runtime selections exist. + let mut config = AIConfig::default(); + config.agent_model_defaults.subagents.enforce_default = true; + let value = serde_json::to_value(&config).unwrap(); + assert_eq!( + value["agent_model_defaults"]["subagents"]["enforce_default"], + serde_json::json!(true) + ); + AIConfigProvider + .validate_config(&value) + .await + .expect("a boolean enforcement flag has nothing to validate"); + let round_tripped: AIConfig = serde_json::from_value(value).unwrap(); + assert!(round_tripped.agent_model_defaults.subagents.enforce_default); + + // The runtime surface keeps the flag through validation as well. + let mut selections = + openbitfun_config_contracts::model_pool::AgentModelSelections::default(); + selections.subagents.enforce_default = true; + let mut config = AIConfig::default(); + config.set_model_selections(selections).unwrap(); + let value = serde_json::to_value(&config).unwrap(); + assert_eq!( + value["agent_model_selections"]["subagents"]["enforce_default"], + serde_json::json!(true) + ); + AIConfigProvider + .validate_config(&value) + .await + .expect("the runtime selections remain valid"); + let round_tripped: AIConfig = serde_json::from_value(value).unwrap(); + assert!( + round_tripped + .effective_model_selections() + .unwrap() + .subagents + .enforce_default + ); + } + #[tokio::test] async fn rejects_invalid_canonical_reasoning_actions() { for (action, expected) in [ diff --git a/src/crates/assembly/core/src/service/config/service.rs b/src/crates/assembly/core/src/service/config/service.rs index 0a5c45edb8..a0e4dfeaa1 100644 --- a/src/crates/assembly/core/src/service/config/service.rs +++ b/src/crates/assembly/core/src/service/config/service.rs @@ -137,6 +137,17 @@ fn translate_legacy_model_selection_write( SubagentModelSelection, >(value)?))? } + // A raw boolean is not expressible as a legacy model selection, so it is + // passed through unchanged instead of being projected through the enum. + ".subagents.enforce_default" => { + if value.is_boolean() { + value + } else { + return Err(OpenBitFunError::validation( + "ai.agent_model_defaults.subagents.enforce_default must be a boolean", + )); + } + } _ if suffix.starts_with(".subagents.builtin.") => { if value.is_null() { value @@ -1262,6 +1273,98 @@ mod tests { ); } + #[tokio::test] + async fn legacy_subagent_enforce_default_write_lands_on_runtime_selections() { + let (service, dir) = test_service("subagent-enforce-default").await; + service + .add_ai_model(runtime_model("chosen", "fixture-key")) + .await + .unwrap(); + let legacy_before: serde_json::Value = service + .get_config(Some("ai.agent_model_defaults")) + .await + .unwrap(); + assert!(!service + .get_config::(Some("ai.agent_model_selections.subagents.enforce_default")) + .await + .unwrap()); + + assert!(service + .set_config_if_changed( + "ai.agent_model_defaults.subagents.enforce_default", + serde_json::json!(true), + ) + .await + .unwrap()); + assert!(service + .get_config::(Some("ai.agent_model_selections.subagents.enforce_default")) + .await + .unwrap()); + let selections: openbitfun_config_contracts::model_pool::AgentModelSelections = service + .get_config(Some("ai.agent_model_selections")) + .await + .unwrap(); + assert!(selections.subagents.enforce_default); + // The frozen legacy surface is read-only and must not be rewritten. + assert_eq!( + service + .get_config::(Some("ai.agent_model_defaults")) + .await + .unwrap(), + legacy_before + ); + + // Whole-object writes carry the flag through the same translation. + service + .set_config( + "ai.agent_model_defaults.subagents", + serde_json::json!({ + "default": {"kind": "fixed", "model_id": "chosen"}, + "builtin": {}, + "fork": {"kind": "inherit"}, + "enforce_default": true, + }), + ) + .await + .unwrap(); + let selections: openbitfun_config_contracts::model_pool::AgentModelSelections = service + .get_config(Some("ai.agent_model_selections")) + .await + .unwrap(); + assert!(selections.subagents.enforce_default); + assert_eq!( + selections.subagents.default, + openbitfun_config_contracts::model_pool::AgentModelSelection::Model { + model_ref: "chosen".into() + } + ); + + // The flag survives a restart and clearing it restores today's rules. + let restarted = restart_test_service(&dir, "subagent-enforce-default").await; + assert!(restarted + .get_config::(Some("ai.agent_model_selections.subagents.enforce_default")) + .await + .unwrap()); + restarted + .set_config( + "ai.agent_model_defaults.subagents.enforce_default", + serde_json::json!(false), + ) + .await + .unwrap(); + assert!(!restarted + .get_config::(Some("ai.agent_model_selections.subagents.enforce_default")) + .await + .unwrap()); + assert!(service + .set_config( + "ai.agent_model_defaults.subagents.enforce_default", + serde_json::json!("true"), + ) + .await + .is_err()); + } + fn realtime_voice_fixture() -> VoiceCallConfig { VoiceCallConfig { api_key: "fixture-realtime-voice-key".to_string(), diff --git a/src/crates/contracts/config-contracts/src/model_pool.rs b/src/crates/contracts/config-contracts/src/model_pool.rs index 5cc8a27231..5814f2d9eb 100644 --- a/src/crates/contracts/config-contracts/src/model_pool.rs +++ b/src/crates/contracts/config-contracts/src/model_pool.rs @@ -95,6 +95,10 @@ pub struct SubagentModelSelections { pub default: AgentModelSelection, pub builtin: BTreeMap, pub fork: AgentModelSelection, + /// Mirrors `ai.agent_model_defaults.subagents.enforce_default`: pin every + /// internally created subagent to `default`. Missing data means `false`. + #[serde(default)] + pub enforce_default: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -163,6 +167,7 @@ impl Default for AgentModelSelections { AgentModelSelection::Inherit, )]), fork: AgentModelSelection::Inherit, + enforce_default: false, }, } } @@ -183,6 +188,7 @@ impl AgentModelSelections { .map(|(id, choice)| (id.clone(), choice.clone().into())) .collect(), fork: legacy.subagents.fork.clone().into(), + enforce_default: legacy.subagents.enforce_default, }, } } @@ -330,6 +336,60 @@ mod tests { ); } + #[test] + fn subagent_enforce_default_is_absent_by_default_and_round_trips() { + // A document written before the field existed must deserialize to false. + let legacy = json!({ + "mode": {"kind": "model", "model_ref": "primary"}, + "subagents": { + "default": {"kind": "model", "model_ref": "fast"}, + "builtin": {}, + "fork": {"kind": "inherit"} + } + }); + let selections: AgentModelSelections = serde_json::from_value(legacy.clone()).unwrap(); + assert!(!selections.subagents.enforce_default); + assert_eq!( + serde_json::to_value(&selections).unwrap()["subagents"]["enforce_default"], + json!(false) + ); + + let mut explicit = selections.clone(); + explicit.subagents.enforce_default = true; + let saved = serde_json::to_value(&explicit).unwrap(); + assert_eq!(saved["subagents"]["enforce_default"], json!(true)); + assert_eq!( + serde_json::from_value::(saved).unwrap(), + explicit + ); + + // An explicit `false` is still a valid document and keeps today's rules. + let mut disabled = legacy; + disabled["subagents"]["enforce_default"] = json!(false); + assert!( + !serde_json::from_value::(disabled) + .unwrap() + .subagents + .enforce_default + ); + } + + #[test] + fn from_legacy_carries_the_enforced_subagent_default_flag() { + for enforce_default in [false, true] { + let mut legacy = crate::types::AIConfig::default(); + legacy.agent_model_defaults.subagents.enforce_default = enforce_default; + let migrated = AgentModelSelections::from_legacy(&legacy.agent_model_defaults); + assert_eq!(migrated.subagents.enforce_default, enforce_default); + assert_eq!( + migrated.subagents.default, + AgentModelSelection::Model { + model_ref: "fast".into() + } + ); + } + } + #[test] fn malformed_new_selection_is_retained_and_never_read_as_legacy() { for malformed in [json!(null), json!({}), json!({"mode":{"kind":"future"}})] { diff --git a/src/crates/contracts/config-contracts/src/types.rs b/src/crates/contracts/config-contracts/src/types.rs index 884b96f659..2be03002b1 100644 --- a/src/crates/contracts/config-contracts/src/types.rs +++ b/src/crates/contracts/config-contracts/src/types.rs @@ -1066,6 +1066,10 @@ pub struct SubagentModelDefaultsConfig { pub builtin: HashMap, /// Default choice for a child created from the parent's context. pub fork: SubagentModelSelection, + /// When true every internally created subagent uses `default`, ignoring + /// explicit requests, `fork` and per-builtin overrides. Absent means false. + #[serde(default)] + pub enforce_default: bool, } impl Default for SubagentModelDefaultsConfig { @@ -1077,6 +1081,7 @@ impl Default for SubagentModelDefaultsConfig { SubagentModelSelection::Inherit, )]), fork: SubagentModelSelection::Inherit, + enforce_default: false, } } } @@ -4137,6 +4142,53 @@ mod tests { ); } + #[test] + fn subagent_enforce_default_defaults_off_and_round_trips() { + assert!(!super::SubagentModelDefaultsConfig::default().enforce_default); + assert!( + !AgentModelDefaultsConfig::default() + .subagents + .enforce_default + ); + + // Absent field (older configuration) keeps today's resolution rules. + for payload in [ + serde_json::json!({}), + serde_json::json!({"subagents": {}}), + serde_json::json!({"subagents": {"default": {"kind": "inherit"}}}), + ] { + let config: AgentModelDefaultsConfig = serde_json::from_value(payload).unwrap(); + assert!(!config.subagents.enforce_default); + } + + let config: AgentModelDefaultsConfig = serde_json::from_value(serde_json::json!({ + "subagents": {"enforce_default": true} + })) + .unwrap(); + assert!(config.subagents.enforce_default); + let saved = serde_json::to_value(&config).unwrap(); + assert_eq!( + saved["subagents"]["enforce_default"], + serde_json::json!(true) + ); + let reloaded: AgentModelDefaultsConfig = serde_json::from_value(saved).unwrap(); + assert!(reloaded.subagents.enforce_default); + + // A damaged value must stay an error instead of silently enforcing. + for invalid in [ + serde_json::json!("true"), + serde_json::Value::Null, + serde_json::json!(1), + ] { + assert!( + serde_json::from_value::(serde_json::json!({ + "subagents": {"enforce_default": invalid} + })) + .is_err() + ); + } + } + #[test] fn code_font_defaults_follow_frontend_and_preserve_explicit_legacy_fonts() { let editor: EditorConfig = serde_json::from_str("{}").unwrap(); diff --git a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx index a8d0f49d3e..5cc3ec2e9d 100644 --- a/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx +++ b/src/web-ui/src/app/scenes/settings/pages/ai/ExecutionSettingsPage.tsx @@ -77,6 +77,7 @@ const ExecutionSettingsPage: React.FC = () => { } const hasSeededPageData = seed.loaded && Boolean(seededSelections); const [subagentDefaultModel, setSubagentDefaultModel] = useState(() => seededSelections?.subagents.default ?? { kind: 'inherit' }); + const [enforceSubagentDefaultModel, setEnforceSubagentDefaultModel] = useState(() => seededSelections?.subagents.enforce_default === true); const [modelPools, setModelPools] = useState(() => { const document = seed.get('ai.model_pools', null); return document ? readableModelPools(document) : []; @@ -224,6 +225,31 @@ const ExecutionSettingsPage: React.FC = () => { } }; + const handleSubagentModelEnforcementChange = async (checked: boolean) => { + const scope = getActiveSurfaceScope(); + const previousEnforcement = enforceSubagentDefaultModel; + setEnforceSubagentDefaultModel(checked); + setToolExecConfigLoading(true); + try { + if (modelSelectionsConfigPath() === 'ai.agent_model_selections') { + await configManager.setConfig('ai.agent_model_selections.subagents.enforce_default', checked); + } else { + await configManager.setConfig('ai.agent_model_defaults.subagents.enforce_default', checked); + } + if (!scope.isCurrent()) return; + notificationService.success(tTools('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + if (!scope.isCurrent()) return; + log.error('Failed to save subagent model enforcement', error); + setEnforceSubagentDefaultModel(previousEnforcement); + notificationService.error( + `${tTools('messages.saveFailed')}: ${error instanceof Error ? error.message : String(error)}` + ); + } finally { + if (scope.isCurrent()) setToolExecConfigLoading(false); + } + }; + const handleSwarmMaxConcurrencyChange = async (input: number) => { if (!Number.isFinite(input)) return; const value = Math.min(SWARM_MAX_CONCURRENCY_LIMIT, Math.max(1, Math.round(input))); @@ -332,6 +358,7 @@ const ExecutionSettingsPage: React.FC = () => { supportsModelPools() ? configManager.getOptionalConfig('ai.model_pools') : Promise.resolve(undefined), ]); setSubagentDefaultModel(loadedSubagentDefaultModel.subagents.default); + setEnforceSubagentDefaultModel(loadedSubagentDefaultModel.subagents.enforce_default === true); setModelPools(poolDocument ? readableModelPools(poolDocument) : []); setConfiguredModels(loadedModels ?? []); setEnableDeferredToolLoading(deferredToolLoadingEnabled ?? true); @@ -512,6 +539,21 @@ const ExecutionSettingsPage: React.FC = () => { /> + +
+ void handleSubagentModelEnforcementChange(event.target.checked)} + disabled={toolExecConfigLoading} + /> +
+
{ Object.defineProperty(window, '__TAURI__', { configurable: true, value: {} }); return { - invoke: vi.fn(), setConfig: vi.fn(), setEnabled: vi.fn(), getConfig: vi.fn(), error: vi.fn(), + invoke: vi.fn(), setConfig: vi.fn(), setEnabled: vi.fn(), getConfig: vi.fn(), getOptionalConfig: vi.fn(), error: vi.fn(), t: (key: string) => key, workspace: null as { workspaceKind: string } | null, }; }); + +/** Same fixture the previous inline mock returned for the runtime model-selection path. */ +const defaultOptionalConfig = async (path: string) => path === 'ai.agent_model_selections' + ? { mode: { kind: 'model', model_ref: 'primary' }, subagents: { default: { kind: 'model', model_ref: 'fast' }, builtin: {}, fork: { kind: 'inherit' } } } + : true; vi.mock('@/infrastructure/config/hooks', () => ({ useAIExperienceSettings: () => ({ settings: { enable_workspace_search: true }, isLoading: false, error: null, reload: vi.fn() }) })); vi.mock('@/infrastructure/api', () => ({ globalAPI: {}, workspaceAPI: {} })); vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspace: mocks.workspace }) })); @@ -26,7 +32,7 @@ vi.mock('@/infrastructure/i18n', () => ({ vi.mock('@/infrastructure/api/service-api/ApiClient', () => ({ api: { invoke: mocks.invoke } })); vi.mock('@/infrastructure/api/service-api/SystemAPI', () => ({ systemAPI: { getSystemInfo: async () => ({ platform: 'macos' }) } })); vi.mock('../../../../../infrastructure/config/services/ConfigManager', () => ({ configManager: { hasCachedConfig: () => false, getCachedConfig: () => undefined, - getConfig: mocks.getConfig, getOptionalConfig: async (path: string) => path === 'ai.agent_model_selections' ? { mode: { kind: 'model', model_ref: 'primary' }, subagents: { default: { kind: 'model', model_ref: 'fast' }, builtin: {}, fork: { kind: 'inherit' } } } : true, setConfig: mocks.setConfig, + getConfig: mocks.getConfig, getOptionalConfig: mocks.getOptionalConfig, setConfig: mocks.setConfig, } })); vi.mock('../../../../../infrastructure/config/hooks/useComputerUseEnabled', () => ({ useComputerUseEnabled: () => ({ computerUseEnabled: false, setComputerUseEnabled: mocks.setEnabled }) })); vi.mock('../../../../../infrastructure/config/services/AIExperienceConfigService', () => ({ aiExperienceConfigService: { getCachedSettings: () => null, getSettingsAsync: async () => ({ enable_workspace_search: true }) } })); @@ -68,6 +74,7 @@ beforeEach(() => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; status = { ...readyStatus }; mocks.getConfig.mockReset().mockResolvedValue(null); + mocks.getOptionalConfig.mockReset().mockImplementation(defaultOptionalConfig); mocks.setConfig.mockReset().mockResolvedValue(undefined); mocks.error.mockReset(); mocks.invoke.mockReset().mockImplementation(async (command: string) => { @@ -304,3 +311,63 @@ describe('Flashgrep settings availability', () => { expect(container.textContent?.includes('features.workspaceSearch.enable')).toBe(workspaceKind === 'normal'); }); }); + +describe('Subagent model enforcement', () => { + const enforcementRow = () => Array.from(container.querySelectorAll('[data-openbitfun-part="row"]')) + .find(row => row.querySelector('[data-openbitfun-part="rowLabel"]')?.textContent === 'config.subagentModelEnforcement')!; + const enforceSwitch = () => enforcementRow().querySelector('input[role="switch"]')!; + const rowDescription = () => enforcementRow().querySelector('[data-openbitfun-part="rowDescription"]')!.textContent; + + function runtimeSelections(subagents: Record) { + return { mode: { kind: 'model', model_ref: 'primary' }, subagents: { default: { kind: 'model', model_ref: 'fast' }, builtin: {}, fork: { kind: 'inherit' }, ...subagents } }; + } + + it('renders the stored state and writes the runtime path on a local host', async () => { + mocks.getOptionalConfig.mockImplementation(async (path: string) => path === 'ai.agent_model_selections' + ? runtimeSelections({ enforce_default: true }) + : defaultOptionalConfig(path)); + await act(async () => root.render()); + expect(enforceSwitch().checked).toBe(true); + expect(rowDescription()).toContain('config.subagentModelEnforcementDesc'); + mocks.setConfig.mockClear(); + + await act(async () => enforceSwitch().click()); + expect(mocks.setConfig).toHaveBeenCalledExactlyOnceWith('ai.agent_model_selections.subagents.enforce_default', false); + expect(enforceSwitch().checked).toBe(false); + expect(mocks.error).not.toHaveBeenCalled(); + }); + + it('restores the switch and reports the host error when the write is rejected', async () => { + mocks.setConfig.mockRejectedValue(new Error('Host rejected the subagent policy')); + await act(async () => root.render()); + await act(async () => enforceSwitch().click()); + expect(mocks.error).toHaveBeenCalledWith('messages.saveFailed: Host rejected the subagent policy'); + expect(enforceSwitch().checked).toBe(false); + }); + + it('reads and writes the legacy typed path on a host without runtime selections', async () => { + await act(async () => activateSurface('peer-legacy')); + try { + mocks.getOptionalConfig.mockImplementation(async (path: string) => path === 'ai.agent_model_defaults' + ? { mode: 'primary', subagents: { default: { kind: 'fixed', model_id: 'fast' }, builtin: {}, fork: { kind: 'inherit' }, enforce_default: true } } + : defaultOptionalConfig(path)); + await act(async () => root.render()); + expect(enforceSwitch().checked).toBe(true); + + await act(async () => enforceSwitch().click()); + expect(mocks.setConfig).toHaveBeenCalledExactlyOnceWith('ai.agent_model_defaults.subagents.enforce_default', false); + expect(mocks.error).not.toHaveBeenCalled(); + } finally { + await act(async () => activateSurface('local')); + } + }); + + it('warns when enforcement would leave subagents on the session model', async () => { + mocks.getOptionalConfig.mockImplementation(async (path: string) => path === 'ai.agent_model_selections' + ? runtimeSelections({ default: { kind: 'inherit' }, enforce_default: true }) + : defaultOptionalConfig(path)); + await act(async () => root.render()); + expect(enforceSwitch().checked).toBe(true); + expect(rowDescription()).toContain('config.subagentModelEnforcementInheritWarning'); + }); +}); diff --git a/src/web-ui/src/infrastructure/config/services/modelSelections.test.ts b/src/web-ui/src/infrastructure/config/services/modelSelections.test.ts index a3a10a2005..5193c51dc3 100644 --- a/src/web-ui/src/infrastructure/config/services/modelSelections.test.ts +++ b/src/web-ui/src/infrastructure/config/services/modelSelections.test.ts @@ -13,8 +13,14 @@ describe('typed model preferences', () => { expect(result.mode).toEqual({ kind: 'model', model_ref: 'a' }); expect(result.subagents.default).toEqual({ kind: 'model', model_ref: 'fast' }); expect(result.subagents.builtin.ResearchSpecialist).toEqual({ kind: 'inherit' }); + expect(result.subagents.enforce_default).toBe(false); expect(legacy.mode).toBe('a'); }); + it('carries the legacy enforcement flag next to the converted selection', () => { + const enforced: AgentModelDefaultsConfig = { ...legacy, subagents: { ...legacy.subagents, enforce_default: true } }; + expect(effectiveModelSelections(enforced, 'ai.agent_model_defaults').subagents.enforce_default).toBe(true); + expect(legacy.subagents.enforce_default).toBeUndefined(); + }); it('rejects malformed selections and read failures instead of falling back', async () => { expect(() => effectiveModelSelections({} as any)).toThrow('Invalid'); mocks.get.mockRejectedValue(new Error('offline')); diff --git a/src/web-ui/src/infrastructure/config/services/modelSelections.ts b/src/web-ui/src/infrastructure/config/services/modelSelections.ts index b579bb8b1b..338c19c4bc 100644 --- a/src/web-ui/src/infrastructure/config/services/modelSelections.ts +++ b/src/web-ui/src/infrastructure/config/services/modelSelections.ts @@ -31,6 +31,9 @@ export function effectiveModelSelections(config?: AgentModelDefaultsConfig | Age default: convert(defaults?.subagents?.default ?? { kind: 'fixed', model_id: 'fast' }), builtin: Object.fromEntries(Object.entries(defaults?.subagents?.builtin ?? {}).map(([id, choice]) => [id, convert(choice)])), fork: convert(defaults?.subagents?.fork ?? { kind: 'inherit' }), + // The host copies this flag when it migrates the legacy typed path; mirror + // that so readers see the enforcement state on both dialects. + enforce_default: defaults?.subagents?.enforce_default === true, }, }; } diff --git a/src/web-ui/src/infrastructure/config/types/index.ts b/src/web-ui/src/infrastructure/config/types/index.ts index e64b01be6f..96c23dbd01 100644 --- a/src/web-ui/src/infrastructure/config/types/index.ts +++ b/src/web-ui/src/infrastructure/config/types/index.ts @@ -474,6 +474,8 @@ export interface AgentModelSelections { default: AgentModelTargetSelection; builtin: Record; fork: AgentModelTargetSelection; + /** `ai.agent_model_selections.subagents.enforce_default`; absent means false. */ + enforce_default?: boolean; }; } @@ -483,6 +485,8 @@ export interface AgentModelDefaultsConfig { default: SubagentModelSelection; builtin: Record; fork: SubagentModelSelection; + /** `ai.agent_model_defaults.subagents.enforce_default`; absent means false. */ + enforce_default?: boolean; }; } diff --git a/src/web-ui/src/locales/en-US/settings/agentic-tools.json b/src/web-ui/src/locales/en-US/settings/agentic-tools.json index 33d06d81b1..17bd875b19 100644 --- a/src/web-ui/src/locales/en-US/settings/agentic-tools.json +++ b/src/web-ui/src/locales/en-US/settings/agentic-tools.json @@ -42,7 +42,10 @@ "subagentModelSettings": "Go to the Agents page to set models for individual subagents", "subagentModelInherit": "Inherit parent session", "subagentDefaultModel": "Default subagent model", - "subagentDefaultModelDesc": "Used by subagents without an individual model override." + "subagentDefaultModelDesc": "Used by subagents without an individual model override.", + "subagentModelEnforcement": "Always use this model for subagents", + "subagentModelEnforcementDesc": "When on, every subagent, including forked child sessions, is locked to the model above: the session's own model and any model requested by a task are ignored", + "subagentModelEnforcementInheritWarning": "The model above is set to inherit the parent session, so subagents would still follow the session model. Choose a specific model first" }, "filters": { "all": "All", diff --git a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json index 58e046be99..b95ba98153 100644 --- a/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-CN/settings/agentic-tools.json @@ -42,7 +42,10 @@ "subagentModelSettings": "前往智能体页面为子智能体单独设置模型", "subagentModelInherit": "继承父会话", "subagentDefaultModel": "子智能体默认模型", - "subagentDefaultModelDesc": "未单独指定模型的子智能体使用此设置。" + "subagentDefaultModelDesc": "未单独指定模型的子智能体使用此设置。", + "subagentModelEnforcement": "强制子智能体固定使用该模型", + "subagentModelEnforcementDesc": "开启后,所有子智能体(含分叉出的子会话)都固定使用上方选择的模型,会话自身模型和任务级模型请求都会被忽略", + "subagentModelEnforcementInheritWarning": "上方选择的是“继承父会话”,开启后子智能体仍会跟随会话模型。请先改选一个具体模型" }, "filters": { "all": "全部", diff --git a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json index 4a4be6da39..6363a2638b 100644 --- a/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json +++ b/src/web-ui/src/locales/zh-TW/settings/agentic-tools.json @@ -42,7 +42,10 @@ "subagentModelSettings": "前往智慧體頁面為子智慧體個別設定模型", "subagentModelInherit": "繼承父會話", "subagentDefaultModel": "子智能體預設模型", - "subagentDefaultModelDesc": "未個別指定模型的子智慧體使用此設定。" + "subagentDefaultModelDesc": "未個別指定模型的子智慧體使用此設定。", + "subagentModelEnforcement": "強制子智慧體固定使用該模型", + "subagentModelEnforcementDesc": "開啟後,所有子智慧體(含分叉出的子會話)都固定使用上方選擇的模型,會話自身模型與任務級模型請求都會被忽略", + "subagentModelEnforcementInheritWarning": "上方選擇的是「繼承父會話」,開啟後子智慧體仍會跟隨會話模型。請先改選一個具體模型" }, "filters": { "all": "全部",