feat(model): 主代理/子代理模型解析链 topic>session>expert>config + TaskToolResult 附带实际模型

- 主代理解析链新增 topic 级选择(最高优先级),session 级首命中时物化固化为话题选择
- 子代理解析链共享 helper:def frontmatter > 话题级 > session 级 > 全局基础配置
- TaskToolResult 携带子代理实际使用的 provider/model,供前端差异展示
This commit is contained in:
oudecheng 2026-08-15 15:34:18 +08:00
parent 414105d419
commit dc693fa80b
3 changed files with 133 additions and 38 deletions

View File

@ -15,6 +15,7 @@ use crate::observability::Observer;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
use crate::storage::PromptInjectionRepository; use crate::storage::PromptInjectionRepository;
use crate::storage::persistent_session_id; use crate::storage::persistent_session_id;
use crate::storage::SessionStore;
use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime}; use crate::tools::task::runtime::{SubagentPromptProvider, SubagentRuntime};
use crate::tools::task::SubagentResult; use crate::tools::task::SubagentResult;
use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator}; use crate::tools::{ToolContext, ToolRegistry, WaitCoordinator};
@ -56,8 +57,12 @@ pub(crate) struct AgentFactory {
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
/// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置 /// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
/// per-session 的用户模型选择(最高优先级,覆盖专家配置) /// per-session 的用户模型选择(覆盖专家配置)
model_selections: Arc<ModelSelectionStore>, model_selections: Arc<ModelSelectionStore>,
/// per-topic 的用户模型选择(最高优先级;物化后话题模型不再随 session 选择漂移)
topic_model_selections: Arc<ModelSelectionStore>,
/// 持久化存储session 级选择首次被话题命中时物化回写 topics 行
store: Arc<SessionStore>,
/// 上下文压缩算法配置(所有 agent 共享) /// 上下文压缩算法配置(所有 agent 共享)
compaction_config: CompactionConfig, compaction_config: CompactionConfig,
/// 可观测性 Observer依赖注入到 AgentLoop业务层不感知具体实现 /// 可观测性 Observer依赖注入到 AgentLoop业务层不感知具体实现
@ -96,6 +101,8 @@ impl AgentFactory {
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>, model_resolver: Arc<ModelResolver>,
model_selections: Arc<ModelSelectionStore>, model_selections: Arc<ModelSelectionStore>,
topic_model_selections: Arc<ModelSelectionStore>,
store: Arc<SessionStore>,
compaction_config: CompactionConfig, compaction_config: CompactionConfig,
observer: Option<Arc<dyn Observer>>, observer: Option<Arc<dyn Observer>>,
) -> Self { ) -> Self {
@ -115,6 +122,8 @@ impl AgentFactory {
prompt_repository, prompt_repository,
model_resolver, model_resolver,
model_selections, model_selections,
topic_model_selections,
store,
compaction_config, compaction_config,
observer, observer,
instance_id, instance_id,
@ -164,9 +173,20 @@ impl AgentFactory {
_ => request.provider_config.clone(), _ => request.provider_config.clone(),
}; };
// 按用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。 // 用户手动选择的 provider/model 覆盖(最高优先级,覆盖专家配置)。
// 优先级topic 级选择 > session 级选择;均未设置时保持专家/基础配置。
// 物化规则session 级选择首次被话题命中时回写 topics 行固化——此后该话题的
// 模型只能被"在该话题内显式改选"改变,不再随 session 级选择漂移;
// 专家/config 默认不物化(保持继承活性)。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。 // 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择,配置错误应明确反馈)。
let effective_provider_config = match self.model_selections.get(&session_id) { let topic_selection = request
.topic_id
.as_deref()
.and_then(|tid| self.topic_model_selections.get(tid));
let from_topic = topic_selection.is_some();
let user_selection = topic_selection.or_else(|| self.model_selections.get(&session_id));
let effective_provider_config = match user_selection {
Some((user_provider, user_model)) Some((user_provider, user_model))
if user_provider.is_some() || user_model.is_some() => if user_provider.is_some() || user_model.is_some() =>
{ {
@ -178,9 +198,32 @@ impl AgentFactory {
&expert_provider_config, &expert_provider_config,
) )
.map_err(|e| AgentError::Other(e.to_string()))?; .map_err(|e| AgentError::Other(e.to_string()))?;
// 物化:命中 session 级选择且话题无固化值时,将解析后的具体
// (provider, model) 写入 topics 行(持久化 + 内存缓存)
if !from_topic {
if let Some(tid) = request.topic_id.as_deref() {
let provider = resolved.name.clone();
let model = resolved.model_id.clone();
self.topic_model_selections
.set(tid, Some(provider.clone()), Some(model.clone()));
if let Err(err) =
self.store.update_topic_model(tid, Some(&provider), Some(&model))
{
tracing::warn!(
error = %err,
topic_id = %tid,
"AgentFactory: failed to materialize topic model selection"
);
}
}
}
tracing::info!( tracing::info!(
instance_id = self.instance_id, instance_id = self.instance_id,
session_id = %session_id, session_id = %session_id,
topic_id = request.topic_id.as_deref().unwrap_or(""),
source = if from_topic { "topic" } else { "session" },
provider = %resolved.name, provider = %resolved.name,
model_id = %resolved.model_id, model_id = %resolved.model_id,
"AgentFactory: applied user model override" "AgentFactory: applied user model override"

View File

@ -462,6 +462,10 @@ pub struct DefaultSubAgentRuntime {
/// task_id → CancellationToken 映射,用于取消传播 /// task_id → CancellationToken 映射,用于取消传播
/// Arc 包装以便 spawned task 完成后清理自身条目 /// Arc 包装以便 spawned task 完成后清理自身条目
cancel_registry: Arc<parking_lot::Mutex<HashMap<String, tokio_util::sync::CancellationToken>>>, cancel_registry: Arc<parking_lot::Mutex<HashMap<String, tokio_util::sync::CancellationToken>>>,
/// per-session 的用户模型选择(子代理 def 未显式设定模型时继承)
model_selections: Option<Arc<crate::gateway::model_selection::ModelSelectionStore>>,
/// per-topic 的用户模型选择(优先于 session 级继承)
topic_model_selections: Option<Arc<crate::gateway::model_selection::ModelSelectionStore>>,
} }
impl DefaultSubAgentRuntime { impl DefaultSubAgentRuntime {
@ -476,6 +480,8 @@ impl DefaultSubAgentRuntime {
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
model_selections: Option<Arc<crate::gateway::model_selection::ModelSelectionStore>>,
topic_model_selections: Option<Arc<crate::gateway::model_selection::ModelSelectionStore>>,
) -> Self { ) -> Self {
let max_concurrent = config.max_concurrent.max(1); let max_concurrent = config.max_concurrent.max(1);
let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent)); let semaphore = Arc::new(tokio::sync::Semaphore::new(max_concurrent));
@ -492,6 +498,8 @@ impl DefaultSubAgentRuntime {
skills, skills,
semaphore, semaphore,
cancel_registry: Arc::new(parking_lot::Mutex::new(HashMap::new())), cancel_registry: Arc::new(parking_lot::Mutex::new(HashMap::new())),
model_selections,
topic_model_selections,
} }
} }
@ -502,6 +510,55 @@ impl DefaultSubAgentRuntime {
.ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name)) .ok_or_else(|| format!("subagent type '{}' is disabled or not found", type_name))
} }
/// 解析子代理最终生效的 provider 配置create_subagent 与 spawn 共用,保证一致)。
///
/// 优先级def frontmatter > 话题级用户选择 > session 级用户选择 > 全局基础配置。
/// def 仅设定部分字段时,缺失字段从继承链补齐(而非全局),
/// 与主代理"专家覆盖 → 用户选择再覆盖"的解析语义对称。
fn resolve_effective_provider_config(
&self,
session: &TaskSession,
def_name: Option<&str>,
def_provider: Option<&str>,
def_model: Option<&str>,
) -> Result<LLMProviderConfig, TaskError> {
let resolve_err = |scope: &str, e: crate::config::ConfigError| {
TaskError::AgentCreationFailed(format!(
"subagent '{}' {} model resolution failed: {}",
def_name.unwrap_or("?"),
scope,
e
))
};
// 继承链topic 级 > session 级key 为主代理的 persistent session id
let inherited = session
.parent_topic_id
.as_deref()
.and_then(|tid| self.topic_model_selections.as_ref().and_then(|s| s.get(tid)))
.or_else(|| {
self.model_selections
.as_ref()
.and_then(|s| s.get(&session.parent_session_id))
});
let base = match inherited {
Some((p, m)) if p.is_some() || m.is_some() => self
.model_resolver
.resolve(p.as_deref(), m.as_deref(), &self.provider_config)
.map_err(|e| resolve_err("inherited", e))?,
_ => self.provider_config.clone(),
};
match (def_provider.is_some(), def_model.is_some()) {
(true, _) | (_, true) => self
.model_resolver
.resolve(def_provider, def_model, &base)
.map_err(|e| resolve_err("", e)),
_ => Ok(base),
}
}
/// 获取实际执行时间 /// 获取实际执行时间
fn effective_max_execution_secs(&self, def: &SubagentDef) -> u64 { fn effective_max_execution_secs(&self, def: &SubagentDef) -> u64 {
def.max_execution_secs def.max_execution_secs
@ -582,25 +639,14 @@ impl DefaultSubAgentRuntime {
let child_depth = parent_nesting_depth + 1; let child_depth = parent_nesting_depth + 1;
let tools = self.build_subagent_tools_registry(def, child_depth); let tools = self.build_subagent_tools_registry(def, child_depth);
// 按 def 中的 provider/model 字段解析覆盖基础 provider_config // 解析优先级def frontmatter > topic 级用户选择 > session 级用户选择 > 全局基础配置
// 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。 // 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。
let effective_provider_config = match def { let effective_provider_config = self.resolve_effective_provider_config(
Some(d) if d.provider.is_some() || d.model.is_some() => self session,
.model_resolver def.map(|d| d.name.as_str()),
.resolve( def.and_then(|d| d.provider.as_deref()),
d.provider.as_deref(), def.and_then(|d| d.model.as_deref()),
d.model.as_deref(), )?;
&self.provider_config,
)
.map_err(|e| {
TaskError::AgentCreationFailed(format!(
"subagent '{}' model resolution failed: {}",
def.map(|d| d.name.as_str()).unwrap_or("?"),
e
))
})?,
_ => self.provider_config.clone(),
};
AgentLoop::with_tools_and_system_prompt_provider( AgentLoop::with_tools_and_system_prompt_provider(
AgentRuntimeConfig::from(effective_provider_config), AgentRuntimeConfig::from(effective_provider_config),
@ -714,6 +760,8 @@ impl DefaultSubAgentRuntime {
summary: extract_summary(&final_message.content), summary: extract_summary(&final_message.content),
output: final_message.content, output: final_message.content,
task_id: session.id.clone(), task_id: session.id.clone(),
provider: None,
model: None,
}) })
} }
Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())), Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())),
@ -759,6 +807,8 @@ impl DefaultSubAgentRuntime {
summary: extract_summary(&final_message.content), summary: extract_summary(&final_message.content),
output: final_message.content, output: final_message.content,
task_id: session.id.clone(), task_id: session.id.clone(),
provider: None,
model: None,
}) })
} }
Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())), Ok(Err(e)) => Err(TaskError::ExecutionFailed(e.to_string())),
@ -797,6 +847,8 @@ impl DefaultSubAgentRuntime {
summary: error.to_string(), summary: error.to_string(),
output: String::new(), output: String::new(),
task_id: session.id.clone(), task_id: session.id.clone(),
provider: None,
model: None,
}) })
} }
} }
@ -926,23 +978,14 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} else { } else {
self.skills.system_index_prompt() self.skills.system_index_prompt()
}; };
// 同步解析 def 中的 provider/model 覆盖,保证环境提示中的模型名与实际使用的模型一致 // 同步解析生效配置def > topic 级 > session 级 > 全局),
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) { // 与 create_subagent 共用 helper保证环境提示中的模型名与实际使用的模型一致
(true, _) | (_, true) => self let effective_provider_config = self.resolve_effective_provider_config(
.model_resolver &session,
.resolve( Some(def.name.as_str()),
def.provider.as_deref(), def.provider.as_deref(),
def.model.as_deref(), def.model.as_deref(),
&self.provider_config, )?;
)
.map_err(|e| {
TaskError::AgentCreationFailed(format!(
"subagent '{}' model resolution failed: {}",
def.name, e
))
})?,
_ => self.provider_config.clone(),
};
let system_prompt = SubagentPromptBuilder::build( let system_prompt = SubagentPromptBuilder::build(
&def, &def,
&task.description, &task.description,
@ -1177,6 +1220,7 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
// 8c. 立即返回 running 占位结果 // 8c. 立即返回 running 占位结果
// 注意: summary 留空output 只含引导信息。LLM 看到 running 后应调 wait_for_subagents。 // 注意: summary 留空output 只含引导信息。LLM 看到 running 后应调 wait_for_subagents。
// provider/model 携带实际生效配置,供前端在偏离主代理模型时差异显示。
return Ok(TaskToolResult { return Ok(TaskToolResult {
status: "running".to_string(), status: "running".to_string(),
summary: format!("Task {} spawned asynchronously", task_id), summary: format!("Task {} spawned asynchronously", task_id),
@ -1185,6 +1229,8 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
task_id task_id
), ),
task_id, task_id,
provider: Some(effective_provider_config.name.clone()),
model: Some(effective_provider_config.model_id.clone()),
}); });
} }

View File

@ -301,6 +301,12 @@ pub struct TaskToolResult {
pub output: String, pub output: String,
/// 会话 ID用于恢复 /// 会话 ID用于恢复
pub task_id: String, pub task_id: String,
/// 子代理实际使用的 provider 名running 占位时携带,供前端差异显示;其余场景可省略)
#[serde(skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// 子代理实际使用的 model id同上
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
} }
/// 异步子代理完成状态 /// 异步子代理完成状态