feat(model): 专家和子代理支持独立配置 provider/model

- 新增 ModelResolver 解析器,按 frontmatter 中的 provider/model 名覆盖基础 LLMProviderConfig

- Expert/SubagentDef 数据结构新增 provider/model 字段,frontmatter 解析与渲染支持往返

- AgentFactory 和 DefaultSubAgentRuntime 持有 ModelResolver,在创建 agent 时解析模型覆盖

- HTTP API 新增 /api/model-options 端点,ExpertResponse/Create/Update 和 SubagentUpdateRequest 支持 provider/model

- update_expert/update_subagent 支持 provider/model 字段写回 frontmatter

- 保持架构解耦:ModelResolver 位于 config 底层,不引入新的跨模块依赖
This commit is contained in:
oudecheng 2026-07-30 22:44:10 +08:00
parent 003eab4f21
commit dc9211548a
12 changed files with 360 additions and 25 deletions

View File

@ -1049,6 +1049,80 @@ impl std::fmt::Display for ConfigError {
impl std::error::Error for ConfigError {} impl std::error::Error for ConfigError {}
/// Provider/Model 解析器:按可选的 provider/model 名覆盖基础 LLMProviderConfig。
///
/// 用于专家和子代理独立配置模型frontmatter 中的 `provider`/`model` 字段
/// 引用 `config.json` 的 `providers`/`models` 表,由本结构解析覆盖。
/// 保留 base 中的 agent 级参数max_tool_iterations 等)和 image_context 配置。
#[derive(Debug, Clone)]
pub struct ModelResolver {
providers: HashMap<String, ProviderConfig>,
models: HashMap<String, ModelConfig>,
}
impl ModelResolver {
pub fn new(providers: HashMap<String, ProviderConfig>, models: HashMap<String, ModelConfig>) -> Self {
Self { providers, models }
}
/// 从 Config 构造。
pub fn from_config(config: &Config) -> Self {
Self::new(config.providers.clone(), config.models.clone())
}
/// 按可选的 provider/model 名解析覆盖 base 中的对应部分。
///
/// - `provider_name=None` 时保留 base 的 provider 部分
/// - `model_name=None` 时保留 base 的 model 部分
/// - agent 级参数和 image_context 始终保留 base 的值
pub fn resolve(
&self,
provider_name: Option<&str>,
model_name: Option<&str>,
base: &LLMProviderConfig,
) -> Result<LLMProviderConfig, ConfigError> {
let mut result = base.clone();
if let Some(name) = provider_name.map(str::trim).filter(|s| !s.is_empty()) {
let provider = self
.providers
.get(name)
.ok_or(ConfigError::ProviderNotFound(name.to_string()))?;
result.provider_type = provider.provider_type.clone();
result.name = name.to_string();
result.base_url = provider.base_url.clone();
result.api_key = provider.api_key.clone();
result.extra_headers = provider.extra_headers.clone();
result.llm_timeout_secs = provider.llm_timeout_secs;
result.memory_maintenance_timeout_secs = provider.memory_maintenance_timeout_secs;
}
if let Some(name) = model_name.map(str::trim).filter(|s| !s.is_empty()) {
let model = self
.models
.get(name)
.ok_or(ConfigError::ModelNotFound(name.to_string()))?;
result.model_id = model.model_id.clone();
result.temperature = model.temperature;
result.max_tokens = model.max_tokens;
result.context_window_tokens = model.context_window_tokens;
result.model_extra = model.extra.clone();
}
Ok(result)
}
/// 列出所有可用的 provider 名(供前端下拉框)。
pub fn provider_names(&self) -> Vec<String> {
self.providers.keys().cloned().collect()
}
/// 列出所有可用的 model 名(供前端下拉框)。
pub fn model_names(&self) -> Vec<String> {
self.models.keys().cloned().collect()
}
}
fn load_env_file() -> Result<(), Box<dyn std::error::Error>> { fn load_env_file() -> Result<(), Box<dyn std::error::Error>> {
let env_path = Path::new(".env"); let env_path = Path::new(".env");
if env_path.exists() { if env_path.exists() {

View File

@ -25,6 +25,10 @@ pub struct Expert {
pub path: PathBuf, pub path: PathBuf,
/// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。 /// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。
pub capability: CapabilityPolicy, pub capability: CapabilityPolicy,
/// 可选的 provider 名(引用 config.json 的 providers 表。None 时继承主智能体。
pub provider: Option<String>,
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
pub model: Option<String>,
} }
/// Where an expert definition was discovered from. /// Where an expert definition was discovered from.
@ -93,6 +97,12 @@ pub struct ExpertWithStatus {
/// 工具与技能加载策略。 /// 工具与技能加载策略。
#[serde(default)] #[serde(default)]
pub capability: CapabilityPolicy, pub capability: CapabilityPolicy,
/// 可选的 provider 名(引用 config.json 的 providers 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
} }
/// Result of an enable/disable operation. /// Result of an enable/disable operation.
@ -331,6 +341,8 @@ impl ExpertRuntime {
path: expert.path.display().to_string(), path: expert.path.display().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
capability: expert.capability.clone(), capability: expert.capability.clone(),
provider: expert.provider.clone(),
model: expert.model.clone(),
} }
}) })
.collect(); .collect();
@ -353,6 +365,8 @@ impl ExpertRuntime {
description: &str, description: &str,
body: &str, body: &str,
capability: &CapabilityPolicy, capability: &CapabilityPolicy,
provider: &Option<String>,
model: &Option<String>,
reload: bool, reload: bool,
) -> Result<Expert, String> { ) -> Result<Expert, String> {
validate_expert_name(name)?; validate_expert_name(name)?;
@ -365,7 +379,7 @@ impl ExpertRuntime {
)); ));
} }
write_expert_file(&path, name, description, body, capability)?; write_expert_file(&path, name, description, body, capability, provider, model)?;
let expert = parse_expert_file(&path, scope.into())?; let expert = parse_expert_file(&path, scope.into())?;
if reload { if reload {
let _ = self.reload()?; let _ = self.reload()?;
@ -380,6 +394,8 @@ impl ExpertRuntime {
description: Option<&str>, description: Option<&str>,
body: Option<&str>, body: Option<&str>,
capability: Option<&CapabilityPolicy>, capability: Option<&CapabilityPolicy>,
provider: Option<&Option<String>>,
model: Option<&Option<String>>,
reload: bool, reload: bool,
) -> Result<Expert, String> { ) -> Result<Expert, String> {
validate_expert_name(name)?; validate_expert_name(name)?;
@ -392,8 +408,10 @@ impl ExpertRuntime {
let next_description = description.unwrap_or(&existing.description); let next_description = description.unwrap_or(&existing.description);
let next_body = body.unwrap_or(&existing.body); let next_body = body.unwrap_or(&existing.body);
let next_capability = capability.cloned().unwrap_or(existing.capability); let next_capability = capability.cloned().unwrap_or(existing.capability);
let next_provider = provider.cloned().unwrap_or(existing.provider);
let next_model = model.cloned().unwrap_or(existing.model);
write_expert_file(&path, name, next_description, next_body, &next_capability)?; write_expert_file(&path, name, next_description, next_body, &next_capability, &next_provider, &next_model)?;
let expert = parse_expert_file(&path, scope.into())?; let expert = parse_expert_file(&path, scope.into())?;
if reload { if reload {
let _ = self.reload()?; let _ = self.reload()?;
@ -742,6 +760,10 @@ struct ExpertFrontmatter {
allowed_subagents: Option<Vec<String>>, allowed_subagents: Option<Vec<String>>,
#[serde(default)] #[serde(default)]
denied_subagents: Vec<String>, denied_subagents: Vec<String>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
} }
/// 规范化字符串列表:去除空白项与首尾空格。 /// 规范化字符串列表:去除空白项与首尾空格。
@ -762,6 +784,8 @@ fn render_expert_file(
description: &str, description: &str,
body: &str, body: &str,
capability: &CapabilityPolicy, capability: &CapabilityPolicy,
provider: &Option<String>,
model: &Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
if description.trim().is_empty() { if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string()); return Err("description is required and cannot be empty".to_string());
@ -783,6 +807,10 @@ fn render_expert_file(
allowed_subagents: Option<Vec<String>>, allowed_subagents: Option<Vec<String>>,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(skip_serializing_if = "Vec::is_empty")]
denied_subagents: Vec<String>, denied_subagents: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
} }
let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned {
@ -794,6 +822,8 @@ fn render_expert_file(
denied_tools: capability.denied_tools.clone(), denied_tools: capability.denied_tools.clone(),
allowed_subagents: capability.allowed_subagents.clone(), allowed_subagents: capability.allowed_subagents.clone(),
denied_subagents: capability.denied_subagents.clone(), denied_subagents: capability.denied_subagents.clone(),
provider: provider.clone(),
model: model.clone(),
}) })
.map_err(|err| format!("failed to render expert frontmatter: {}", err))?; .map_err(|err| format!("failed to render expert frontmatter: {}", err))?;
@ -812,8 +842,10 @@ fn write_expert_file(
description: &str, description: &str,
body: &str, body: &str,
capability: &CapabilityPolicy, capability: &CapabilityPolicy,
provider: &Option<String>,
model: &Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let content = render_expert_file(name, description, body, capability)?; let content = render_expert_file(name, description, body, capability, provider, model)?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
.map_err(|err| format!("failed to create expert directory: {}", err))?; .map_err(|err| format!("failed to create expert directory: {}", err))?;
@ -890,6 +922,15 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
denied_subagents: normalize_string_list(frontmatter.denied_subagents), denied_subagents: normalize_string_list(frontmatter.denied_subagents),
}; };
let provider = frontmatter
.provider
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let model = frontmatter
.model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(Expert { Ok(Expert {
name, name,
description: description.to_string(), description: description.to_string(),
@ -897,6 +938,8 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result<Expert, String
source, source,
path: path.to_path_buf(), path: path.to_path_buf(),
capability, capability,
provider,
model,
}) })
} }
@ -1094,7 +1137,7 @@ mod tests {
#[test] #[test]
fn test_render_expert_file_requires_description() { fn test_render_expert_file_requires_description() {
let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default()).unwrap_err(); let err = render_expert_file("demo", " ", "body", &CapabilityPolicy::default(), &None, &None).unwrap_err();
assert!(err.contains("description")); assert!(err.contains("description"));
} }
@ -1130,8 +1173,10 @@ mod tests {
denied_skills: vec!["skill_c".to_string()], denied_skills: vec!["skill_c".to_string()],
allowed_tools: Some(vec!["read".to_string(), "mcp_fs_echo".to_string()]), allowed_tools: Some(vec!["read".to_string(), "mcp_fs_echo".to_string()]),
denied_tools: vec!["bash".to_string()], denied_tools: vec!["bash".to_string()],
allowed_subagents: None,
denied_subagents: vec![],
}; };
let rendered = render_expert_file("cap", "desc", "body", &policy).unwrap(); let rendered = render_expert_file("cap", "desc", "body", &policy, &None, &None).unwrap();
// 白名单/黑名单字段都应出现 // 白名单/黑名单字段都应出现
assert!(rendered.contains("allowed_skills:")); assert!(rendered.contains("allowed_skills:"));
assert!(rendered.contains("denied_skills:")); assert!(rendered.contains("denied_skills:"));
@ -1143,7 +1188,7 @@ mod tests {
// 写入磁盘再读回,策略应一致 // 写入磁盘再读回,策略应一致
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
let file_path = temp.path().join("EXPERT.md"); let file_path = temp.path().join("EXPERT.md");
write_expert_file(&file_path, "cap", "desc", "body", &policy).unwrap(); write_expert_file(&file_path, "cap", "desc", "body", &policy, &None, &None).unwrap();
let expert = parse_expert_file(&file_path, ExpertSource::Project).unwrap(); let expert = parse_expert_file(&file_path, ExpertSource::Project).unwrap();
assert_eq!(expert.capability, policy); assert_eq!(expert.capability, policy);
} }
@ -1151,7 +1196,7 @@ mod tests {
#[test] #[test]
fn test_empty_capability_omits_keys() { fn test_empty_capability_omits_keys() {
// 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容 // 空策略不应输出多余 frontmatter 键,保持旧文件格式兼容
let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default()).unwrap(); let rendered = render_expert_file("plain", "desc", "body", &CapabilityPolicy::default(), &None, &None).unwrap();
assert!(!rendered.contains("allowed_skills")); assert!(!rendered.contains("allowed_skills"));
assert!(!rendered.contains("denied_skills")); assert!(!rendered.contains("denied_skills"));
assert!(!rendered.contains("allowed_tools")); assert!(!rendered.contains("allowed_tools"));
@ -1227,6 +1272,8 @@ mod tests {
"翻译专家", "翻译专家",
"你是一名专业翻译。", "你是一名专业翻译。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1240,6 +1287,8 @@ mod tests {
"dup", "dup",
"body", "body",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
); );
assert!(dup.is_err()); assert!(dup.is_err());
@ -1251,6 +1300,8 @@ mod tests {
Some("更新翻译专家"), Some("更新翻译专家"),
Some("你是一名中文教师。"), Some("你是一名中文教师。"),
None, None,
None,
None,
true, true,
) )
.unwrap(); .unwrap();
@ -1259,7 +1310,7 @@ mod tests {
// update with None preserves fields // update with None preserves fields
let updated_none = runtime let updated_none = runtime
.update_expert(ExpertScope::Project, "translator", None, None, None, true) .update_expert(ExpertScope::Project, "translator", None, None, None, None, None, true)
.unwrap(); .unwrap();
assert_eq!(updated_none.description, "更新翻译专家"); assert_eq!(updated_none.description, "更新翻译专家");
assert_eq!(updated_none.body, "你是一名中文教师。"); assert_eq!(updated_none.body, "你是一名中文教师。");
@ -1294,6 +1345,8 @@ mod tests {
"编程专家", "编程专家",
"你是一名编程专家。", "你是一名编程专家。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1344,6 +1397,8 @@ mod tests {
"写作专家", "写作专家",
"你是一名写作专家。", "你是一名写作专家。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1385,6 +1440,8 @@ mod tests {
"代码审查专家", "代码审查专家",
"你是一名代码审查专家。", "你是一名代码审查专家。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1435,6 +1492,8 @@ mod tests {
"规划专家", "规划专家",
"你是一名规划专家。", "你是一名规划专家。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1570,6 +1629,8 @@ mod tests {
"教师专家", "教师专家",
"你是一名中文教师,请用中文回答。", "你是一名中文教师,请用中文回答。",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();
@ -1608,6 +1669,8 @@ mod tests {
"无 body 的专家", "无 body 的专家",
"", // body empty "", // body empty
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
&None,
&None,
true, true,
) )
.unwrap(); .unwrap();

View File

@ -1,7 +1,7 @@
use std::sync::Arc; use std::sync::Arc;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider}; use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider, SystemPromptProvider};
use crate::config::LLMProviderConfig; use crate::config::{LLMProviderConfig, ModelResolver};
use crate::domain::CapabilityPolicy; use crate::domain::CapabilityPolicy;
use crate::experts::ExpertPromptProvider; use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime; use crate::experts::ExpertRuntime;
@ -48,6 +48,8 @@ pub(crate) struct AgentFactory {
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
/// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<ModelResolver>,
/// 实例创建时间戳(用于区分新旧 AgentFactory 实例) /// 实例创建时间戳(用于区分新旧 AgentFactory 实例)
instance_id: u64, instance_id: u64,
} }
@ -73,6 +75,7 @@ impl AgentFactory {
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
model_resolver: Arc<ModelResolver>,
) -> Self { ) -> Self {
// 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例 // 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例
let instance_id = Arc::as_ptr(&tools) as u64; let instance_id = Arc::as_ptr(&tools) as u64;
@ -88,6 +91,7 @@ impl AgentFactory {
subagent_runtime, subagent_runtime,
reinject_every, reinject_every,
prompt_repository, prompt_repository,
model_resolver,
instance_id, instance_id,
} }
} }
@ -95,13 +99,40 @@ impl AgentFactory {
pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> { pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result<AgentLoop, AgentError> {
let session_id = persistent_session_id(request.channel_name, request.session_chat_id); let session_id = persistent_session_id(request.channel_name, request.session_chat_id);
// 读取所选专家(用于工具过滤 + 子代理策略 + 模型覆盖)
let expert = self.experts.selected_expert_for(&session_id);
let expert_capability = expert.as_ref().map(|e| e.capability.clone());
// 按专家 frontmatter 中的 provider/model 字段解析覆盖基础 provider_config。
// 引用不存在的 provider/model 名时报错并阻止会话(用户主动选择的角色,配置错误应明确反馈)。
let effective_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(),
e.model.as_deref(),
&request.provider_config,
)
.map_err(|e| AgentError::Other(e.to_string()))?;
tracing::info!(
instance_id = self.instance_id,
session_id = %session_id,
expert = %e.name,
provider = %resolved.name,
model_id = %resolved.model_id,
"AgentFactory: applied expert model override"
);
resolved
}
_ => request.provider_config.clone(),
};
// 诊断日志:记录 agent 实际使用的配置和实例 ID // 诊断日志:记录 agent 实际使用的配置和实例 ID
tracing::info!( tracing::info!(
instance_id = self.instance_id, instance_id = self.instance_id,
channel = %request.channel_name, channel = %request.channel_name,
session_id = %session_id, session_id = %session_id,
provider = %request.provider_config.name, provider = %effective_provider_config.name,
model_id = %request.provider_config.model_id, model_id = %effective_provider_config.model_id,
tool_count = self.tools.tool_names().len(), tool_count = self.tools.tool_names().len(),
"AgentFactory: creating agent with config" "AgentFactory: creating agent with config"
); );
@ -109,19 +140,13 @@ impl AgentFactory {
// 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑) // 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑)
let system_prompt_provider = build_system_prompt_provider( let system_prompt_provider = build_system_prompt_provider(
self.reinject_every, self.reinject_every,
request.provider_config.clone(), effective_provider_config.clone(),
self.prompt_repository.clone(), self.prompt_repository.clone(),
self.skills.clone(), self.skills.clone(),
self.experts.clone(), self.experts.clone(),
self.subagent_runtime.clone(), self.subagent_runtime.clone(),
); );
// 读取所选专家的 capability用于工具过滤 + 子代理策略注入 ToolContext
let expert_capability = self
.experts
.selected_expert_for(&session_id)
.map(|e| e.capability);
// 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。 // 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。
// 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。 // 无专家或专家未声明工具策略时,复用共享的 Arc<ToolRegistry>(零拷贝)。
let base_tool_count = self.tools.tool_names().len(); let base_tool_count = self.tools.tool_names().len();
@ -142,7 +167,7 @@ impl AgentFactory {
}; };
AgentLoop::with_tools_and_system_prompt_provider( AgentLoop::with_tools_and_system_prompt_provider(
request.provider_config, effective_provider_config,
tools, tools,
system_prompt_provider, system_prompt_provider,
Some(self.skills.clone()), Some(self.skills.clone()),

View File

@ -263,6 +263,13 @@ pub struct ToolsListResponse {
pub tools: Vec<ToolInfo>, pub tools: Vec<ToolInfo>,
} }
/// GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框)。
#[derive(Serialize)]
pub struct ModelOptionsResponse {
pub providers: Vec<String>,
pub models: Vec<String>,
}
/// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source. /// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source.
/// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。 /// 通过 SessionManager::tools() 只读访问 ToolRegistry不修改状态。
pub async fn tools_list( pub async fn tools_list(
@ -295,6 +302,18 @@ pub async fn tools_list(
Json(ToolsListResponse { total, tools }) Json(ToolsListResponse { total, tools })
} }
/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。
pub async fn model_options(
State(state): State<Arc<GatewayState>>,
) -> Json<ModelOptionsResponse> {
let config = state.config.read().await;
let resolver = crate::config::ModelResolver::from_config(&config);
Json(ModelOptionsResponse {
providers: resolver.provider_names(),
models: resolver.model_names(),
})
}
/// POST /api/skills/toggle — Enable or disable a specific skill /// POST /api/skills/toggle — Enable or disable a specific skill
pub async fn skills_toggle( pub async fn skills_toggle(
State(state): State<Arc<GatewayState>>, State(state): State<Arc<GatewayState>>,
@ -477,6 +496,10 @@ pub struct SubagentUpdateRequest {
pub body: Option<String>, pub body: Option<String>,
#[serde(default)] #[serde(default)]
pub capability: Option<CapabilityPolicy>, pub capability: Option<CapabilityPolicy>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -499,6 +522,8 @@ pub async fn subagents_update(
req.description.as_deref(), req.description.as_deref(),
req.body.as_deref(), req.body.as_deref(),
req.capability.as_ref(), req.capability.as_ref(),
Some(&req.provider),
Some(&req.model),
true, true,
) )
.map_err(|err| { .map_err(|err| {
@ -524,6 +549,8 @@ pub async fn subagents_update(
source: updated.source.as_str().to_string(), source: updated.source.as_str().to_string(),
disabled_in_scopes: vec![], disabled_in_scopes: vec![],
capability: updated.capability.clone(), capability: updated.capability.clone(),
provider: updated.provider.clone(),
model: updated.model.clone(),
}); });
Ok(Json(SubagentUpdateResponse { Ok(Json(SubagentUpdateResponse {
@ -570,6 +597,10 @@ pub struct ExpertCreateRequest {
pub scope: String, pub scope: String,
#[serde(default)] #[serde(default)]
pub capability: CapabilityPolicy, pub capability: CapabilityPolicy,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -580,6 +611,10 @@ pub struct ExpertUpdateRequest {
pub body: Option<String>, pub body: Option<String>,
#[serde(default)] #[serde(default)]
pub capability: Option<CapabilityPolicy>, pub capability: Option<CapabilityPolicy>,
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -621,6 +656,10 @@ pub struct ExpertResponse {
pub path: String, pub path: String,
#[serde(default)] #[serde(default)]
pub capability: CapabilityPolicy, pub capability: CapabilityPolicy,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
} }
impl From<Expert> for ExpertResponse { impl From<Expert> for ExpertResponse {
@ -632,6 +671,8 @@ impl From<Expert> for ExpertResponse {
source: expert.source.as_str().to_string(), source: expert.source.as_str().to_string(),
path: expert.path.display().to_string(), path: expert.path.display().to_string(),
capability: expert.capability, capability: expert.capability,
provider: expert.provider,
model: expert.model,
} }
} }
} }
@ -742,7 +783,16 @@ pub async fn experts_create(
let expert = state let expert = state
.experts .experts
.create_expert(scope, &req.name, &req.description, &req.body, &req.capability, true) .create_expert(
scope,
&req.name,
&req.description,
&req.body,
&req.capability,
&req.provider,
&req.model,
true,
)
.map_err(|err| { .map_err(|err| {
let status = if err.contains("already exists") { let status = if err.contains("already exists") {
StatusCode::CONFLICT StatusCode::CONFLICT
@ -771,6 +821,8 @@ pub async fn experts_update(
req.description.as_deref(), req.description.as_deref(),
req.body.as_deref(), req.body.as_deref(),
req.capability.as_ref(), req.capability.as_ref(),
Some(&req.provider),
Some(&req.model),
true, true,
) )
.map_err(|err| { .map_err(|err| {

View File

@ -108,6 +108,7 @@ impl GatewayState {
session_ttl_hours, session_ttl_hours,
mcp_config, mcp_config,
Some(bus.clone()), Some(bus.clone()),
Arc::new(crate::config::ModelResolver::from_config(&config)),
)?; )?;
// 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态) // 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态)
@ -242,6 +243,7 @@ pub async fn run(
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/tools", routing::get(http::tools_list)) .route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list)) .route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/subagents/update", routing::put(http::subagents_update)) .route("/api/subagents/update", routing::put(http::subagents_update))
@ -265,6 +267,7 @@ pub async fn run(
.route("/api/skills", routing::get(http::skills_list)) .route("/api/skills", routing::get(http::skills_list))
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .route("/api/skills/toggle", routing::post(http::skills_toggle))
.route("/api/tools", routing::get(http::tools_list)) .route("/api/tools", routing::get(http::tools_list))
.route("/api/model-options", routing::get(http::model_options))
.route("/api/subagents", routing::get(http::subagents_list)) .route("/api/subagents", routing::get(http::subagents_list))
.route("/api/subagents/toggle", routing::post(http::subagents_toggle)) .route("/api/subagents/toggle", routing::post(http::subagents_toggle))
.route("/api/subagents/update", routing::put(http::subagents_update)) .route("/api/subagents/update", routing::put(http::subagents_update))

View File

@ -8,7 +8,7 @@ use tokio::sync::RwLock;
use crate::agent::AgentError; use crate::agent::AgentError;
use crate::bus::MessageBus; use crate::bus::MessageBus;
use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, SubagentsConfig, TaskConfig}; use crate::config::{LLMProviderConfig, MemoryMaintenanceConfig, ModelResolver, SubagentsConfig, TaskConfig};
use crate::gateway::tool_registry_factory::ToolRegistryFactory; use crate::gateway::tool_registry_factory::ToolRegistryFactory;
use crate::mcp::McpInitializer; use crate::mcp::McpInitializer;
use crate::mcp::client::McpClientManager; use crate::mcp::client::McpClientManager;
@ -53,6 +53,7 @@ pub(crate) fn build_session_manager(
session_ttl_hours: Option<u64>, session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, 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>), AgentError> {
build_session_manager_with_sender( build_session_manager_with_sender(
agent_prompt_reinject_every, agent_prompt_reinject_every,
@ -70,6 +71,7 @@ pub(crate) fn build_session_manager(
session_ttl_hours, session_ttl_hours,
mcp_config, mcp_config,
bus, bus,
model_resolver,
) )
} }
@ -90,6 +92,7 @@ pub(crate) fn build_session_manager_with_sender(
session_ttl_hours: Option<u64>, session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
bus: Option<Arc<MessageBus>>, 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>), AgentError> {
let store = Arc::new( let store = Arc::new(
SessionStore::new() SessionStore::new()
@ -211,6 +214,7 @@ pub(crate) fn build_session_manager_with_sender(
conversations.clone(), conversations.clone(),
subagent_tools.clone(), subagent_tools.clone(),
provider_config.clone(), provider_config.clone(),
model_resolver.clone(),
subagent_runtime.clone(), subagent_runtime.clone(),
bus.clone(), bus.clone(),
store.clone(), store.clone(),
@ -275,6 +279,7 @@ pub(crate) fn build_session_manager_with_sender(
subagent_runtime.clone(), subagent_runtime.clone(),
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),
model_resolver.clone(),
); );
let session_factory = SessionFactory::new( let session_factory = SessionFactory::new(
provider_config.clone(), provider_config.clone(),

View File

@ -258,6 +258,11 @@ impl Session {
let experts = Arc::new(crate::experts::ExpertRuntime::from_config( let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(), crate::config::ExpertsConfig::default(),
)); ));
// Session::new 仅用于测试/简单场景,传入空 ModelResolver无 provider/model 可解析覆盖)。
let model_resolver = Arc::new(crate::config::ModelResolver::new(
std::collections::HashMap::new(),
std::collections::HashMap::new(),
));
let agent_factory = AgentFactory::new( let agent_factory = AgentFactory::new(
tools, tools,
skills.clone(), skills.clone(),
@ -265,6 +270,7 @@ impl Session {
subagent_runtime, subagent_runtime,
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),
model_resolver,
); );
Self::with_factories( Self::with_factories(
channel_name, channel_name,
@ -688,6 +694,11 @@ impl SessionManager {
let experts = Arc::new(crate::experts::ExpertRuntime::from_config( let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(), crate::config::ExpertsConfig::default(),
)); ));
// SessionManager::new 用于测试/简单场景,传入空 ModelResolver无 provider/model 可解析覆盖)。
let model_resolver = Arc::new(crate::config::ModelResolver::new(
std::collections::HashMap::new(),
std::collections::HashMap::new(),
));
super::runtime::build_session_manager( super::runtime::build_session_manager(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
@ -703,6 +714,7 @@ impl SessionManager {
session_ttl_hours, session_ttl_hours,
mcp_config, mcp_config,
None, None,
model_resolver,
) )
.map(|(session_manager, _, _, _)| session_manager) .map(|(session_manager, _, _, _)| session_manager)
} }

View File

@ -105,6 +105,8 @@ mod tests {
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,
provider: None,
model: None,
} }
} }

View File

@ -349,6 +349,8 @@ pub struct DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>, conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>, subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
/// Provider/Model 解析器:按子代理 def 中的 provider/model 字段覆盖基础配置
model_resolver: Arc<crate::config::ModelResolver>,
/// 子代理运行时协调层(管理禁用状态) /// 子代理运行时协调层(管理禁用状态)
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
@ -364,6 +366,7 @@ impl DefaultSubAgentRuntime {
conversation_repository: Arc<dyn ConversationRepository>, conversation_repository: Arc<dyn ConversationRepository>,
subagent_tools: Arc<ToolRegistry>, subagent_tools: Arc<ToolRegistry>,
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
model_resolver: Arc<crate::config::ModelResolver>,
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
bus: Option<Arc<MessageBus>>, bus: Option<Arc<MessageBus>>,
store: Arc<SessionStore>, store: Arc<SessionStore>,
@ -375,6 +378,7 @@ impl DefaultSubAgentRuntime {
conversation_repository, conversation_repository,
subagent_tools, subagent_tools,
provider_config, provider_config,
model_resolver,
subagent_runtime, subagent_runtime,
bus, bus,
store, store,
@ -468,8 +472,25 @@ 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。
// 引用不存在的 provider/model 名时返回错误(反馈给 LLM 重试,与 def 缺失即拒绝的安全范式一致)。
let effective_provider_config = match def {
Some(d) if d.provider.is_some() || d.model.is_some() => {
self.model_resolver
.resolve(d.provider.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(self.provider_config.clone()), AgentRuntimeConfig::from(effective_provider_config),
tools, tools,
prompt_provider, prompt_provider,
None, // 子代理不需要 skill provider None, // 子代理不需要 skill provider
@ -727,11 +748,22 @@ impl SubAgentRuntime for DefaultSubAgentRuntime {
} else { } else {
self.skills.system_index_prompt() self.skills.system_index_prompt()
}; };
// 同步解析 def 中的 provider/model 覆盖,保证环境提示中的模型名与实际使用的模型一致
let effective_provider_config = match (def.provider.is_some(), def.model.is_some()) {
(true, _) | (_, true) => self
.model_resolver
.resolve(def.provider.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,
&task.prompt, &task.prompt,
&self.provider_config, &effective_provider_config,
skills_index.as_deref(), skills_index.as_deref(),
); );
@ -1051,6 +1083,12 @@ pub struct SubagentWithStatus {
/// 工具与技能加载策略。 /// 工具与技能加载策略。
#[serde(default)] #[serde(default)]
pub capability: CapabilityPolicy, pub capability: CapabilityPolicy,
/// 可选的 provider 名(引用 config.json 的 providers 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@ -1205,6 +1243,8 @@ impl SubagentRuntime {
source: def.source.as_str().to_string(), source: def.source.as_str().to_string(),
disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(),
capability: def.capability.clone(), capability: def.capability.clone(),
provider: def.provider.clone(),
model: def.model.clone(),
} }
}) })
.collect(); .collect();
@ -1398,7 +1438,7 @@ impl SubagentRuntime {
/// 更新子代理定义(写回 SUBAGENT.md frontmatter /// 更新子代理定义(写回 SUBAGENT.md frontmatter
/// 对齐 `ExpertRuntime::update_expert`。 /// 对齐 `ExpertRuntime::update_expert`。
/// - `description`/`body`/`capability` 为 None 时保留原值。 /// - `description`/`body`/`capability`/`provider`/`model` 为 None 时保留原值。
/// - `prompt_template`/`max_execution_secs` 不在 UI 暴露编辑,始终保留原值。 /// - `prompt_template`/`max_execution_secs` 不在 UI 暴露编辑,始终保留原值。
/// - builtin 子代理(`source == Builtin`、`path == None`)禁止 update。 /// - builtin 子代理(`source == Builtin`、`path == None`)禁止 update。
pub fn update_subagent( pub fn update_subagent(
@ -1407,6 +1447,8 @@ impl SubagentRuntime {
description: Option<&str>, description: Option<&str>,
body: Option<&str>, body: Option<&str>,
capability: Option<&CapabilityPolicy>, capability: Option<&CapabilityPolicy>,
provider: Option<&Option<String>>,
model: Option<&Option<String>>,
reload: bool, reload: bool,
) -> Result<SubagentDef, String> { ) -> Result<SubagentDef, String> {
let def = { let def = {
@ -1430,6 +1472,8 @@ impl SubagentRuntime {
let next_description = description.unwrap_or(&def.description); let next_description = description.unwrap_or(&def.description);
let next_body = body.unwrap_or(def.body.as_deref().unwrap_or("")); let next_body = body.unwrap_or(def.body.as_deref().unwrap_or(""));
let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone()); let next_capability = capability.cloned().unwrap_or_else(|| def.capability.clone());
let next_provider = provider.cloned().unwrap_or(def.provider);
let next_model = model.cloned().unwrap_or(def.model);
write_subagent_file( write_subagent_file(
path, path,
@ -1439,6 +1483,8 @@ impl SubagentRuntime {
next_body, next_body,
&next_capability, &next_capability,
def.max_execution_secs, def.max_execution_secs,
&next_provider,
&next_model,
)?; )?;
let new_def = parse_subagent_file(path, def.source.clone())?; let new_def = parse_subagent_file(path, def.source.clone())?;
@ -1565,6 +1611,10 @@ struct SubagentFrontmatter {
denied_subagents: Vec<String>, denied_subagents: Vec<String>,
#[serde(default)] #[serde(default)]
max_execution_secs: Option<u64>, max_execution_secs: Option<u64>,
#[serde(default)]
provider: Option<String>,
#[serde(default)]
model: Option<String>,
} }
/// 从根目录加载所有子代理 /// 从根目录加载所有子代理
@ -1657,6 +1707,15 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
denied_subagents: frontmatter.denied_subagents, denied_subagents: frontmatter.denied_subagents,
}; };
let provider = frontmatter
.provider
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
let model = frontmatter
.model
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
Ok(SubagentDef { Ok(SubagentDef {
name, name,
description: frontmatter.description.trim().to_string(), description: frontmatter.description.trim().to_string(),
@ -1666,6 +1725,8 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result<SubagentDe
max_execution_secs: frontmatter.max_execution_secs, max_execution_secs: frontmatter.max_execution_secs,
source, source,
path: Some(path.to_path_buf()), path: Some(path.to_path_buf()),
provider,
model,
}) })
} }
@ -1678,6 +1739,8 @@ fn render_subagent_file(
body: &str, body: &str,
capability: &CapabilityPolicy, capability: &CapabilityPolicy,
max_execution_secs: Option<u64>, max_execution_secs: Option<u64>,
provider: &Option<String>,
model: &Option<String>,
) -> Result<String, String> { ) -> Result<String, String> {
if description.trim().is_empty() { if description.trim().is_empty() {
return Err("description is required and cannot be empty".to_string()); return Err("description is required and cannot be empty".to_string());
@ -1703,6 +1766,10 @@ fn render_subagent_file(
denied_subagents: Vec<String>, denied_subagents: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
max_execution_secs: Option<u64>, max_execution_secs: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
provider: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
model: Option<String>,
} }
let fm = SubagentFrontmatterOwned { let fm = SubagentFrontmatterOwned {
@ -1720,6 +1787,8 @@ fn render_subagent_file(
allowed_subagents: capability.allowed_subagents.clone(), allowed_subagents: capability.allowed_subagents.clone(),
denied_subagents: capability.denied_subagents.clone(), denied_subagents: capability.denied_subagents.clone(),
max_execution_secs, max_execution_secs,
provider: provider.clone(),
model: model.clone(),
}; };
let yaml = serde_yaml::to_string(&fm) let yaml = serde_yaml::to_string(&fm)
@ -1742,6 +1811,8 @@ fn write_subagent_file(
body: &str, body: &str,
capability: &CapabilityPolicy, capability: &CapabilityPolicy,
max_execution_secs: Option<u64>, max_execution_secs: Option<u64>,
provider: &Option<String>,
model: &Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let content = render_subagent_file( let content = render_subagent_file(
name, name,
@ -1750,6 +1821,8 @@ fn write_subagent_file(
body, body,
capability, capability,
max_execution_secs, max_execution_secs,
provider,
model,
)?; )?;
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
fs::create_dir_all(parent) fs::create_dir_all(parent)
@ -1965,6 +2038,8 @@ mod tests {
denied_skills: Vec::new(), denied_skills: Vec::new(),
allowed_tools: allowed.map(|v| v.iter().map(|x| x.to_string()).collect()), allowed_tools: allowed.map(|v| v.iter().map(|x| x.to_string()).collect()),
denied_tools: denied.iter().map(|x| x.to_string()).collect(), denied_tools: denied.iter().map(|x| x.to_string()).collect(),
allowed_subagents: None,
denied_subagents: vec![],
} }
} }
@ -2125,10 +2200,14 @@ mod tests {
denied_skills: Vec::new(), denied_skills: Vec::new(),
allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]),
denied_tools: vec!["bash".to_string()], denied_tools: vec!["bash".to_string()],
allowed_subagents: None,
denied_subagents: vec![],
}, },
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,
provider: None,
model: None,
}); });
let runtime = SubagentRuntime::new( let runtime = SubagentRuntime::new(
SubagentsConfig::default(), SubagentsConfig::default(),
@ -2154,6 +2233,8 @@ mod tests {
denied_skills: vec!["skill_b".to_string()], denied_skills: vec!["skill_b".to_string()],
allowed_tools: Some(vec!["read".to_string()]), allowed_tools: Some(vec!["read".to_string()]),
denied_tools: vec!["bash".to_string()], denied_tools: vec!["bash".to_string()],
allowed_subagents: None,
denied_subagents: vec![],
}; };
let content = render_subagent_file( let content = render_subagent_file(
"demo", "demo",
@ -2162,6 +2243,8 @@ mod tests {
"body instructions", "body instructions",
&cap, &cap,
Some(1800), Some(1800),
&None,
&None,
) )
.unwrap(); .unwrap();
@ -2197,6 +2280,8 @@ mod tests {
"body", "body",
&cap, &cap,
None, None,
&None,
&None,
) )
.unwrap(); .unwrap();
// 空 capability 字段不应出现在 YAML 中 // 空 capability 字段不应出现在 YAML 中
@ -2221,6 +2306,8 @@ mod tests {
"initial body", "initial body",
&CapabilityPolicy::default(), &CapabilityPolicy::default(),
None, None,
&None,
&None,
) )
.unwrap(); .unwrap();
@ -2236,9 +2323,11 @@ mod tests {
denied_skills: vec!["skill_x".to_string()], denied_skills: vec!["skill_x".to_string()],
allowed_tools: Some(vec!["read".to_string()]), allowed_tools: Some(vec!["read".to_string()]),
denied_tools: vec!["bash".to_string()], denied_tools: vec!["bash".to_string()],
allowed_subagents: None,
denied_subagents: vec![],
}; };
let updated = runtime let updated = runtime
.update_subagent("demo", Some("updated desc"), None, Some(&new_cap), false) .update_subagent("demo", Some("updated desc"), None, Some(&new_cap), Some(&None), Some(&None), false)
.unwrap(); .unwrap();
assert_eq!(updated.description, "updated desc"); assert_eq!(updated.description, "updated desc");
assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]); assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]);
@ -2262,6 +2351,8 @@ mod tests {
Some("new desc"), Some("new desc"),
None, None,
None, None,
Some(&None),
Some(&None),
false, false,
); );
assert!(result.is_err()); assert!(result.is_err());

View File

@ -70,6 +70,10 @@ pub struct SubagentDef {
pub source: SubagentSource, pub source: SubagentSource,
/// 文件路径(仅自定义类型) /// 文件路径(仅自定义类型)
pub path: Option<PathBuf>, pub path: Option<PathBuf>,
/// 可选的 provider 名(引用 config.json 的 providers 表。None 时继承主智能体。
pub provider: Option<String>,
/// 可选的 model 名(引用 config.json 的 models 表。None 时继承主智能体。
pub model: Option<String>,
} }
impl SubagentDef { impl SubagentDef {
@ -84,6 +88,8 @@ impl SubagentDef {
max_execution_secs: None, max_execution_secs: None,
source: SubagentSource::Builtin, source: SubagentSource::Builtin,
path: None, path: None,
provider: None,
model: None,
} }
} }
} }

View File

@ -187,6 +187,7 @@ mod tests {
task_id: None, task_id: None,
parent_task_id: None, parent_task_id: None,
tool_call_id: None, tool_call_id: None,
parent_capability: None,
} }
} }

View File

@ -472,6 +472,7 @@ mod tests {
task_id: None, task_id: None,
parent_task_id: None, parent_task_id: None,
tool_call_id: None, tool_call_id: None,
parent_capability: None,
} }
} }