feat(multi-agent): enable Director + butler delegation (Chunk 4)
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- Enable multi-agent feature by default in desktop build - Add butler delegation logic: task decomposition, expert assignment - Add ExpertTask, DelegationResult, butler_delegate() to Director - Add butler_delegate_task Tauri command bridging Director to frontend - 13 Director tests passing (6 original + 7 new butler tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -793,6 +793,246 @@ impl Default for DirectorBuilder {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Butler delegation — task decomposition and expert assignment
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A task assigned to an expert agent by the butler.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExpertTask {
|
||||
/// Unique task ID
|
||||
pub id: String,
|
||||
/// The sub-task description
|
||||
pub description: String,
|
||||
/// Assigned expert agent (if any)
|
||||
pub assigned_expert: Option<DirectorAgent>,
|
||||
/// Task category (logistics, compliance, customer, pricing, technology, general)
|
||||
pub category: String,
|
||||
/// Task priority (higher = more urgent)
|
||||
pub priority: u8,
|
||||
/// Current status
|
||||
pub status: ExpertTaskStatus,
|
||||
}
|
||||
|
||||
/// Status of an expert task.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExpertTaskStatus {
|
||||
#[default]
|
||||
Pending,
|
||||
Assigned,
|
||||
InProgress,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Result of butler delegation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DelegationResult {
|
||||
/// Original user request
|
||||
pub request: String,
|
||||
/// Decomposed sub-tasks with expert assignments
|
||||
pub tasks: Vec<ExpertTask>,
|
||||
/// Whether delegation was successful
|
||||
pub success: bool,
|
||||
/// Summary message for the user
|
||||
pub summary: String,
|
||||
}
|
||||
|
||||
impl Director {
|
||||
/// Butler receives a user request, decomposes it into sub-tasks,
|
||||
/// and assigns each to the best-matching registered expert agent.
|
||||
///
|
||||
/// If no LLM driver is available, falls back to rule-based decomposition.
|
||||
pub async fn butler_delegate(&self, user_request: &str) -> Result<DelegationResult> {
|
||||
let agents = self.get_active_agents().await;
|
||||
|
||||
// Decompose the request into sub-tasks
|
||||
let subtasks = if self.llm_driver.is_some() {
|
||||
self.decompose_with_llm(user_request).await?
|
||||
} else {
|
||||
Self::decompose_rule_based(user_request)
|
||||
};
|
||||
|
||||
// Assign experts to each sub-task
|
||||
let tasks = self.assign_experts(&subtasks, &agents).await;
|
||||
|
||||
let summary = format!(
|
||||
"已将您的需求拆解为 {} 个子任务{}。",
|
||||
tasks.len(),
|
||||
if tasks.iter().any(|t| t.assigned_expert.is_some()) {
|
||||
",已分派给对应专家"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
);
|
||||
|
||||
Ok(DelegationResult {
|
||||
request: user_request.to_string(),
|
||||
tasks,
|
||||
success: true,
|
||||
summary,
|
||||
})
|
||||
}
|
||||
|
||||
/// Use LLM to decompose a user request into structured sub-tasks.
|
||||
async fn decompose_with_llm(&self, request: &str) -> Result<Vec<ExpertTask>> {
|
||||
let driver = self.llm_driver.as_ref()
|
||||
.ok_or_else(|| ZclawError::InvalidInput("No LLM driver configured".into()))?;
|
||||
|
||||
let prompt = format!(
|
||||
r#"你是 ZCLAW 管家。请将以下用户需求拆解为 1-5 个具体子任务。
|
||||
|
||||
用户需求:{}
|
||||
|
||||
请按 JSON 数组格式输出,每个元素包含:
|
||||
- description: 子任务描述(中文)
|
||||
- category: 分类(logistics/compliance/customer/pricing/technology/general)
|
||||
- priority: 优先级 1-10
|
||||
|
||||
只输出 JSON 数组,不要其他内容。"#,
|
||||
request
|
||||
);
|
||||
|
||||
let completion_request = CompletionRequest {
|
||||
model: "default".to_string(),
|
||||
system: Some("你是任务拆解专家,只输出 JSON。".to_string()),
|
||||
messages: vec![zclaw_types::Message::User { content: prompt }],
|
||||
tools: vec![],
|
||||
max_tokens: Some(500),
|
||||
temperature: Some(0.3),
|
||||
stop: vec![],
|
||||
stream: false,
|
||||
thinking_enabled: false,
|
||||
reasoning_effort: None,
|
||||
plan_mode: false,
|
||||
};
|
||||
|
||||
match driver.complete(completion_request).await {
|
||||
Ok(response) => {
|
||||
let text: String = response.content.iter()
|
||||
.filter_map(|block| match block {
|
||||
zclaw_runtime::ContentBlock::Text { text } => Some(text.as_str()),
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
|
||||
// Try to extract JSON array from response
|
||||
let json_text = extract_json_array(&text);
|
||||
match serde_json::from_str::<Vec<serde_json::Value>>(&json_text) {
|
||||
Ok(items) => {
|
||||
let tasks: Vec<ExpertTask> = items.into_iter().map(|item| {
|
||||
ExpertTask {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: item.get("description")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("未命名任务")
|
||||
.to_string(),
|
||||
assigned_expert: None,
|
||||
category: item.get("category")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("general")
|
||||
.to_string(),
|
||||
priority: item.get("priority")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(5) as u8,
|
||||
status: ExpertTaskStatus::Pending,
|
||||
}
|
||||
}).collect();
|
||||
Ok(tasks)
|
||||
}
|
||||
Err(_) => {
|
||||
// Fallback: treat the whole request as one task
|
||||
Ok(vec![ExpertTask {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: request.to_string(),
|
||||
assigned_expert: None,
|
||||
category: "general".to_string(),
|
||||
priority: 5,
|
||||
status: ExpertTaskStatus::Pending,
|
||||
}])
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("LLM decomposition failed: {}, falling back to rule-based", e);
|
||||
Ok(Self::decompose_rule_based(request))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rule-based decomposition for when no LLM is available.
|
||||
fn decompose_rule_based(request: &str) -> Vec<ExpertTask> {
|
||||
let category = classify_delegation_category(request);
|
||||
vec![ExpertTask {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
description: request.to_string(),
|
||||
assigned_expert: None,
|
||||
category,
|
||||
priority: 5,
|
||||
status: ExpertTaskStatus::Pending,
|
||||
}]
|
||||
}
|
||||
|
||||
/// Assign each task to the best-matching expert agent.
|
||||
async fn assign_experts(
|
||||
&self,
|
||||
tasks: &[ExpertTask],
|
||||
agents: &[DirectorAgent],
|
||||
) -> Vec<ExpertTask> {
|
||||
tasks.iter().map(|task| {
|
||||
let best_match = agents.iter().find(|agent| {
|
||||
agent.role == AgentRole::Expert
|
||||
&& agent.persona.to_lowercase().contains(&task.category.to_lowercase())
|
||||
}).or_else(|| {
|
||||
// Fallback: find any expert
|
||||
agents.iter().find(|agent| agent.role == AgentRole::Expert)
|
||||
});
|
||||
|
||||
let mut assigned = task.clone();
|
||||
if let Some(expert) = best_match {
|
||||
assigned.assigned_expert = Some(expert.clone());
|
||||
assigned.status = ExpertTaskStatus::Assigned;
|
||||
}
|
||||
assigned
|
||||
}).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a request into a delegation category based on keyword matching.
|
||||
fn classify_delegation_category(text: &str) -> String {
|
||||
let lower = text.to_lowercase();
|
||||
// Check compliance first — "合规/法规/标准" are more specific than logistics keywords
|
||||
if ["合规", "法规", "标准", "认证", "报检"].iter().any(|k| lower.contains(k)) {
|
||||
"compliance".to_string()
|
||||
} else if ["物流", "发货", "出口", "包", "运输", "仓库"].iter().any(|k| lower.contains(k)) {
|
||||
"logistics".to_string()
|
||||
} else if ["客户", "投诉", "反馈", "服务", "售后"].iter().any(|k| lower.contains(k)) {
|
||||
"customer".to_string()
|
||||
} else if ["报价", "价格", "成本", "利润", "预算"].iter().any(|k| lower.contains(k)) {
|
||||
"pricing".to_string()
|
||||
} else if ["系统", "软件", "电脑", "网络", "数据"].iter().any(|k| lower.contains(k)) {
|
||||
"technology".to_string()
|
||||
} else {
|
||||
"general".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract a JSON array from text that may contain surrounding prose.
|
||||
fn extract_json_array(text: &str) -> String {
|
||||
// Try to find content between [ and ]
|
||||
if let Some(start) = text.find('[') {
|
||||
if let Some(end) = text.rfind(']') {
|
||||
if end > start {
|
||||
return text[start..=end].to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
// Return original if no array brackets found
|
||||
text.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -912,4 +1152,88 @@ mod tests {
|
||||
assert_eq!(AgentRole::from_str("STUDENT"), Some(AgentRole::Student));
|
||||
assert_eq!(AgentRole::from_str("unknown"), None);
|
||||
}
|
||||
|
||||
// -- Butler delegation tests --
|
||||
|
||||
#[test]
|
||||
fn test_classify_delegation_category() {
|
||||
assert_eq!(classify_delegation_category("这批物流要发往欧洲"), "logistics");
|
||||
assert_eq!(classify_delegation_category("出口合规标准变了"), "compliance");
|
||||
assert_eq!(classify_delegation_category("客户投诉太多了"), "customer");
|
||||
assert_eq!(classify_delegation_category("报价需要调整"), "pricing");
|
||||
assert_eq!(classify_delegation_category("系统又崩了"), "technology");
|
||||
assert_eq!(classify_delegation_category("随便聊聊"), "general");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_json_array() {
|
||||
let with_prose = "好的,分析如下:\n[{\"description\":\"分析物流\",\"category\":\"logistics\",\"priority\":8}]\n以上。";
|
||||
let result = extract_json_array(with_prose);
|
||||
assert!(result.starts_with('['));
|
||||
assert!(result.ends_with(']'));
|
||||
|
||||
let bare = "[{\"a\":1}]";
|
||||
assert_eq!(extract_json_array(bare), bare);
|
||||
|
||||
let no_array = "just text";
|
||||
assert_eq!(extract_json_array(no_array), "just text");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_rule_based_decomposition() {
|
||||
let tasks = Director::decompose_rule_based("出口包装需要整改");
|
||||
assert_eq!(tasks.len(), 1);
|
||||
// "包" matches logistics first
|
||||
assert_eq!(tasks[0].category, "logistics");
|
||||
assert_eq!(tasks[0].status, ExpertTaskStatus::Pending);
|
||||
assert!(!tasks[0].id.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_butler_delegate_rule_based() {
|
||||
let director = Director::new(DirectorConfig::default());
|
||||
|
||||
// Register an expert
|
||||
director.register_agent(DirectorAgent::new(
|
||||
AgentId::new(),
|
||||
"合规专家",
|
||||
AgentRole::Expert,
|
||||
"擅长 compliance 和 logistics 领域",
|
||||
)).await;
|
||||
|
||||
let result = director.butler_delegate("出口包装被退回了,需要整改").await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.summary.contains("拆解为"));
|
||||
assert_eq!(result.tasks.len(), 1);
|
||||
// Expert should be assigned (matches category)
|
||||
assert!(result.tasks[0].assigned_expert.is_some());
|
||||
assert_eq!(result.tasks[0].status, ExpertTaskStatus::Assigned);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_butler_delegate_no_experts() {
|
||||
let director = Director::new(DirectorConfig::default());
|
||||
// No agents registered
|
||||
let result = director.butler_delegate("帮我查一下物流状态").await.unwrap();
|
||||
assert!(result.success);
|
||||
assert!(result.tasks[0].assigned_expert.is_none());
|
||||
assert_eq!(result.tasks[0].status, ExpertTaskStatus::Pending);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expert_task_serialization() {
|
||||
let task = ExpertTask {
|
||||
id: "test-id".to_string(),
|
||||
description: "测试任务".to_string(),
|
||||
assigned_expert: None,
|
||||
category: "logistics".to_string(),
|
||||
priority: 8,
|
||||
status: ExpertTaskStatus::Assigned,
|
||||
};
|
||||
let json = serde_json::to_string(&task).unwrap();
|
||||
let decoded: ExpertTask = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(decoded.id, "test-id");
|
||||
assert_eq!(decoded.category, "logistics");
|
||||
assert_eq!(decoded.status, ExpertTaskStatus::Assigned);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user