One authority answers 'which credential may ride this request': credential_class / manager_for / credential_for / bearer_resolver_for, keyed by (platform, base_url). SessionCredential is an opaque type with no production constructor, so a new call site cannot re-introduce the session-bearer leak. Platform-scoped tests extended across all bearer channels (session, aux, summary, subagent override). Verified: cargo check --workspace --all-targets clean; kigi-shell and kigi-tui suites green (6611+ tests).
266 lines
11 KiB
Rust
266 lines
11 KiB
Rust
//! 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<acp::SetSessionModelResponse, acp::Error> {
|
|
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<kigi_agent::AgentDefinition> = 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<String>,
|
|
) {
|
|
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(),
|
|
));
|
|
}
|
|
}
|