diff --git a/src/config/mod.rs b/src/config/mod.rs index 44b0369..0813ed7 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1049,6 +1049,80 @@ impl std::fmt::Display 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, + models: HashMap, +} + +impl ModelResolver { + pub fn new(providers: HashMap, models: HashMap) -> 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 { + 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 { + self.providers.keys().cloned().collect() + } + + /// 列出所有可用的 model 名(供前端下拉框)。 + pub fn model_names(&self) -> Vec { + self.models.keys().cloned().collect() + } +} + fn load_env_file() -> Result<(), Box> { let env_path = Path::new(".env"); if env_path.exists() { diff --git a/src/experts/mod.rs b/src/experts/mod.rs index a408858..19d5915 100644 --- a/src/experts/mod.rs +++ b/src/experts/mod.rs @@ -25,6 +25,10 @@ pub struct Expert { pub path: PathBuf, /// 工具与技能加载策略。全为空表示沿用主智能体默认配置(不过滤)。 pub capability: CapabilityPolicy, + /// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。 + pub provider: Option, + /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 + pub model: Option, } /// Where an expert definition was discovered from. @@ -93,6 +97,12 @@ pub struct ExpertWithStatus { /// 工具与技能加载策略。 #[serde(default)] pub capability: CapabilityPolicy, + /// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, } /// Result of an enable/disable operation. @@ -331,6 +341,8 @@ impl ExpertRuntime { path: expert.path.display().to_string(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), capability: expert.capability.clone(), + provider: expert.provider.clone(), + model: expert.model.clone(), } }) .collect(); @@ -353,6 +365,8 @@ impl ExpertRuntime { description: &str, body: &str, capability: &CapabilityPolicy, + provider: &Option, + model: &Option, reload: bool, ) -> Result { 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())?; if reload { let _ = self.reload()?; @@ -380,6 +394,8 @@ impl ExpertRuntime { description: Option<&str>, body: Option<&str>, capability: Option<&CapabilityPolicy>, + provider: Option<&Option>, + model: Option<&Option>, reload: bool, ) -> Result { validate_expert_name(name)?; @@ -392,8 +408,10 @@ impl ExpertRuntime { let next_description = description.unwrap_or(&existing.description); let next_body = body.unwrap_or(&existing.body); 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())?; if reload { let _ = self.reload()?; @@ -742,6 +760,10 @@ struct ExpertFrontmatter { allowed_subagents: Option>, #[serde(default)] denied_subagents: Vec, + #[serde(default)] + provider: Option, + #[serde(default)] + model: Option, } /// 规范化字符串列表:去除空白项与首尾空格。 @@ -762,6 +784,8 @@ fn render_expert_file( description: &str, body: &str, capability: &CapabilityPolicy, + provider: &Option, + model: &Option, ) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); @@ -783,6 +807,10 @@ fn render_expert_file( allowed_subagents: Option>, #[serde(skip_serializing_if = "Vec::is_empty")] denied_subagents: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, } let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { @@ -794,6 +822,8 @@ fn render_expert_file( denied_tools: capability.denied_tools.clone(), allowed_subagents: capability.allowed_subagents.clone(), denied_subagents: capability.denied_subagents.clone(), + provider: provider.clone(), + model: model.clone(), }) .map_err(|err| format!("failed to render expert frontmatter: {}", err))?; @@ -812,8 +842,10 @@ fn write_expert_file( description: &str, body: &str, capability: &CapabilityPolicy, + provider: &Option, + model: &Option, ) -> 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() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create expert directory: {}", err))?; @@ -890,6 +922,15 @@ fn parse_expert_file(path: &Path, source: ExpertSource) -> Result Result, reinject_every: usize, prompt_repository: Arc, + /// Provider/Model 解析器:按专家 frontmatter 中的 provider/model 字段覆盖基础配置 + model_resolver: Arc, /// 实例创建时间戳(用于区分新旧 AgentFactory 实例) instance_id: u64, } @@ -73,6 +75,7 @@ impl AgentFactory { subagent_runtime: Arc, reinject_every: usize, prompt_repository: Arc, + model_resolver: Arc, ) -> Self { // 使用 Arc 指针地址作为实例标识符,用于区分新旧 AgentFactory 实例 let instance_id = Arc::as_ptr(&tools) as u64; @@ -88,6 +91,7 @@ impl AgentFactory { subagent_runtime, reinject_every, prompt_repository, + model_resolver, instance_id, } } @@ -95,13 +99,40 @@ impl AgentFactory { pub(crate) fn create(&self, request: AgentBuildRequest<'_>) -> Result { 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 tracing::info!( instance_id = self.instance_id, channel = %request.channel_name, session_id = %session_id, - provider = %request.provider_config.name, - model_id = %request.provider_config.model_id, + provider = %effective_provider_config.name, + model_id = %effective_provider_config.model_id, tool_count = self.tools.tool_names().len(), "AgentFactory: creating agent with config" ); @@ -109,19 +140,13 @@ impl AgentFactory { // 创建组合的系统提示词提供者(与命令侧 /save 等共享同一构建逻辑) let system_prompt_provider = build_system_prompt_provider( self.reinject_every, - request.provider_config.clone(), + effective_provider_config.clone(), self.prompt_repository.clone(), self.skills.clone(), self.experts.clone(), self.subagent_runtime.clone(), ); - // 读取所选专家的 capability(用于工具过滤 + 子代理策略注入 ToolContext) - let expert_capability = self - .experts - .selected_expert_for(&session_id) - .map(|e| e.capability); - // 按所选专家的工具策略过滤工具集(含内置 + MCP 工具)。 // 无专家或专家未声明工具策略时,复用共享的 Arc(零拷贝)。 let base_tool_count = self.tools.tool_names().len(); @@ -142,7 +167,7 @@ impl AgentFactory { }; AgentLoop::with_tools_and_system_prompt_provider( - request.provider_config, + effective_provider_config, tools, system_prompt_provider, Some(self.skills.clone()), diff --git a/src/gateway/http.rs b/src/gateway/http.rs index c557a33..1affeae 100644 --- a/src/gateway/http.rs +++ b/src/gateway/http.rs @@ -263,6 +263,13 @@ pub struct ToolsListResponse { pub tools: Vec, } +/// GET /api/model-options 返回可用的 provider/model 名列表(供专家/子代理编辑下拉框)。 +#[derive(Serialize)] +pub struct ModelOptionsResponse { + pub providers: Vec, + pub models: Vec, +} + /// GET /api/tools — Return all registered tools (builtin + MCP) with name/description/source. /// 通过 SessionManager::tools() 只读访问 ToolRegistry,不修改状态。 pub async fn tools_list( @@ -295,6 +302,18 @@ pub async fn tools_list( Json(ToolsListResponse { total, tools }) } +/// GET /api/model-options — 返回 config.json 中配置的 provider/model 名列表。 +pub async fn model_options( + State(state): State>, +) -> Json { + 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 pub async fn skills_toggle( State(state): State>, @@ -477,6 +496,10 @@ pub struct SubagentUpdateRequest { pub body: Option, #[serde(default)] pub capability: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, } #[derive(Serialize)] @@ -499,6 +522,8 @@ pub async fn subagents_update( req.description.as_deref(), req.body.as_deref(), req.capability.as_ref(), + Some(&req.provider), + Some(&req.model), true, ) .map_err(|err| { @@ -524,6 +549,8 @@ pub async fn subagents_update( source: updated.source.as_str().to_string(), disabled_in_scopes: vec![], capability: updated.capability.clone(), + provider: updated.provider.clone(), + model: updated.model.clone(), }); Ok(Json(SubagentUpdateResponse { @@ -570,6 +597,10 @@ pub struct ExpertCreateRequest { pub scope: String, #[serde(default)] pub capability: CapabilityPolicy, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, } #[derive(Deserialize)] @@ -580,6 +611,10 @@ pub struct ExpertUpdateRequest { pub body: Option, #[serde(default)] pub capability: Option, + #[serde(default)] + pub provider: Option, + #[serde(default)] + pub model: Option, } #[derive(Deserialize)] @@ -621,6 +656,10 @@ pub struct ExpertResponse { pub path: String, #[serde(default)] pub capability: CapabilityPolicy, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, } impl From for ExpertResponse { @@ -632,6 +671,8 @@ impl From for ExpertResponse { source: expert.source.as_str().to_string(), path: expert.path.display().to_string(), capability: expert.capability, + provider: expert.provider, + model: expert.model, } } } @@ -742,7 +783,16 @@ pub async fn experts_create( let expert = state .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| { let status = if err.contains("already exists") { StatusCode::CONFLICT @@ -771,6 +821,8 @@ pub async fn experts_update( req.description.as_deref(), req.body.as_deref(), req.capability.as_ref(), + Some(&req.provider), + Some(&req.model), true, ) .map_err(|err| { diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index bd9c6fe..344098f 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -108,6 +108,7 @@ impl GatewayState { session_ttl_hours, mcp_config, Some(bus.clone()), + Arc::new(crate::config::ModelResolver::from_config(&config)), )?; // 诊断日志:记录新 GatewayState 的创建(用于排查重启后是否使用了新状态) @@ -242,6 +243,7 @@ pub async fn run( .route("/api/skills", routing::get(http::skills_list)) .route("/api/skills/toggle", routing::post(http::skills_toggle)) .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/toggle", routing::post(http::subagents_toggle)) .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/toggle", routing::post(http::skills_toggle)) .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/toggle", routing::post(http::subagents_toggle)) .route("/api/subagents/update", routing::put(http::subagents_update)) diff --git a/src/gateway/runtime.rs b/src/gateway/runtime.rs index 557a5ad..fcfebce 100644 --- a/src/gateway/runtime.rs +++ b/src/gateway/runtime.rs @@ -8,7 +8,7 @@ use tokio::sync::RwLock; use crate::agent::AgentError; 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::mcp::McpInitializer; use crate::mcp::client::McpClientManager; @@ -53,6 +53,7 @@ pub(crate) fn build_session_manager( session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, bus: Option>, + model_resolver: Arc, ) -> Result<(SessionManager, Arc, Option>, Arc), AgentError> { build_session_manager_with_sender( agent_prompt_reinject_every, @@ -70,6 +71,7 @@ pub(crate) fn build_session_manager( session_ttl_hours, mcp_config, bus, + model_resolver, ) } @@ -90,6 +92,7 @@ pub(crate) fn build_session_manager_with_sender( session_ttl_hours: Option, mcp_config: crate::mcp::McpConfig, bus: Option>, + model_resolver: Arc, ) -> Result<(SessionManager, Arc, Option>, Arc), AgentError> { let store = Arc::new( SessionStore::new() @@ -211,6 +214,7 @@ pub(crate) fn build_session_manager_with_sender( conversations.clone(), subagent_tools.clone(), provider_config.clone(), + model_resolver.clone(), subagent_runtime.clone(), bus.clone(), store.clone(), @@ -275,6 +279,7 @@ pub(crate) fn build_session_manager_with_sender( subagent_runtime.clone(), agent_prompt_reinject_every as usize, prompt_repository.clone(), + model_resolver.clone(), ); let session_factory = SessionFactory::new( provider_config.clone(), diff --git a/src/gateway/session.rs b/src/gateway/session.rs index 5524371..1f9796a 100644 --- a/src/gateway/session.rs +++ b/src/gateway/session.rs @@ -258,6 +258,11 @@ impl Session { let experts = Arc::new(crate::experts::ExpertRuntime::from_config( 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( tools, skills.clone(), @@ -265,6 +270,7 @@ impl Session { subagent_runtime, agent_prompt_reinject_every as usize, prompt_repository.clone(), + model_resolver, ); Self::with_factories( channel_name, @@ -688,6 +694,11 @@ impl SessionManager { let experts = Arc::new(crate::experts::ExpertRuntime::from_config( 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( agent_prompt_reinject_every, show_tool_results, @@ -703,6 +714,7 @@ impl SessionManager { session_ttl_hours, mcp_config, None, + model_resolver, ) .map(|(session_manager, _, _, _)| session_manager) } diff --git a/src/tools/task/prompt.rs b/src/tools/task/prompt.rs index 8c85972..5fb2e6e 100644 --- a/src/tools/task/prompt.rs +++ b/src/tools/task/prompt.rs @@ -105,6 +105,8 @@ mod tests { max_execution_secs: None, source: SubagentSource::Builtin, path: None, + provider: None, + model: None, } } diff --git a/src/tools/task/runtime.rs b/src/tools/task/runtime.rs index 4176fba..4519f25 100644 --- a/src/tools/task/runtime.rs +++ b/src/tools/task/runtime.rs @@ -349,6 +349,8 @@ pub struct DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, + /// Provider/Model 解析器:按子代理 def 中的 provider/model 字段覆盖基础配置 + model_resolver: Arc, /// 子代理运行时协调层(管理禁用状态) subagent_runtime: Arc, bus: Option>, @@ -364,6 +366,7 @@ impl DefaultSubAgentRuntime { conversation_repository: Arc, subagent_tools: Arc, provider_config: LLMProviderConfig, + model_resolver: Arc, subagent_runtime: Arc, bus: Option>, store: Arc, @@ -375,6 +378,7 @@ impl DefaultSubAgentRuntime { conversation_repository, subagent_tools, provider_config, + model_resolver, subagent_runtime, bus, store, @@ -468,8 +472,25 @@ impl DefaultSubAgentRuntime { let child_depth = parent_nesting_depth + 1; 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( - AgentRuntimeConfig::from(self.provider_config.clone()), + AgentRuntimeConfig::from(effective_provider_config), tools, prompt_provider, None, // 子代理不需要 skill provider @@ -727,11 +748,22 @@ impl SubAgentRuntime for DefaultSubAgentRuntime { } else { 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( &def, &task.description, &task.prompt, - &self.provider_config, + &effective_provider_config, skills_index.as_deref(), ); @@ -1051,6 +1083,12 @@ pub struct SubagentWithStatus { /// 工具与技能加载策略。 #[serde(default)] pub capability: CapabilityPolicy, + /// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub provider: Option, + /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, } #[derive(Debug, Clone)] @@ -1205,6 +1243,8 @@ impl SubagentRuntime { source: def.source.as_str().to_string(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), capability: def.capability.clone(), + provider: def.provider.clone(), + model: def.model.clone(), } }) .collect(); @@ -1398,7 +1438,7 @@ impl SubagentRuntime { /// 更新子代理定义(写回 SUBAGENT.md frontmatter)。 /// 对齐 `ExpertRuntime::update_expert`。 - /// - `description`/`body`/`capability` 为 None 时保留原值。 + /// - `description`/`body`/`capability`/`provider`/`model` 为 None 时保留原值。 /// - `prompt_template`/`max_execution_secs` 不在 UI 暴露编辑,始终保留原值。 /// - builtin 子代理(`source == Builtin`、`path == None`)禁止 update。 pub fn update_subagent( @@ -1407,6 +1447,8 @@ impl SubagentRuntime { description: Option<&str>, body: Option<&str>, capability: Option<&CapabilityPolicy>, + provider: Option<&Option>, + model: Option<&Option>, reload: bool, ) -> Result { let def = { @@ -1430,6 +1472,8 @@ impl SubagentRuntime { let next_description = description.unwrap_or(&def.description); 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_provider = provider.cloned().unwrap_or(def.provider); + let next_model = model.cloned().unwrap_or(def.model); write_subagent_file( path, @@ -1439,6 +1483,8 @@ impl SubagentRuntime { next_body, &next_capability, def.max_execution_secs, + &next_provider, + &next_model, )?; let new_def = parse_subagent_file(path, def.source.clone())?; @@ -1565,6 +1611,10 @@ struct SubagentFrontmatter { denied_subagents: Vec, #[serde(default)] max_execution_secs: Option, + #[serde(default)] + provider: Option, + #[serde(default)] + model: Option, } /// 从根目录加载所有子代理 @@ -1657,6 +1707,15 @@ fn parse_subagent_file(path: &Path, source: SubagentSource) -> Result Result, + provider: &Option, + model: &Option, ) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); @@ -1703,6 +1766,10 @@ fn render_subagent_file( denied_subagents: Vec, #[serde(skip_serializing_if = "Option::is_none")] max_execution_secs: Option, + #[serde(skip_serializing_if = "Option::is_none")] + provider: Option, + #[serde(skip_serializing_if = "Option::is_none")] + model: Option, } let fm = SubagentFrontmatterOwned { @@ -1720,6 +1787,8 @@ fn render_subagent_file( allowed_subagents: capability.allowed_subagents.clone(), denied_subagents: capability.denied_subagents.clone(), max_execution_secs, + provider: provider.clone(), + model: model.clone(), }; let yaml = serde_yaml::to_string(&fm) @@ -1742,6 +1811,8 @@ fn write_subagent_file( body: &str, capability: &CapabilityPolicy, max_execution_secs: Option, + provider: &Option, + model: &Option, ) -> Result<(), String> { let content = render_subagent_file( name, @@ -1750,6 +1821,8 @@ fn write_subagent_file( body, capability, max_execution_secs, + provider, + model, )?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -1965,6 +2038,8 @@ mod tests { denied_skills: Vec::new(), allowed_tools: allowed.map(|v| v.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(), allowed_tools: Some(vec!["read".to_string(), "todo_write".to_string()]), denied_tools: vec!["bash".to_string()], + allowed_subagents: None, + denied_subagents: vec![], }, max_execution_secs: None, source: SubagentSource::Builtin, path: None, + provider: None, + model: None, }); let runtime = SubagentRuntime::new( SubagentsConfig::default(), @@ -2154,6 +2233,8 @@ mod tests { denied_skills: vec!["skill_b".to_string()], allowed_tools: Some(vec!["read".to_string()]), denied_tools: vec!["bash".to_string()], + allowed_subagents: None, + denied_subagents: vec![], }; let content = render_subagent_file( "demo", @@ -2162,6 +2243,8 @@ mod tests { "body instructions", &cap, Some(1800), + &None, + &None, ) .unwrap(); @@ -2197,6 +2280,8 @@ mod tests { "body", &cap, None, + &None, + &None, ) .unwrap(); // 空 capability 字段不应出现在 YAML 中 @@ -2221,6 +2306,8 @@ mod tests { "initial body", &CapabilityPolicy::default(), None, + &None, + &None, ) .unwrap(); @@ -2236,9 +2323,11 @@ mod tests { denied_skills: vec!["skill_x".to_string()], allowed_tools: Some(vec!["read".to_string()]), denied_tools: vec!["bash".to_string()], + allowed_subagents: None, + denied_subagents: vec![], }; 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(); assert_eq!(updated.description, "updated desc"); assert_eq!(updated.capability.denied_skills, vec!["skill_x".to_string()]); @@ -2262,6 +2351,8 @@ mod tests { Some("new desc"), None, None, + Some(&None), + Some(&None), false, ); assert!(result.is_err()); diff --git a/src/tools/task/types.rs b/src/tools/task/types.rs index f5e72cc..db2fd6b 100644 --- a/src/tools/task/types.rs +++ b/src/tools/task/types.rs @@ -70,6 +70,10 @@ pub struct SubagentDef { pub source: SubagentSource, /// 文件路径(仅自定义类型) pub path: Option, + /// 可选的 provider 名(引用 config.json 的 providers 表)。None 时继承主智能体。 + pub provider: Option, + /// 可选的 model 名(引用 config.json 的 models 表)。None 时继承主智能体。 + pub model: Option, } impl SubagentDef { @@ -84,6 +88,8 @@ impl SubagentDef { max_execution_secs: None, source: SubagentSource::Builtin, path: None, + provider: None, + model: None, } } } diff --git a/src/tools/todo_read.rs b/src/tools/todo_read.rs index 3665124..471d96a 100644 --- a/src/tools/todo_read.rs +++ b/src/tools/todo_read.rs @@ -187,6 +187,7 @@ mod tests { task_id: None, parent_task_id: None, tool_call_id: None, + parent_capability: None, } } diff --git a/src/tools/todo_write.rs b/src/tools/todo_write.rs index a9d7d49..58c605a 100644 --- a/src/tools/todo_write.rs +++ b/src/tools/todo_write.rs @@ -472,6 +472,7 @@ mod tests { task_id: None, parent_task_id: None, tool_call_id: None, + parent_capability: None, } }