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:
parent
5ff1e8455c
commit
a825b10c48
@ -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,
|
||||
|
||||
@ -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 })
|
||||
}
|
||||
|
||||
@ -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())
|
||||
|
||||
84
src/gateway/model_selection.rs
Normal file
84
src/gateway/model_selection.rs
Normal 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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user