//! Applies a model switch to a session — the ungated path. `set_session_model` //! enforces the `allowed_models` gate before delegating here; internal callers //! (`new_session`, `load_session`) call `apply` directly. use crate::agent::config; use crate::agent::mvp_agent::{ MvpAgent, agent_name_after_model_switch, harnesses_are_compatible, resolve_required_agent_type, }; use crate::session::SessionCommand; use agent_client_protocol::{self as acp}; use kigi_sampling_types::parse_reasoning_effort_meta; use tokio::sync::oneshot; /// Apply a model switch to a session (no gate — `set_session_model` gates first). pub(crate) async fn apply( agent: &MvpAgent, args: acp::SetSessionModelRequest, ) -> Result { tracing::info!("Received set session model request {args:?}"); kigi_log::unified_log::info( "model changed", Some(args.session_id.0.as_ref()), Some(serde_json::json!({ "model" : args.model_id.0.as_ref() })), ); tracing::debug!("session_session_model::mvp_agent: {:?}", &args); let effort_override = parse_reasoning_effort_meta(args.meta.as_ref()); let acp::SetSessionModelRequest { session_id, model_id, .. } = args; let handle = agent .session_handle_waiting_for_load(&session_id) .await .ok_or_else(|| acp::Error::invalid_params().data("unknown session id"))?; let model = agent.resolve_model_id(&model_id)?; let use_concise = model.info().use_concise; let session_default = handle .session_default_agent_profile .as_deref() .unwrap_or(&handle.agent_name); let required_agent_type = resolve_required_agent_type(Some(model.info().agent_type.as_str()), session_default); let previous_model_id = handle.model_id.0.clone(); let mut pending_rebuild_definition: Option = None; { let required = &required_agent_type; let turn_count = handle .signals_handle .snapshot() .await .map(|s| s.turn_count) .unwrap_or(0); let (agent_tx, agent_rx) = oneshot::channel(); let _ = handle.cmd_tx.send(SessionCommand::GetActiveAgent { responds_to: agent_tx, }); let active_agent_type = agent_rx.await.ok().flatten(); let is_mismatch = active_agent_type .as_ref() .is_some_and(|active| !harnesses_are_compatible(active, required)); tracing::info!( session_id = % session_id.0, model_id = % model_id.0, ? required_agent_type, ? active_agent_type, turn_count, is_mismatch, "set_session_model: agent type compatibility check" ); if is_mismatch && turn_count > 0 { tracing::warn!( session_id = % session_id.0, model_id = % model_id.0, active_agent = ? active_agent_type, required_agent = % required, turn_count, "set_session_model: agent type mismatch rejected" ); let err_payload = config::ModelSwitchIncompatibleAgentError { code: config::MODEL_SWITCH_INCOMPATIBLE_AGENT.to_string(), active_agent_type: active_agent_type.unwrap_or_else(|| "unknown".to_owned()), required_agent_type: required.clone(), model_id: model_id.0.to_string(), suggestion: "start_new_session".to_string(), }; return Err(err_payload.into_acp_error()); } if is_mismatch && turn_count == 0 { let cwd = handle.tool_context.cwd.as_path(); let resolved = kigi_agent::discovery::by_name_in_cwd_with_plugins( required, cwd, agent.plugin_registry_handle.snapshot().as_deref(), ); match resolved { Some(def) => { tracing::info!( session_id = % session_id.0, model_id = % model_id.0, required_agent_type = % required, agent_def_name = % def.name, "set_session_model: zero-turn harness switch — queued agent rebuild" ); pending_rebuild_definition = Some(def); } None => { tracing::warn!( session_id = % session_id.0, model_id = % model_id.0, required_agent_type = % required, "set_session_model: zero-turn harness switch — could not resolve agent definition; proceeding with stale harness" ); } } } } let mut model_sampling = agent.prepare_sampling_config_for_model(&model, handle.origin_client.clone()); if let Some(eff) = effort_override { if agent .models_manager .model_supports_reasoning_effort(model_id.0.as_ref()) { // Legacy migration: pre-split sessions persisted canonical // `xhigh` for models whose live menu now spells the top tier // `max` (K3). The wire is identical either way (kimi_compat // renames xhigh→max), but the menu has no xhigh-valued row, so // display/active-row would drift from the model vocabulary and // the stale token would be re-persisted forever. Migrate once. let eff = if eff == kigi_sampling_types::ReasoningEffort::Xhigh && !agent .models_manager .model_offers_effort(model_id.0.as_ref(), eff) && agent.models_manager.model_offers_effort( model_id.0.as_ref(), kigi_sampling_types::ReasoningEffort::Max, ) { tracing::info!( session_id = % session_id.0, "set_session_model: migrating legacy xhigh override to max \ (model menu offers max, not xhigh)" ); kigi_sampling_types::ReasoningEffort::Max } else { eff }; tracing::info!( session_id = % session_id.0, effort = % eff, "set_session_model: applying reasoning_effort override from meta" ); model_sampling.reasoning_effort = Some(eff); } else { tracing::warn!( session_id = % session_id.0, model_id = % model_id.0, effort = % eff, "set_session_model: ignoring reasoning_effort override — model does not support it" ); } } let applied_effort = model_sampling.reasoning_effort; let gate_closed = !handle .gateway_enabled .load(std::sync::atomic::Ordering::Relaxed); let apply_prompt_override = !gate_closed; if gate_closed { tracing::info!( session_id = % session_id.0, model_id = % model_id.0, "set_session_model: gateway gate closed, prompt override suppressed" ); pending_rebuild_definition = None; } let did_rebuild = if let Some(def) = pending_rebuild_definition { let (rebuild_tx, rebuild_rx) = oneshot::channel(); let _ = handle .cmd_tx .send(SessionCommand::RebuildAgentForDefinition { definition: def, responds_to: rebuild_tx, }); let rebuild_result = rebuild_rx .await .map_err(|_| acp::Error::internal_error().data("rebuild_agent: actor closed"))?; match rebuild_result { Ok(()) => true, Err(e) => { tracing::error!( session_id = % session_id.0, model_id = % model_id.0, error = ? e, "set_session_model: zero-turn harness rebuild failed; aborting model switch" ); return Err(e); } } } else { false }; let model_unchanged = previous_model_id == model_id.0; let new_threshold = { let cfg = agent.cfg.borrow(); let models = agent.models_manager.models(); let model = config::find_model_by_id(&models, model_sampling.model.as_str()); crate::util::config::resolve_auto_compact_threshold_percent( &cfg, model_sampling.model.as_str(), model.map(|e| &e.info), ) }; // H4: hand the session the catalog KEY the picker actually resolved. The // slug in `model_sampling.model` cannot distinguish `xai/grok-*` from // `xai-grok/grok-*` (duplicate ids across an API-key platform and its // subscription-OAuth twin are by design), and the process-global // `current_model_id()` below is not written at all in Leader mode. let catalog_key = crate::agent::models::resolve_catalog_key(&agent.models_manager.models(), &model_id) .map(|k| k.0.to_string()); let (tx, rx) = oneshot::channel(); let _ = handle.cmd_tx.send(SessionCommand::SetSessionModel { sampling_config: model_sampling, catalog_key, use_concise, apply_prompt_override, skip_prompt_rewrite: did_rebuild || model_unchanged, auto_compact_threshold_percent: new_threshold, responds_to: tx, }); let updated_model = rx .await .map_err(|_| acp::Error::internal_error().data("failed to set session model"))?; if let Some(handle) = agent.sessions.borrow_mut().get_mut(&session_id) { handle.model_id = model_id.clone(); handle.reasoning_effort = applied_effort; handle.agent_name = agent_name_after_model_switch(did_rebuild, &required_agent_type, &handle.agent_name); } broadcast_model_changed( agent, &session_id, model_id.0.as_ref(), applied_effort.map(|eff| eff.to_string()), ); if agent.cfg.borrow().mode != config::AgentMode::Leader { agent.models_manager.set_current_model_id(model_id); agent .models_manager .set_current_reasoning_effort(applied_effort); } Ok(acp::SetSessionModelResponse::new().meta( serde_json::json!({ "model" : updated_model, }) .as_object() .cloned(), )) } /// Broadcast a `ModelChanged` to every client subscribed to this session so /// followers mirror the new model. The originating client ignores its own echo /// (gated by `model_switch_pending`). Broadcast-only — no eventId, not persisted. fn broadcast_model_changed( agent: &MvpAgent, session_id: &acp::SessionId, model_id: &str, reasoning_effort: Option, ) { let notification = crate::extensions::notification::SessionNotification { session_id: session_id.clone(), update: crate::extensions::notification::SessionUpdate::ModelChanged { model_id: model_id.to_owned(), reasoning_effort, }, meta: None, }; if let Ok(params) = serde_json::value::to_raw_value(¬ification) { agent .gateway .forward_fire_and_forget(acp::ExtNotification::new( "kigi/session_notification", params.into(), )); } }