feat(model): 主 agent 支持会话级模型覆盖

新增 ModelSelectionStore 存储 session_id -> (provider, model) 映射,职责单一,仅依赖 std,不引入业务模块耦合。

AgentFactory::create 中按链式覆盖应用模型配置: 专家 frontmatter 覆盖 -> 用户手动选择覆盖(最高优先级)。

HTTP API: POST /api/session/select-model 设置/清除用户模型覆盖(校验 provider/model 存在性); GET /api/session/selected-model 读取当前 session 的用户覆盖(与 experts/selected 对称)。

在 build_session_manager 系列函数中创建并注入 ModelSelectionStore,GatewayState 持有 Arc<ModelSelectionStore> 供 HTTP handler 访问。
This commit is contained in:
oudecheng 2026-07-30 23:24:51 +08:00
parent 5ff1e8455c
commit a825b10c48
6 changed files with 251 additions and 6 deletions

View File

@ -6,6 +6,7 @@ use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime;
use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_prompt_provider::ToolPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::persistent_session_id;
@ -50,6 +51,8 @@ pub(crate) struct AgentFactory {
prompt_repository: Arc<dyn PromptInjectionRepository>,
/// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<ModelResolver>,
/// per-session 的用户模型选择(最高优先级,覆盖专家配置)
model_selections: Arc<ModelSelectionStore>,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
instance_id: u64,
}
@ -76,6 +79,7 @@ impl AgentFactory {
reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>,
model_selections: Arc<ModelSelectionStore>,
) -> Self {
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
let instance_id = Arc::as_ptr(&tools) as u64;
@ -92,6 +96,7 @@ impl AgentFactory {
reinject_every,
prompt_repository,
model_resolver,
model_selections,
instance_id,
}
}
@ -105,7 +110,7 @@ impl AgentFactory {
// 按专家 frontmatter 中的 provider/model 字段解析覆盖基础 provider_config。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
let effective_provider_config = match &expert {
let expert_provider_config = match &expert {
Some(e) if e.provider.is_some() || e.model.is_some() => {
let resolved = self.model_resolver.resolve(
e.provider.as_deref(),
@ -126,6 +131,33 @@ impl AgentFactory {
_ => request.provider_config.clone(),
};
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
let effective_provider_config =
match self.model_selections.get(&session_id) {
Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() =>
{
let resolved = self
.model_resolver
.resolve(
user_provider.as_deref(),
user_model.as_deref(),
&expert_provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied user model override"
);
resolved
}
_ => expert_provider_config,
};
// 诊断日志:记录 agent 实际使用的配置和实例 ID
tracing::info!(
instance_id = self.instance_id,

View File

@ -268,6 +268,15 @@ pub struct ToolsListResponse {
pub struct ModelOptionsResponse {
pub providers: Vec<String>,
pub models: Vec<String>,
/// 当前默认 agent 的 provider/model 名(来自 config.json agents.default
/// 前端用于在"继承默认"选项旁标注当前生效的模型。
pub current: CurrentModel,
}
#[derive(Serialize)]
pub struct CurrentModel {
pub provider: String,
pub model: String,
}
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
@ -308,9 +317,22 @@ pub async fn model_options(
) -> Json<ModelOptionsResponse> {
let config = state.config.read().await;
let resolver = crate::config::ModelResolver::from_config(&config);
// 当前默认 agent 的 provider/model 名(直接引用 providers/models 表的 key
let current = if let Some(agent) = config.agents.get("default") {
CurrentModel {
provider: agent.provider.clone(),
model: agent.model.clone(),
}
} else {
CurrentModel {
provider: String::new(),
model: String::new(),
}
};
Json(ModelOptionsResponse {
providers: resolver.provider_names(),
models: resolver.model_names(),
current,
})
}
@ -647,6 +669,23 @@ pub struct ExpertSelectResponse {
pub error: Option<String>,
}
/// POST /api/session/select-model 请求体
#[derive(Deserialize)]
pub struct SelectModelRequest {
pub session_id: String,
/// None 或空字符串表示清除覆盖(继承默认)
pub provider: Option<String>,
/// None 或空字符串表示清除覆盖(继承默认)
pub model: Option<String>,
}
#[derive(Serialize)]
pub struct SelectModelResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertResponse {
pub name: String,
@ -929,3 +968,81 @@ pub async fn experts_select(
),
}
}
/// POST /api/session/select-model — 设置或清除session 的用户模型覆盖
pub async fn session_select_model(
State(state): State<Arc<GatewayState>>,
Json(req): Json<SelectModelRequest>,
) -> (StatusCode, Json<SelectModelResponse>) {
// 规范化trim 后空字符串视为 None与 frontmatter 解析逻辑一致)
let provider = req
.provider
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let model = req
.model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
// 校验provider/model 名必须在 config 的 providers/models 表中存在
// (与 AgentFactory::create 中的解析失败行为对齐,提前反馈错误)
let config = state.config.read().await;
if let Some(name) = provider.as_ref() {
if !config.providers.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("provider '{}' not found in config", name)),
}),
);
}
}
if let Some(name) = model.as_ref() {
if !config.models.contains_key(name) {
return (
StatusCode::BAD_REQUEST,
Json(SelectModelResponse {
success: false,
error: Some(format!("model '{}' not found in config", name)),
}),
);
}
}
drop(config);
state
.model_selections
.set(&req.session_id, provider, model);
(
StatusCode::OK,
Json(SelectModelResponse {
success: true,
error: None,
}),
)
}
/// GET /api/session/selected-model?session_id=... — 返回该 session 当前的用户模型覆盖
#[derive(Deserialize)]
pub struct SessionSelectedModelQuery {
pub session_id: String,
}
#[derive(Serialize)]
pub struct SessionSelectedModelResponse {
/// None 表示未设置用户覆盖(继承默认 / 专家配置)
pub provider: Option<String>,
pub model: Option<String>,
}
pub async fn session_selected_model(
State(state): State<Arc<GatewayState>>,
Query(q): Query<SessionSelectedModelQuery>,
) -> Json<SessionSelectedModelResponse> {
let (provider, model) = state
.model_selections
.get(&q.session_id)
.unwrap_or((None, None));
Json(SessionSelectedModelResponse { provider, model })
}

View File

@ -10,6 +10,7 @@ pub mod http;
pub mod memory_maintenance;
pub mod memory_maintenance_coordinator;
pub mod message_prepare;
pub mod model_selection;
pub mod outbound_dispatcher;
pub mod processor;
pub mod prompt;
@ -67,6 +68,8 @@ pub struct GatewayState {
pub skills: Arc<SkillRuntime>,
pub experts: Arc<crate::experts::ExpertRuntime>,
pub subagent_runtime: Arc<SubagentRuntime>,
/// per-session 的用户模型选择(覆盖专家配置)
pub model_selections: Arc<model_selection::ModelSelectionStore>,
}
impl GatewayState {
@ -92,7 +95,7 @@ impl GatewayState {
mcp_servers: config.mcp_servers.clone(),
};
let (session_manager, task_repository, mcp_manager, subagent_runtime) = build_session_manager_with_sender(
let (session_manager, task_repository, mcp_manager, subagent_runtime, model_selections) = build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
config.time.timezone.clone(),
@ -131,6 +134,7 @@ impl GatewayState {
skills,
experts,
subagent_runtime,
model_selections,
})
}
@ -254,6 +258,8 @@ pub async fn run(
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model))
.route("/api/session/selected-model", routing::get(http::session_selected_model))
.route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler)
.with_state(state.clone())
@ -278,6 +284,8 @@ pub async fn run(
.route("/api/experts/delete", routing::delete(http::experts_delete))
.route("/api/experts/selected", routing::get(http::experts_selected))
.route("/api/experts/select", routing::post(http::experts_select))
.route("/api/session/select-model", routing::post(http::session_select_model))
.route("/api/session/selected-model", routing::get(http::session_selected_model))
.route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone())

View File

@ -0,0 +1,84 @@
use std::collections::HashMap;
use std::sync::RwLock;
/// per-session 的用户模型覆盖选择存储。
///
/// 与 ExpertRuntime 的 session_experts 平级独立,职责单一:
/// 只负责存储 session_id -> (provider, model) 的映射,不依赖任何业务模块。
#[derive(Debug, Default)]
pub struct ModelSelectionStore {
selections: RwLock<HashMap<String, (Option<String>, Option<String>)>>,
}
impl ModelSelectionStore {
pub fn new() -> Self {
Self::default()
}
/// 设置 session 的用户模型覆盖。provider 和 model 均为 None 时清除该 session 的选择。
pub fn set(
&self,
session_id: &str,
provider: Option<String>,
model: Option<String>,
) {
let mut selections = self
.selections
.write()
.expect("model selections rwlock poisoned");
if provider.is_none() && model.is_none() {
selections.remove(session_id);
} else {
selections.insert(session_id.to_string(), (provider, model));
}
}
/// 读取 session 的用户模型覆盖。
pub fn get(&self, session_id: &str) -> Option<(Option<String>, Option<String>)> {
self.selections
.read()
.expect("model selections rwlock poisoned")
.get(session_id)
.cloned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn set_and_get() {
let store = ModelSelectionStore::new();
store.set("s1", Some("p1".to_string()), Some("m1".to_string()));
assert_eq!(
store.get("s1"),
Some((Some("p1".to_string()), Some("m1".to_string())))
);
}
#[test]
fn get_missing_returns_none() {
let store = ModelSelectionStore::new();
assert_eq!(store.get("missing"), None);
}
#[test]
fn set_none_none_removes_entry() {
let store = ModelSelectionStore::new();
store.set("s1", Some("p1".to_string()), Some("m1".to_string()));
assert!(store.get("s1").is_some());
store.set("s1", None, None);
assert!(store.get("s1").is_none());
}
#[test]
fn set_only_provider_keeps_entry() {
let store = ModelSelectionStore::new();
store.set("s1", Some("p1".to_string()), None);
assert_eq!(
store.get("s1"),
Some((Some("p1".to_string()), None))
);
}
}

View File

@ -9,6 +9,7 @@ use tokio::sync::RwLock;
use crate::agent::AgentError;
use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig};
use crate::gateway::model_selection::ModelSelectionStore;
use crate::gateway::tool_registry_factory::ToolRegistryFactory;
use crate::mcp::McpInitializer;
use crate::mcp::client::McpClientManager;
@ -54,7 +55,7 @@ pub(crate) fn build_session_manager(
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
build_session_manager_with_sender(
agent_prompt_reinject_every,
show_tool_results,
@ -93,7 +94,7 @@ pub(crate) fn build_session_manager_with_sender(
mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>,
model_resolver: Arc<ModelResolver>,
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>), AgentError> {
) -> Result<(SessionManager, Arc<dyn TaskRepository>, Option<Arc<McpClientManager>>, Arc<SubagentRuntime>, Arc<ModelSelectionStore>), AgentError> {
let store = Arc::new(
SessionStore::new()
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
@ -272,6 +273,7 @@ pub(crate) fn build_session_manager_with_sender(
);
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
let model_selections = Arc::new(ModelSelectionStore::new());
let agent_factory = AgentFactory::new(
tools.clone(),
skills.clone(),
@ -280,6 +282,7 @@ pub(crate) fn build_session_manager_with_sender(
agent_prompt_reinject_every as usize,
prompt_repository.clone(),
model_resolver.clone(),
model_selections.clone(),
);
let session_factory = SessionFactory::new(
provider_config.clone(),
@ -316,5 +319,5 @@ pub(crate) fn build_session_manager_with_sender(
scheduled_tasks,
memory_maintenance,
task_repository: task_repository.clone(),
}), task_repository, mcp_manager, subagent_runtime))
}), task_repository, mcp_manager, subagent_runtime, model_selections))
}

View File

@ -271,6 +271,7 @@ impl Session {
agent_prompt_reinject_every as usize,
prompt_repository.clone(),
model_resolver,
Arc::new(super::model_selection::ModelSelectionStore::new()),
);
Self::with_factories(
channel_name,
@ -716,7 +717,7 @@ impl SessionManager {
None,
model_resolver,
)
.map(|(session_manager, _, _, _)| session_manager)
.map(|(session_manager, _, _, _, _)| session_manager)
}
pub fn tools(&self) -> Arc<ToolRegistry> {