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

- 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:
iven
2026-04-07 09:21:49 +08:00
parent c7ffba196a
commit e1f3a9719e
4 changed files with 409 additions and 4 deletions

View File

@@ -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);
}
}