feat: 集成专家提示词到网关配置、依赖注入与HTTP API

- config: 新增 ExpertsConfig,默认 sources=[user,project], enabled=true

- AgentFactory: 新增 experts 字段,providers 顺序为 Agent→Skill→Expert→Subagent→Todo

- GatewayState: 持有 Arc<ExpertRuntime>,from_config 中初始化

- runtime: build_session_manager_with_sender 接收 experts 参数

- 路由: 注册 7 个专家 API (list/toggle/create/update/delete/selected/select)

- http: 实现 7 个处理器,支持专家 CRUD 与 session 级选择

- session/cli: 测试便利构造器与 empty_config/build_config 补齐 experts 字段
This commit is contained in:
oudecheng 2026-07-06 18:07:33 +08:00
parent c2d9bf8b5e
commit 9d7c1f2e52
7 changed files with 408 additions and 1 deletions

View File

@ -79,6 +79,7 @@ impl InitWizard {
mcp_servers: HashMap::new(), mcp_servers: HashMap::new(),
image_context: crate::config::ImageContextConfig::default(), image_context: crate::config::ImageContextConfig::default(),
subagents: crate::config::SubagentsConfig::default(), subagents: crate::config::SubagentsConfig::default(),
experts: crate::config::ExpertsConfig::default(),
} }
} }
@ -832,6 +833,7 @@ impl InitWizard {
mcp_servers: existing.mcp_servers.clone(), mcp_servers: existing.mcp_servers.clone(),
image_context: existing.image_context.clone(), image_context: existing.image_context.clone(),
subagents: existing.subagents.clone(), subagents: existing.subagents.clone(),
experts: existing.experts.clone(),
} }
} }

View File

@ -38,6 +38,8 @@ pub struct Config {
pub image_context: ImageContextConfig, pub image_context: ImageContextConfig,
#[serde(default)] #[serde(default)]
pub subagents: SubagentsConfig, pub subagents: SubagentsConfig,
#[serde(default)]
pub experts: ExpertsConfig,
} }
/// 图片上下文限制配置 /// 图片上下文限制配置
@ -169,6 +171,34 @@ impl Default for SubagentsConfig {
} }
} }
/// 专家提示词配置
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ExpertsConfig {
/// 是否启用专家发现与注入
#[serde(default = "default_experts_enabled")]
pub enabled: bool,
/// 定义来源优先级
#[serde(default = "default_experts_sources")]
pub sources: Vec<String>,
}
fn default_experts_enabled() -> bool {
true
}
fn default_experts_sources() -> Vec<String> {
vec!["user".to_string(), "project".to_string()]
}
impl Default for ExpertsConfig {
fn default() -> Self {
Self {
enabled: default_experts_enabled(),
sources: default_experts_sources(),
}
}
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)] #[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolsConfig { pub struct ToolsConfig {
#[serde(default)] #[serde(default)]

View File

@ -2,6 +2,8 @@ use std::sync::Arc;
use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider}; use crate::agent::{AgentError, AgentLoop, CompositeSystemPromptProvider};
use crate::config::LLMProviderConfig; use crate::config::LLMProviderConfig;
use crate::experts::ExpertPromptProvider;
use crate::experts::ExpertRuntime;
use crate::gateway::agent_prompt_provider::AgentPromptProvider; use crate::gateway::agent_prompt_provider::AgentPromptProvider;
use crate::gateway::todo_prompt_provider::TodoPromptProvider; use crate::gateway::todo_prompt_provider::TodoPromptProvider;
use crate::skills::{SkillPromptProvider, SkillRuntime}; use crate::skills::{SkillPromptProvider, SkillRuntime};
@ -14,6 +16,7 @@ use crate::tools::{ToolContext, ToolRegistry};
pub(crate) struct AgentFactory { pub(crate) struct AgentFactory {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
@ -38,6 +41,7 @@ impl AgentFactory {
pub(crate) fn new( pub(crate) fn new(
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
experts: Arc<ExpertRuntime>,
subagent_runtime: Arc<SubagentRuntime>, subagent_runtime: Arc<SubagentRuntime>,
reinject_every: usize, reinject_every: usize,
prompt_repository: Arc<dyn PromptInjectionRepository>, prompt_repository: Arc<dyn PromptInjectionRepository>,
@ -52,6 +56,7 @@ impl AgentFactory {
Self { Self {
tools, tools,
skills, skills,
experts,
subagent_runtime, subagent_runtime,
reinject_every, reinject_every,
prompt_repository, prompt_repository,
@ -74,6 +79,7 @@ impl AgentFactory {
); );
// 创建组合的系统提示词提供者 // 创建组合的系统提示词提供者
// 顺序AgentPrompt → SkillPrompt → ExpertPrompt → SubagentPrompt → TodoPrompt
let system_prompt_provider = Arc::new(CompositeSystemPromptProvider::new(vec![ let system_prompt_provider = Arc::new(CompositeSystemPromptProvider::new(vec![
Box::new(AgentPromptProvider::new( Box::new(AgentPromptProvider::new(
self.reinject_every, self.reinject_every,
@ -81,6 +87,7 @@ impl AgentFactory {
self.prompt_repository.clone(), self.prompt_repository.clone(),
)), )),
Box::new(SkillPromptProvider::new(self.skills.clone())), Box::new(SkillPromptProvider::new(self.skills.clone())),
Box::new(ExpertPromptProvider::new(self.experts.clone())),
Box::new(SubagentPromptProvider::new(self.subagent_runtime.clone())), Box::new(SubagentPromptProvider::new(self.subagent_runtime.clone())),
Box::new(TodoPromptProvider::new()), Box::new(TodoPromptProvider::new()),
])); ]));

View File

@ -1,10 +1,11 @@
use axum::{Json, extract::State}; use axum::{Json, extract::{Query, State}};
use axum::http::StatusCode; use axum::http::StatusCode;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::sync::Arc; use std::sync::Arc;
use super::GatewayState; use super::GatewayState;
use crate::config::{Config, get_default_config_path}; use crate::config::{Config, get_default_config_path};
use crate::experts::{Expert, ExpertScope, ExpertWithStatus};
use crate::skills::SkillWithStatus; use crate::skills::SkillWithStatus;
use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus}; use crate::tools::task::runtime::{SubagentScope, SubagentWithStatus};
@ -410,3 +411,340 @@ pub async fn subagents_toggle(
} }
} }
} }
// ===================== Experts =====================
#[derive(Deserialize)]
pub struct ExpertToggleRequest {
pub name: String,
pub scope: String,
pub enabled: bool,
}
#[derive(Serialize)]
pub struct ExpertToggleResponse {
success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
changed: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
available: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
disabled_in_scopes: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertListResponse {
experts_system_enabled: bool,
total: usize,
experts: Vec<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertCreateRequest {
pub name: String,
pub description: String,
pub body: String,
pub scope: String,
}
#[derive(Deserialize)]
pub struct ExpertUpdateRequest {
pub name: String,
pub scope: String,
pub description: Option<String>,
pub body: Option<String>,
}
#[derive(Deserialize)]
pub struct ExpertDeleteRequest {
pub name: String,
pub scope: String,
}
#[derive(Deserialize)]
pub struct ExpertSelectedQuery {
pub session_id: String,
}
#[derive(Serialize)]
pub struct ExpertSelectedResponse {
pub expert_name: Option<String>,
pub expert: Option<ExpertWithStatus>,
}
#[derive(Deserialize)]
pub struct ExpertSelectRequest {
pub session_id: String,
pub expert_name: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertSelectResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
#[derive(Serialize)]
pub struct ExpertResponse {
pub name: String,
pub description: String,
pub body: String,
pub source: String,
pub path: String,
}
impl From<Expert> for ExpertResponse {
fn from(expert: Expert) -> Self {
Self {
name: expert.name,
description: expert.description,
body: expert.body,
source: expert.source.as_str().to_string(),
path: expert.path.display().to_string(),
}
}
}
#[derive(Serialize)]
pub struct ExpertDeleteResponse {
pub success: bool,
pub path: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
/// GET /api/experts — Return all discovered experts with their disabled status
pub async fn experts_list(
State(state): State<Arc<GatewayState>>,
) -> Json<ExpertListResponse> {
let experts_enabled = state.config.read().await.experts.enabled;
if !experts_enabled {
return Json(ExpertListResponse {
experts_system_enabled: false,
total: 0,
experts: vec![],
});
}
let experts = state.experts.list_experts_with_status();
let total = experts.len();
Json(ExpertListResponse {
experts_system_enabled: true,
total,
experts,
})
}
/// POST /api/experts/toggle — Enable or disable a specific expert
pub async fn experts_toggle(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertToggleRequest>,
) -> (StatusCode, Json<ExpertToggleResponse>) {
let scope = match ExpertScope::parse(&req.scope) {
Some(s) => s,
None => {
return (
StatusCode::BAD_REQUEST,
Json(ExpertToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(format!("invalid scope: {}", req.scope)),
}),
);
}
};
let result = if req.enabled {
state.experts.enable_expert(scope, &req.name)
} else {
state.experts.disable_expert(scope, &req.name)
};
match result {
Ok(change) => (
StatusCode::OK,
Json(ExpertToggleResponse {
success: true,
changed: Some(change.changed),
available: Some(change.available),
disabled_in_scopes: Some(
change
.disabled_in_scopes
.iter()
.map(|s| s.as_str().to_string())
.collect(),
),
error: None,
}),
),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
(
status,
Json(ExpertToggleResponse {
success: false,
changed: None,
available: None,
disabled_in_scopes: None,
error: Some(msg),
}),
)
}
}
}
/// POST /api/experts/create — Create a new expert
pub async fn experts_create(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertCreateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
.create_expert(scope, &req.name, &req.description, &req.body, true)
.map_err(|err| {
let status = if err.contains("already exists") {
StatusCode::CONFLICT
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
Ok(Json(ExpertResponse::from(expert)))
}
/// PUT /api/experts/update — Update an existing expert
pub async fn experts_update(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertUpdateRequest>,
) -> Result<Json<ExpertResponse>, (StatusCode, String)> {
let scope = ExpertScope::parse(&req.scope)
.ok_or_else(|| (StatusCode::BAD_REQUEST, format!("invalid scope: {}", req.scope)))?;
let expert = state
.experts
.update_expert(
scope,
&req.name,
req.description.as_deref(),
req.body.as_deref(),
true,
)
.map_err(|err| {
let status = if err.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::BAD_REQUEST
};
(status, err)
})?;
Ok(Json(ExpertResponse::from(expert)))
}
/// DELETE /api/experts/delete?name=&scope= — Delete an expert
pub async fn experts_delete(
State(state): State<Arc<GatewayState>>,
Query(req): Query<ExpertDeleteRequest>,
) -> Result<Json<ExpertDeleteResponse>, (StatusCode, Json<ExpertDeleteResponse>)> {
let scope = match ExpertScope::parse(&req.scope) {
Some(s) => s,
None => {
return Err((
StatusCode::BAD_REQUEST,
Json(ExpertDeleteResponse {
success: false,
path: String::new(),
error: Some(format!("invalid scope: {}", req.scope)),
}),
));
}
};
match state.experts.delete_expert(scope, &req.name, true) {
Ok(path) => Ok(Json(ExpertDeleteResponse {
success: true,
path: path.display().to_string(),
error: None,
})),
Err(msg) => {
let status = if msg.contains("not found") {
StatusCode::NOT_FOUND
} else {
StatusCode::INTERNAL_SERVER_ERROR
};
Err((
status,
Json(ExpertDeleteResponse {
success: false,
path: String::new(),
error: Some(msg),
}),
))
}
}
}
/// GET /api/experts/selected?session_id=... — Return the currently selected expert for a session
pub async fn experts_selected(
State(state): State<Arc<GatewayState>>,
Query(q): Query<ExpertSelectedQuery>,
) -> Json<ExpertSelectedResponse> {
let expert_name = state.experts.selected_expert_name_for(&q.session_id);
let expert = expert_name.as_ref().and_then(|name| {
// Build an ExpertWithStatus from the discovered catalog.
state
.experts
.list_experts_with_status()
.into_iter()
.find(|e| &e.name == name)
});
Json(ExpertSelectedResponse {
expert_name,
expert,
})
}
/// POST /api/experts/select — Select (or clear) the expert for a session
pub async fn experts_select(
State(state): State<Arc<GatewayState>>,
Json(req): Json<ExpertSelectRequest>,
) -> (StatusCode, Json<ExpertSelectResponse>) {
let result = match req.expert_name {
Some(name) => state.experts.select_expert(&req.session_id, &name),
None => state.experts.clear_expert(&req.session_id),
};
match result {
Ok(()) => (
StatusCode::OK,
Json(ExpertSelectResponse {
success: true,
error: None,
}),
),
Err(msg) => (
StatusCode::BAD_REQUEST,
Json(ExpertSelectResponse {
success: false,
error: Some(msg),
}),
),
}
}

View File

@ -65,6 +65,7 @@ pub struct GatewayState {
pub restart_tx: watch::Sender<bool>, pub restart_tx: watch::Sender<bool>,
pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>, pub mcp_manager: Option<Arc<crate::mcp::client::McpClientManager>>,
pub skills: Arc<SkillRuntime>, pub skills: Arc<SkillRuntime>,
pub experts: Arc<crate::experts::ExpertRuntime>,
pub subagent_runtime: Arc<SubagentRuntime>, pub subagent_runtime: Arc<SubagentRuntime>,
} }
@ -83,6 +84,7 @@ impl GatewayState {
let session_ttl_hours = config.gateway.session_ttl_hours; let session_ttl_hours = config.gateway.session_ttl_hours;
let skills = Arc::new(SkillRuntime::from_config(config.skills.clone())); let skills = Arc::new(SkillRuntime::from_config(config.skills.clone()));
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(config.experts.clone()));
let channel_manager = ChannelManager::new(); let channel_manager = ChannelManager::new();
let bus = channel_manager.bus(); let bus = channel_manager.bus();
@ -97,6 +99,7 @@ impl GatewayState {
provider_config, provider_config,
provider_configs, provider_configs,
skills.clone(), skills.clone(),
experts.clone(),
Arc::new(BusSessionMessageSender::new(bus.clone())), Arc::new(BusSessionMessageSender::new(bus.clone())),
std::collections::HashSet::new(), std::collections::HashSet::new(),
config.tools.task.clone(), config.tools.task.clone(),
@ -125,6 +128,7 @@ impl GatewayState {
restart_tx, restart_tx,
mcp_manager, mcp_manager,
skills, skills,
experts,
subagent_runtime, subagent_runtime,
}) })
} }
@ -239,6 +243,13 @@ pub async fn run(
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .route("/api/skills/toggle", routing::post(http::skills_toggle))
.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/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.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("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback(static_handler) .fallback(static_handler)
.with_state(state.clone()) .with_state(state.clone())
@ -253,6 +264,13 @@ pub async fn run(
.route("/api/skills/toggle", routing::post(http::skills_toggle)) .route("/api/skills/toggle", routing::post(http::skills_toggle))
.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/experts", routing::get(http::experts_list))
.route("/api/experts/toggle", routing::post(http::experts_toggle))
.route("/api/experts/create", routing::post(http::experts_create))
.route("/api/experts/update", routing::put(http::experts_update))
.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("/ws", routing::get(ws::ws_handler)) .route("/ws", routing::get(ws::ws_handler))
.fallback_service(ServeDir::new(&static_dir)) .fallback_service(ServeDir::new(&static_dir))
.with_state(state.clone()) .with_state(state.clone())

View File

@ -45,6 +45,7 @@ pub(crate) fn build_session_manager(
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
provider_configs: HashMap<String, LLMProviderConfig>, provider_configs: HashMap<String, LLMProviderConfig>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
experts: Arc<crate::experts::ExpertRuntime>,
disabled_tools: HashSet<String>, disabled_tools: HashSet<String>,
task_config: TaskConfig, task_config: TaskConfig,
subagents_config: SubagentsConfig, subagents_config: SubagentsConfig,
@ -60,6 +61,7 @@ pub(crate) fn build_session_manager(
provider_config, provider_config,
provider_configs, provider_configs,
skills, skills,
experts,
Arc::new(NoopSessionMessageSender), Arc::new(NoopSessionMessageSender),
disabled_tools, disabled_tools,
task_config, task_config,
@ -79,6 +81,7 @@ pub(crate) fn build_session_manager_with_sender(
provider_config: LLMProviderConfig, provider_config: LLMProviderConfig,
provider_configs: HashMap<String, LLMProviderConfig>, provider_configs: HashMap<String, LLMProviderConfig>,
skills: Arc<SkillRuntime>, skills: Arc<SkillRuntime>,
experts: Arc<crate::experts::ExpertRuntime>,
session_message_sender: Arc<dyn SessionMessageSender>, session_message_sender: Arc<dyn SessionMessageSender>,
disabled_tools: HashSet<String>, disabled_tools: HashSet<String>,
task_config: TaskConfig, task_config: TaskConfig,
@ -269,6 +272,7 @@ pub(crate) fn build_session_manager_with_sender(
let agent_factory = AgentFactory::new( let agent_factory = AgentFactory::new(
tools.clone(), tools.clone(),
skills.clone(), skills.clone(),
experts.clone(),
subagent_runtime.clone(), subagent_runtime.clone(),
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),

View File

@ -255,9 +255,13 @@ impl Session {
let conversations: Arc<dyn ConversationRepository> = store.clone(); let conversations: Arc<dyn ConversationRepository> = store.clone();
let skill_events: Arc<dyn SkillEventRepository> = store.clone(); let skill_events: Arc<dyn SkillEventRepository> = store.clone();
let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone(); let prompt_repository: Arc<dyn PromptInjectionRepository> = store.clone();
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(),
));
let agent_factory = AgentFactory::new( let agent_factory = AgentFactory::new(
tools, tools,
skills.clone(), skills.clone(),
experts,
subagent_runtime, subagent_runtime,
agent_prompt_reinject_every as usize, agent_prompt_reinject_every as usize,
prompt_repository.clone(), prompt_repository.clone(),
@ -670,6 +674,9 @@ impl SessionManager {
session_ttl_hours: Option<u64>, session_ttl_hours: Option<u64>,
mcp_config: crate::mcp::McpConfig, mcp_config: crate::mcp::McpConfig,
) -> Result<Self, AgentError> { ) -> Result<Self, AgentError> {
let experts = Arc::new(crate::experts::ExpertRuntime::from_config(
crate::config::ExpertsConfig::default(),
));
super::runtime::build_session_manager( super::runtime::build_session_manager(
agent_prompt_reinject_every, agent_prompt_reinject_every,
show_tool_results, show_tool_results,
@ -677,6 +684,7 @@ impl SessionManager {
provider_config, provider_config,
provider_configs, provider_configs,
skills, skills,
experts,
disabled_tools, disabled_tools,
task_config, task_config,
subagents_config, subagents_config,