Files
DevFlow/crates/df-ai/src/coordinator.rs
T
lxy a031521776 优化: token分项显示(in/cache/out/reasoning)+ 详情面板 + base前置
token分项(各计费不同,不显 total):df-ai 解析 provider cache/reasoning(openai_compat prompt_cache_hit/miss/reasoning_tokens + anthropic cache_read/creation)+ TokenUsage 加字段(全构造点)+ AiMessage/AiCompleted/DB V39(ai_messages 加 cache_hit/miss/reasoning 列)+ message_repo 映射(持久化)+ 前端 MessageList 显 in·cache·out·reason(in=cache_miss 全价,reasoning 有才显)+ 点击 token 弹详情面板(完整 usage+缓存命中率+model)+ df-miniapp 同步

base前置(提升 prompt cache 命中率):chat.rs aug 拼 base 后(4处)+ knowledge_inject 知识拼 base 后(固定 base 前缀,cache 命中)

附修:replace_conversation 原 13 列 INSERT 丢消息级 token → 改 18 列
2026-08-03 01:22:30 +08:00

1758 lines
62 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 多 Agent 协作调度中心 — Coordinator
//!
//! Phase 1 实现,Phase 2 LLM 扩展
//!
//! 职责拆解(4 步):
//! 1. **decompose**:接收用户意图 + 消息 → 拆解为 SubTask 列表
//! 2. **分配**:为每个 SubTask 分配人设(PersonaRegistry.recommend_for_intent
//! 3. **dispatch**:并行执行 SubTask(当前 Phase 1 串行,预留 JoinSet 并行)
//! 4. **merge**:汇总子结果 → 合并产出 → 处理冲突
use crate::persona::PersonaRegistry;
use crate::planner::{Plan, SubTask, ValidateOptions};
use crate::provider::{ChatMessage, CompletionRequest, LlmProvider};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::Arc;
// ---- Plan-driven LLM 规划开关(Phase 1) -------------------------------------
/// aichat Plan-driven Phase 1 总开关(LLM 规划端)。
///
/// 默认 **关**(gradual 灰度,对齐 memory `ai-improvement-principles`「每改进配开关 +
/// 默认关 + 兜底可回退」)。开启时 `decompose_with_llm` 在 agentic loop 入口被调用,
/// 由 LLM 生成 Plan JSON(替代 `decompose` 关键词匹配)。
///
/// 与 `plan_executor::PLAN_EXECUTION_ENABLED`(Plan 执行端开关)正交:
/// - 本开关治「Plan 从哪来」(LLM 出 Plan);
/// - 执行端开关治「Plan 怎么执行」(JoinSet 并行 / 串行)。
///
/// **关时零行为变更**:agentic loop 入口走 `decompose`(关键词匹配)旧行为,
/// ReAct 主链不受影响。
static AICHAT_PLAN_ENABLED: AtomicBool = AtomicBool::new(false);
/// 设置 aichat Plan-driven 规划开关(运行时热切换,IPC / 前端可调)。
pub fn set_aichat_plan_enabled(enabled: bool) {
AICHAT_PLAN_ENABLED.store(enabled, Ordering::SeqCst);
tracing::info!(enabled, "[PLAN-LLM] aichat Plan-driven 规划开关已更新");
}
/// 读取 aichat Plan-driven 规划开关。
pub fn aichat_plan_enabled() -> bool {
AICHAT_PLAN_ENABLED.load(Ordering::SeqCst)
}
// ---- Token 预算池 ------------------------------------------------------------
/// 全局 Token 预算池(CAS 无锁并发安全)
/// 多个 SubTask 启动前向预算池申请估算额度,超限时降级串行(不拒绝执行)。
#[derive(Debug)]
pub struct TokenBudgetPool {
total: AtomicU64,
consumed: AtomicU64,
}
impl TokenBudgetPool {
/// 创建预算池。total=0 表示不限制(等价无限)。
pub fn new(total: u64) -> Arc<Self> {
Arc::new(Self {
total: AtomicU64::new(total),
consumed: AtomicU64::new(0),
})
}
/// 尝试预占额度。成功返回 true,超限返回 false。
/// total=0 时不限制,始终返回 true。
pub fn try_reserve(&self, estimate: u64) -> bool {
let total = self.total.load(Ordering::SeqCst);
if total == 0 {
return true;
}
let mut consumed = self.consumed.load(Ordering::SeqCst);
loop {
if consumed + estimate > total {
return false;
}
match self.consumed.compare_exchange_weak(
consumed,
consumed + estimate,
Ordering::SeqCst,
Ordering::SeqCst,
) {
Ok(_) => return true,
Err(actual) => consumed = actual,
}
}
}
/// 已消耗额度
pub fn consumed(&self) -> u64 {
self.consumed.load(Ordering::SeqCst)
}
/// 总预算(0=不限制)
pub fn total(&self) -> u64 {
self.total.load(Ordering::SeqCst)
}
}
// ---- 枚举 --------------------------------------------------------------------
/// 调度策略枚举
pub enum DispatchStrategy {
/// 规则驱动(关键词匹配,Phase 1 MVP)
RuleBased,
/// LLM 驱动(LLM 生成 PlanPhase 2 预留)
LLMDriven,
}
impl DispatchStrategy {
/// 策略名称标签(日志 / 审计用)
pub fn name(&self) -> &'static str {
match self {
Self::RuleBased => "rule_based",
Self::LLMDriven => "llm_driven",
}
}
}
// ---- 数据结构 ----------------------------------------------------------------
/// 拆解结果
pub struct DecompositionResult {
/// 拆解出的子任务列表
pub subtasks: Vec<SubTask>,
/// 对应 Plan 结构(含依赖关系)
pub plan: Plan,
}
/// 执行结果
pub struct ExecutionResult {
/// 对应 SubTask id
pub subtask_id: String,
/// 分配的人设 id
pub persona_id: String,
/// 执行产出(工具结果摘要 / LLM 回复)
pub output: String,
/// 执行是否成功
pub success: bool,
}
/// 合并结果
pub struct MergeResult {
/// 合并后的最终产出
pub merged_output: String,
/// 检测到的冲突列表(Phase 2 由 reviewer Agent 填充)
pub conflicts: Vec<ConflictItem>,
}
/// 冲突项
pub struct ConflictItem {
/// 冲突涉及的文件
pub file: String,
/// 冲突描述
pub description: String,
/// 冲突双方 SubTask id
pub subtask_a: Option<String>,
pub subtask_b: Option<String>,
/// Reviewer 仲裁推荐(Phase 2 LLM 仲裁填充)
pub recommendation: Option<ConflictResolution>,
}
/// 冲突解决方案
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConflictResolution {
/// 接受 A 的改动
AcceptA,
/// 接受 B 的改动
AcceptB,
/// 合并两者
Merged,
/// 用户手动处理
Manual,
}
impl ConflictResolution {
pub fn as_str(&self) -> &'static str {
match self {
Self::AcceptA => "a",
Self::AcceptB => "b",
Self::Merged => "merged",
Self::Manual => "manual",
}
}
}
// ---- Coordinator 实现 --------------------------------------------------------
/// 多 Agent 协作调度器
///
/// 当前实现:
/// - 规则驱动 decompose(关键词匹配)
/// - dispatch_with_budget: JoinSet 层内并行 + token 预算管控
/// - dispatch_serial: 串行降级路径(无 'static 约束)
/// - merge: 拼接产出 + 同文件冲突检测
///
/// Phase 2 预留:
/// - LLM 驱动 decompose
/// - Reviewer Agent 仲裁
pub struct Coordinator {
/// 人设注册表(内置 5 人设 + 自定义)
registry: PersonaRegistry,
}
impl Coordinator {
/// 创建 Coordinator,绑定人设注册表
pub fn new(registry: PersonaRegistry) -> Self {
Self { registry }
}
/// 规则拆解:intent + text → SubTask 列表 + Plan
///
/// Phase 1 规则(关键词匹配):
/// - 检测 text 中是否含 `read`/`写`/`查看`/`分析`/`search` 等关键词 → 读子任务
/// - 检测 text 中是否含 `write`/`修改`/`生成`/`create`/`fix` 等关键词 → 写子任务
/// - 同时含读+写关键词 → 两个子任务,写依赖读(read → write)
/// - 每 SubTask 分配人设(`PersonaRegistry::recommend_for_intent`
/// - 兜底:无法匹配任何关键词时产单个 default 子任务
///
/// ## Phase 2 扩展点
/// `DispatchStrategy::LLMDriven` 时改为调 LLM 生成 Plan 整体结构,
/// 本方法当前仅走 `RuleBased` 路径。
pub fn decompose(&self, intent: &str, text: &str) -> DecompositionResult {
let lower = text.to_lowercase();
// 规则 1: 读操作关键词
let has_read = lower.contains("read")
|| lower.contains("查看")
|| lower.contains("读取")
|| lower.contains("分析")
|| lower.contains("search")
|| lower.contains("搜索")
|| lower.contains("审查")
|| lower.contains("review");
// 规则 2: 写操作关键词
let has_write = lower.contains("write")
|| lower.contains("写")
|| lower.contains("修改")
|| lower.contains("生成")
|| lower.contains("创建")
|| lower.contains("implement")
|| lower.contains("fix")
|| lower.contains("修复")
|| lower.contains("新增");
let mut subtasks = Vec::new();
match (has_read, has_write) {
(true, true) => {
// 读 + 写 → 两个子任务:read → write
subtasks.push(
SubTask::new("read", "读取/分析现有代码")
.with_tools(vec![
"read_file".into(),
"search_files".into(),
"list_directory".into(),
])
.with_deps(vec![]),
);
subtasks.push(
SubTask::new("write", "修改/生成代码")
.with_tools(vec![
"write_file".into(),
"patch_file".into(),
"edit_file".into(),
])
.with_deps(vec!["read".into()]),
);
}
(true, false) => {
// 纯读 → 单个读子任务
subtasks.push(
SubTask::new("read", "读取/分析代码")
.with_tools(vec![
"read_file".into(),
"search_files".into(),
"list_directory".into(),
])
.with_deps(vec![]),
);
}
(false, true) => {
// 纯写 → 单个写子任务
subtasks.push(
SubTask::new("write", "修改/生成代码")
.with_tools(vec![
"write_file".into(),
"patch_file".into(),
"edit_file".into(),
])
.with_deps(vec![]),
);
}
(false, false) => {
// 兜底:按 intent 分配单个默认子任务
subtasks.push(
SubTask::new("default", intent.to_string())
.with_tools(vec![])
.with_deps(vec![]),
);
}
}
let plan = Plan::from_tasks(subtasks.clone());
DecompositionResult { subtasks, plan }
}
/// 推荐人设 id(供 agentic loop 构建 AiPlanCreated 事件载荷时映射 persona_id)。
///
/// 暴露 registry.recommend_for_intent,使外部(无需自行持有 PersonaRegistry)
/// 能把 SubTask.intent → persona_id 映射填充到 SubTaskInfo.persona_id。
pub fn recommend_persona_id(&self, intent: &str) -> Option<String> {
Some(self.registry.recommend_for_intent(intent).id.clone())
}
/// LLM 驱动拆解(Plan-driven Phase 1):intent + text → LLM 出 Plan JSON → Plan。
///
/// 替代 [`Self::decompose`] 的关键词匹配——LLM 在 system prompt 引导下出
/// 「步骤数组,每步含 tool_hint + risk + deps」的结构化 JSON,经 serde 解析成
/// [`Plan`] 后用 [`Plan::validate_with`] 兜底校验。
///
/// ## 参数
/// - `provider`:LLM Provider(`&dyn LlmProvider`,调用方经 build_provider_for 构造)
/// - `model`:模型 id(`select_model_id` 路由结果 / 兜底 default_model)
/// - `intent`:意图标签(intent.rs IntentRecognizer 推断,作上下文提示)
/// - `text`:用户原始消息(规划素材,末条 active user 消息)
/// - `available_tools`:可用工具名清单(喂给 LLM 限定 tool_hint 取值域,防幻觉工具名)
///
/// ## 返回值
/// - `Ok(Some(result))`:LLM 出 Plan 且 validate 通过 → 走 Plan 路径
/// - `Ok(None)`:LLM 调用失败 / JSON 解析失败 / validate 失败 → **回退纯 ReAct**
/// (调用方据 None 不进 Plan 分支,继续单链 ReAct,不阻断主流程)
///
/// ## 兜底(对齐 memory `ai-improvement-principles`「每改进配兜底 + 可回退」)
/// 三重兜底:provider.complete 失败 / serde 解析失败 / validate 失败 → 均 `Ok(None)`。
/// 调用方 agentic loop 收 None 后不阻断,继续走 ReAct 主链(零回归)。
pub async fn decompose_with_llm(
&self,
provider: &dyn LlmProvider,
model: &str,
intent: &str,
text: &str,
available_tools: &[String],
) -> Option<DecompositionResult> {
// 1) 构造 system prompt + user prompt,调 LLM 出 Plan JSON
let system_prompt = plan_llm_system_prompt(available_tools);
let user_prompt = format!(
"用户意图标签: {}\n\n用户消息:\n{}\n\n请输出执行计划 JSON。",
intent, text
);
let request = CompletionRequest {
model: model.to_string(),
messages: vec![
ChatMessage::system(system_prompt),
ChatMessage::user(user_prompt),
],
temperature: Some(0.3),
max_tokens: Some(2048),
stream: false,
tools: None,
tool_choice: None,
reasoning_content: None,
};
// 2) 调 LLM(无超时:provider.complete 自身语义,调用方可包 tokio::time::timeout)
let resp = match provider.complete(request).await {
Ok(r) => r,
Err(e) => {
tracing::warn!(
intent = intent,
"[PLAN-LLM] LLM 调用失败,回退纯 ReAct: {}",
e
);
return None;
}
};
// 3) 解析 JSON(允许 LLM 包 markdown 代码围栏 / 前后杂文本)
let plan_json: PlanLlmOutput = match parse_plan_json(&resp.text) {
Some(p) => p,
None => {
tracing::warn!(
intent = intent,
text_preview = %resp.text.chars().take(200).collect::<String>(),
"[PLAN-LLM] JSON 解析失败,回退纯 ReAct"
);
return None;
}
};
// 4) 转 SubTask/Plan + validate 兜底
// require_tools=false:LLM 可能产「思考/协调」类无工具步骤(纯编排节点),
// 关 require_tools 避免误拒(对齐 plan_hint 场景允许无工具子任务)。
let subtasks: Vec<SubTask> = plan_json.into_subtasks();
if subtasks.is_empty() {
tracing::warn!("[PLAN-LLM] LLM 返回空步骤列表,回退纯 ReAct");
return None;
}
let plan = Plan::from_tasks(subtasks.clone());
let opts = ValidateOptions {
require_tools: false,
max_depth: crate::planner::MAX_PLAN_DEPTH,
};
let errs = plan.validate_with(opts);
if !errs.is_empty() {
tracing::warn!(
task_count = plan.tasks.len(),
errors = ?errs,
"[PLAN-LLM] Plan validate 失败,回退纯 ReAct"
);
return None;
}
tracing::info!(
intent = intent,
task_count = subtasks.len(),
"[PLAN-LLM] LLM 规划成功"
);
Some(DecompositionResult {
subtasks,
plan,
})
}
/// 分发执行:按 Plan 分层执行 SubTask(层间串行 + 层内并行)
///
/// - 层间串行:上层全部 done 才进下一层(DAG 依赖保证)
/// - 层内并行:同层 SubTask 用 tokio::task::JoinSet 并发执行
/// - Token 预算超限时降级为串行(不拒绝执行)
///
/// ## 参数
/// - `plan`: 待执行的 DAG Plan
/// - `executor`: 子任务执行函数(接收 SubTask + persona_id 字符串)
/// - `budget`: Token 预算池(None=不限制)
///
/// ## 返回值
/// 按 Plan 原始 tasks 顺序排列的执行结果列表。
pub async fn dispatch_with_budget<F, Fut>(
&self,
plan: &Plan,
executor: F,
budget: Option<&Arc<TokenBudgetPool>>,
) -> Vec<ExecutionResult>
where
// 'static + Clone 要求:JoinSet::spawn 需要 owned 闭包(不能借用 self/db)。
// 调用方需用 Arc 包裹共享状态(db/session)传入闭包。
// 不需要并行时用 dispatch_serial(无 'static 约束)。
F: Fn(SubTask, String) -> Fut + Clone + Send + Sync + 'static,
Fut: std::future::Future<Output = ExecutionResult> + Send,
{
let layers = match plan.to_layers() {
Ok(l) => l,
Err(_) => {
tracing::warn!("[COORDINATOR] DAG 分层失败,降级串行");
let mut results = Vec::new();
for task in &plan.tasks {
let persona_id = self.registry.recommend_for_intent(&task.intent).id.clone();
let result = executor(task.clone(), persona_id).await;
results.push(result);
}
return results;
}
};
// 预分配每个 SubTask 的 persona_id(避免在 JoinSet 内借用 self)
let task_persona: std::collections::HashMap<String, String> = plan
.tasks
.iter()
.map(|t| {
(t.id.clone(), self.registry.recommend_for_intent(&t.intent).id.clone())
})
.collect();
let mut results_map: std::collections::HashMap<String, ExecutionResult> =
std::collections::HashMap::new();
for (layer_idx, layer) in layers.iter().enumerate() {
if layer.len() <= 1 {
for task in layer {
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
let result = executor(task.clone(), pid).await;
results_map.insert(result.subtask_id.clone(), result);
}
continue;
}
let can_parallel = budget
.map(|b| b.try_reserve(layer.len() as u64 * 10_000)) // 每层按 SubTask 数累加
.unwrap_or(true);
if can_parallel {
tracing::info!(
layer = layer_idx,
count = layer.len(),
"[COORDINATOR] 层 {} 并行执行 {} 个子任务",
layer_idx,
layer.len()
);
let mut join_set: tokio::task::JoinSet<ExecutionResult> =
tokio::task::JoinSet::new();
for task in layer {
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
let exec = executor.clone();
let task = task.clone();
join_set.spawn(async move {
exec(task, pid).await
});
}
while let Some(join_result) = join_set.join_next().await {
match join_result {
Ok(result) => {
results_map.insert(result.subtask_id.clone(), result);
}
Err(e) => {
tracing::error!("[COORDINATOR] 子任务 panic: {}", e);
}
}
}
} else {
tracing::info!(
layer = layer_idx,
"[COORDINATOR] Token 预算超限,层 {} 降级串行", layer_idx
);
for task in layer {
let pid = task_persona.get(&task.id).cloned().unwrap_or_default();
let result = executor(task.clone(), pid).await;
results_map.insert(result.subtask_id.clone(), result);
}
}
}
plan.tasks
.iter()
.filter_map(|t| results_map.remove(&t.id))
.collect()
}
/// 串行 dispatch(降级/Phase 1 兼容路径)
pub async fn dispatch<F, Fut>(&self, plan: &Plan, executor: F) -> Vec<ExecutionResult>
where
F: Fn(SubTask, crate::persona::AgentPersona) -> Fut,
Fut: std::future::Future<Output = ExecutionResult>,
{
self.dispatch_serial(plan, &executor).await
}
/// 内部串行执行(无并行)
async fn dispatch_serial<F, Fut>(&self, plan: &Plan, executor: &F) -> Vec<ExecutionResult>
where
F: Fn(SubTask, crate::persona::AgentPersona) -> Fut,
Fut: std::future::Future<Output = ExecutionResult>,
{
let mut results = Vec::new();
for task in &plan.tasks {
let persona = self.registry.recommend_for_intent(&task.intent);
let result = executor(task.clone(), persona.clone()).await;
results.push(result);
}
results
}
/// 合并:汇总子结果 → 合并产出 + 冲突检测
///
/// - 成功的 SubTask 产出拼接为 merged_output
/// - 失败的 SubTask 跳过(不参与合并)
/// - 冲突检测:多个 SubTask 写同一文件路径 → 标记冲突
pub fn merge(&self, results: &[ExecutionResult]) -> MergeResult {
let success_results: Vec<&ExecutionResult> =
results.iter().filter(|r| r.success).collect();
let parts: Vec<String> = success_results
.iter()
.map(|r| {
format!(
"## SubTask: {} (Persona: {})\n\n{}",
r.subtask_id, r.persona_id, r.output
)
})
.collect();
// 冲突检测:检查是否有多个 SubTask 改了同一文件
let conflicts = self.detect_file_conflicts(results);
MergeResult {
merged_output: parts.join("\n\n---\n\n"),
conflicts,
}
}
/// 检测文件冲突:从 ExecutionResult 中提取写入的文件路径,同路径 → 冲突
/// Phase 1:基于 output 文本中的文件路径关键词(简化检测)
/// Phase 2:从 Git worktree 的 git diff 提取精确路径
fn detect_file_conflicts(&self, results: &[ExecutionResult]) -> Vec<ConflictItem> {
use std::collections::HashMap;
// 收集每个 SubTask 写入的文件路径(从 output 中提取)
let mut file_map: HashMap<String, Vec<String>> = HashMap::new();
for r in results {
if !r.success {
continue;
}
let files = extract_written_files(&r.output);
for f in files {
file_map.entry(f).or_default().push(r.subtask_id.clone());
}
}
// 同文件被多个 SubTask 写 → 冲突
let mut conflicts = Vec::new();
for (file, subtasks) in &file_map {
if subtasks.len() > 1 {
conflicts.push(ConflictItem {
file: file.clone(),
description: format!(
"文件 {} 被 {} 个子任务同时修改: {}",
file,
subtasks.len(),
subtasks.join(", ")
),
subtask_a: subtasks.first().cloned(),
subtask_b: subtasks.get(1).cloned(),
recommendation: None,
});
}
}
conflicts
}
/// 仲裁冲突:对每个冲突给出推荐解决方案
///
/// Phase 1:规则驱动(简单启发式)
/// - 只有一方成功的冲突 → 推荐成功方
/// - 两方都成功 → 推荐 Merged(需人工确认)
/// - 无法判断 → Manual(留给用户)
///
/// Phase 2 预留:LLM 驱动(读 diff → 推荐方案 + 理由)
pub fn arbitrate_conflicts(
&self,
conflicts: &mut [ConflictItem],
results: &[ExecutionResult],
) {
let success_ids: std::collections::HashSet<&str> =
results.iter().filter(|r| r.success).map(|r| r.subtask_id.as_str()).collect();
for c in conflicts.iter_mut() {
let a_ok = c.subtask_a.as_deref().map(|id| success_ids.contains(id)).unwrap_or(false);
let b_ok = c.subtask_b.as_deref().map(|id| success_ids.contains(id)).unwrap_or(false);
c.recommendation = Some(match (a_ok, b_ok) {
(true, false) => ConflictResolution::AcceptA,
(false, true) => ConflictResolution::AcceptB,
(true, true) => ConflictResolution::Merged,
(false, false) => ConflictResolution::Manual,
});
tracing::info!(
file = %c.file,
recommendation = ?c.recommendation,
"[REVIEWER] 冲突仲裁推荐"
);
}
}
}
/// 从执行产出文本中提取被写入的文件路径(简化检测:匹配 write_file/patch_file 后的路径)
fn extract_written_files(output: &str) -> Vec<String> {
let mut files = Vec::new();
for line in output.lines() {
let trimmed = line.trim();
// 匹配 "write_file: path" 或 "patch_file: path" 或 "写入: path" 模式
for prefix in ["write_file:", "patch_file:", "写入:", "修改:"] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
let path = rest.trim().split_whitespace().next().unwrap_or("");
if !path.is_empty() {
files.push(path.to_string());
}
}
}
}
files
}
// ---- Plan-driven LLM 规划辅助(Phase 1) --------------------------------------
/// LLM 输出的 Plan JSON 中间结构(serde 反序列化用)。
///
/// LLM 出形如:
/// ```json
/// { "steps": [
/// { "id": "read", "intent": "读取代码", "tools": ["read_file"], "deps": [] },
/// { "id": "write", "intent": "修改代码", "tools": ["patch_file"], "deps": ["read"] }
/// ] }
/// ```
/// `risk` 字段可选(LLM 可能省略,默认 "low");`group` 可选(并行组 hint)。
/// 字段命名走宽松容错:tools/deps 任一缺失均回退空 Vec(serde default)。
#[derive(Debug, serde::Deserialize)]
struct PlanLlmStep {
/// 子任务 id(任务内唯一)。空或缺失 → 转换时按序号兜底生成。
#[serde(default)]
id: String,
/// 意图描述(自由文本)。
#[serde(default)]
intent: String,
/// 可用工具名子集(hint,非强制)。缺失 → 空 Vec。
#[serde(default)]
tools: Vec<String>,
/// 依赖前驱 id 列表。缺失 → 空 Vec。
#[serde(default)]
deps: Vec<String>,
/// 并行组 hint(可选)。缺失 → None。
#[serde(default)]
group: Option<String>,
}
/// Plan JSON 顶层结构:仅含 steps 数组。
#[derive(Debug, serde::Deserialize)]
struct PlanLlmOutput {
#[serde(default)]
steps: Vec<PlanLlmStep>,
}
impl PlanLlmOutput {
/// 转换为 SubTask 列表(去空 id 兜底生成,去重 id 保留首个)。
fn into_subtasks(self) -> Vec<SubTask> {
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut out: Vec<SubTask> = Vec::new();
for (idx, step) in self.steps.into_iter().enumerate() {
// 空 id → 按 step_<idx> 兜底生成,避免 validate 拒 EmptyId
let id = if step.id.trim().is_empty() {
format!("step_{}", idx)
} else {
step.id.trim().to_string()
};
// 去重(validate 也会拒 DuplicateId,此处提前过滤防脏数据)
if !seen.insert(id.clone()) {
tracing::warn!(
dup_id = %id,
"[PLAN-LLM] 重复子任务 id,跳过(防 DuplicateId)"
);
continue;
}
out.push(SubTask {
id,
tool_hint: step.tools,
deps: step.deps,
group: step.group,
intent: if step.intent.trim().is_empty() {
format!("step_{}", idx)
} else {
step.intent
},
});
}
out
}
}
/// Plan-driven LLM system prompt:引导 LLM 出结构化 Plan JSON。
///
/// 设计要点(对齐设计文档 §三 Plan 数据结构):
/// - 只输出 JSON(明确格式约定,防 LLM 输出杂文本)
/// - 工具名限定在 `available_tools` 集合内(防幻觉不存在的工具)
/// - deps 引用同 Plan 内的 id(防悬空)
/// - 单任务即可(不强制拆多步,简单问题不堆步骤)
/// - 风险高的步骤放后(顺序依赖自然表达)
fn plan_llm_system_prompt(available_tools: &[String]) -> String {
// 工具名清单(逗号分隔,LLM 据此填 tool_hint)
let tools_list = if available_tools.is_empty() {
"(未提供工具清单,可留空)".to_string()
} else {
available_tools.join(", ")
};
format!(
"你是执行计划规划器。根据用户的意图和消息,把任务拆解为**可执行的步骤**,\n\
输出为严格的 JSON 格式(只输出 JSON,不要任何解释、markdown 围栏或前后文本)。\n\
\n\
输出格式:\n\
```\n\
{{\n\
\x20 \"steps\": [\n\
\x20 {{\n\
\x20 \"id\": \"唯一短标识(如 read/write/step1)\",\n\
\x20 \"intent\": \"这步做什么(简短中文描述)\",\n\
\x20 \"tools\": [\"工具名(从下方清单选)\"],\n\
\x20 \"deps\": [\"依赖的前置步骤 id\"],\n\
\x20 \"group\": \"可选,并行组标识\"\n\
\x20 }}\n\
\x20 ]\n\
}}\n\
```\n\
\n\
可用工具清单: {tools_list}\n\
\n\
规则:\n\
1. 只输出 JSON,首字符必须是 `{{`,末字符必须是 `}}`\n\
2. steps 数组至少 1 个步骤(简单问题 1 个即可,不要为堆步骤而堆)\n\
3. tools 从上方清单选,不存在的工具不要写\n\
4. deps 只能引用同 Plan 内已定义的 id(不可悬空)\n\
5. 风险高/有副作用的步骤(写文件/跑命令)放后面,依赖前置读步骤\n\
6. 不要生成环依赖(A 依赖 B 且 B 依赖 A)"
)
}
/// 从 LLM 输出文本中提取 Plan JSON 并反序列化。
///
/// 容错:LLM 可能(a)包 markdown 代码围栏(```json ... ```);(b)前后带杂文本;
/// (c)纯 JSON。统一处理:找到首个 `{` 到末个 `}` 的子串再 serde 解析。
/// 解析失败返 None(调用方回退 ReAct)。
fn parse_plan_json(raw: &str) -> Option<PlanLlmOutput> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return None;
}
// 剥离可能的整体 markdown 代码围栏
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.unwrap_or(trimmed)
.trim_start_matches('\n');
let stripped = stripped.strip_suffix("```").unwrap_or(stripped).trim();
// 提取首个 { 到末个 } 的子串(防前后杂文本)
let start = stripped.find('{')?;
let end = stripped.rfind('}')?;
if end <= start {
return None;
}
let json_str = &stripped[start..=end];
match serde_json::from_str::<PlanLlmOutput>(json_str) {
Ok(p) => Some(p),
Err(e) => {
tracing::debug!(
json_preview = %json_str.chars().take(200).collect::<String>(),
error = %e,
"[PLAN-LLM] serde 反序列化失败"
);
None
}
}
}
// ---- 单元测试 ---------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::persona::{PersonaRegistry, PERSONA_CODER};
fn make_coord() -> Coordinator {
Coordinator::new(PersonaRegistry::new())
}
// -- decompose 路径覆盖 --
#[test]
fn decompose_read_then_write_chinese() {
let coord = make_coord();
let result = coord.decompose("modify", "请读取当前代码,然后修改它");
assert_eq!(result.subtasks.len(), 2, "读+写应拆为两个子任务");
assert_eq!(result.subtasks[0].id, "read", "第一个子任务应为读");
assert_eq!(result.subtasks[1].id, "write", "第二个子任务应为写");
assert!(
result.subtasks[1].deps.contains(&"read".to_string()),
"写子任务应依赖读子任务"
);
assert_eq!(result.plan.len(), 2);
}
#[test]
fn decompose_read_then_write_english() {
let coord = make_coord();
let result = coord.decompose("modify", "read the file and then write changes");
assert_eq!(result.subtasks.len(), 2);
assert_eq!(result.subtasks[0].id, "read");
assert_eq!(result.subtasks[1].id, "write");
assert!(result.subtasks[1].deps.contains(&"read".to_string()));
}
#[test]
fn decompose_read_only_chinese() {
let coord = make_coord();
let result = coord.decompose("review", "帮我查看这段代码有什么问题");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "read");
}
#[test]
fn decompose_read_only_search() {
let coord = make_coord();
let result = coord.decompose("search", "搜索项目中的 TODO 注释");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "read");
}
#[test]
fn decompose_write_only_generate() {
let coord = make_coord();
let result = coord.decompose("implement", "请生成一个 Rust 模块");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "write");
}
#[test]
fn decompose_write_only_fix() {
let coord = make_coord();
let result = coord.decompose("fix", "修复这个 bug");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "write");
}
#[test]
fn decompose_fallback_default() {
let coord = make_coord();
let result = coord.decompose("chat", "你好,今天天气怎么样");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "default");
}
#[test]
fn decompose_create_keyword() {
let coord = make_coord();
let result = coord.decompose("create", "创建新的 API 端点");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "write");
}
// -- dispatch --
#[test]
fn dispatch_serial_execution() {
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("read", "read code").with_deps(vec![]),
SubTask::new("write", "write code").with_deps(vec!["read".into()]),
]);
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id: persona.id,
output: format!("执行 {} 完毕", task.intent),
success: true,
}
}
}));
assert_eq!(results.len(), 2);
assert!(results[0].success);
assert!(results[1].success);
assert_eq!(results[0].subtask_id, "read");
assert_eq!(results[1].subtask_id, "write");
assert_eq!(results[0].persona_id, PERSONA_CODER);
}
#[test]
fn dispatch_empty_plan() {
let coord = make_coord();
let plan = Plan::new();
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id: persona.id,
output: String::new(),
success: true,
}
}
}));
assert!(results.is_empty(), "空 Plan 应产生空结果");
}
#[test]
fn dispatch_persona_assigned_by_intent() {
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("code", "implement feature").with_deps(vec![]),
]);
let results = futures::executor::block_on(coord.dispatch(&plan, |task, persona| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id: persona.id,
output: String::new(),
success: true,
}
}
}));
// "implement" 不触发特殊人设 → 默认 coder
assert_eq!(results[0].persona_id, PERSONA_CODER);
}
// -- merge --
#[test]
fn merge_single_result() {
let coord = make_coord();
let results = vec![ExecutionResult {
subtask_id: "read".into(),
persona_id: "reviewer".into(),
output: "分析完成,发现 3 个问题".into(),
success: true,
}];
let merged = coord.merge(&results);
assert!(merged.merged_output.contains("分析完成"));
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
}
#[test]
fn merge_multiple_results() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "read".into(),
persona_id: "reviewer".into(),
output: "代码分析结果".into(),
success: true,
},
ExecutionResult {
subtask_id: "write".into(),
persona_id: "coder".into(),
output: "修改后的代码".into(),
success: true,
},
];
let merged = coord.merge(&results);
assert!(merged.merged_output.contains("代码分析结果"));
assert!(merged.merged_output.contains("修改后的代码"));
assert!(merged.merged_output.contains("SubTask: read"));
assert!(merged.merged_output.contains("SubTask: write"));
assert!(merged.conflicts.is_empty(), "Phase 1 不应有冲突");
}
#[test]
fn merge_empty_results() {
let coord = make_coord();
let merged = coord.merge(&[]);
assert!(merged.merged_output.is_empty(), "空输入应产生空输出");
assert!(merged.conflicts.is_empty());
}
#[test]
fn merge_skips_failed_results() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "ok".into(),
persona_id: "coder".into(),
output: "成功产出".into(),
success: true,
},
ExecutionResult {
subtask_id: "fail".into(),
persona_id: "coder".into(),
output: "执行失败".into(),
success: false,
},
];
let merged = coord.merge(&results);
assert!(merged.merged_output.contains("成功产出"), "应含成功产出");
assert!(!merged.merged_output.contains("执行失败"), "不应含失败产出");
}
#[test]
fn merge_detects_same_file_conflict() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs\n新增 auth 模块".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs\n新增 payment 模块".into(),
success: true,
},
];
let merged = coord.merge(&results);
assert_eq!(merged.conflicts.len(), 1, "同文件应检测到 1 个冲突");
assert!(merged.conflicts[0].file.contains("main.rs"));
assert!(merged.conflicts[0].description.contains("A"));
assert!(merged.conflicts[0].description.contains("B"));
}
#[test]
fn merge_no_conflict_different_files() {
let coord = make_coord();
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/auth.rs".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/payment.rs".into(),
success: true,
},
];
let merged = coord.merge(&results);
assert!(merged.conflicts.is_empty(), "不同文件不应有冲突");
}
#[test]
fn merge_conflict_ignored_for_failed() {
let coord = make_coord();
// 失败的 SubTask 不参与冲突检测
let results = vec![
ExecutionResult {
subtask_id: "A".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs".into(),
success: true,
},
ExecutionResult {
subtask_id: "B".into(),
persona_id: "coder".into(),
output: "write_file: src/main.rs".into(),
success: false, // 失败
},
];
let merged = coord.merge(&results);
assert!(merged.conflicts.is_empty(), "失败的不应参与冲突检测");
}
#[test]
fn extract_files_from_output() {
let files = extract_written_files("write_file: src/main.rs\n其他内容");
assert_eq!(files, vec!["src/main.rs"]);
let files = extract_written_files("patch_file: lib/utils.rs\n修改完成");
assert!(files.contains(&"lib/utils.rs".to_string()));
let files = extract_written_files("没有写操作");
assert!(files.is_empty());
}
// -- Reviewer 仲裁 --
#[test]
fn arb_01_both_success_recommends_merged() {
let coord = make_coord();
let mut conflicts = vec![ConflictItem {
file: "main.rs".into(),
description: "冲突".into(),
subtask_a: Some("A".into()),
subtask_b: Some("B".into()),
recommendation: None,
}];
let results = vec![
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: true },
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: true },
];
coord.arbitrate_conflicts(&mut conflicts, &results);
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::Merged));
}
#[test]
fn arb_02_a_failed_recommends_b() {
let coord = make_coord();
let mut conflicts = vec![ConflictItem {
file: "main.rs".into(),
description: "冲突".into(),
subtask_a: Some("A".into()),
subtask_b: Some("B".into()),
recommendation: None,
}];
let results = vec![
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: false },
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: true },
];
coord.arbitrate_conflicts(&mut conflicts, &results);
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::AcceptB));
}
#[test]
fn arb_03_both_failed_recommends_manual() {
let coord = make_coord();
let mut conflicts = vec![ConflictItem {
file: "main.rs".into(),
description: "冲突".into(),
subtask_a: Some("A".into()),
subtask_b: Some("B".into()),
recommendation: None,
}];
let results = vec![
ExecutionResult { subtask_id: "A".into(), persona_id: "coder".into(), output: "".into(), success: false },
ExecutionResult { subtask_id: "B".into(), persona_id: "coder".into(), output: "".into(), success: false },
];
coord.arbitrate_conflicts(&mut conflicts, &results);
assert_eq!(conflicts[0].recommendation, Some(ConflictResolution::Manual));
}
// -- DispatchStrategy --
#[test]
fn dispatch_strategy_name() {
assert_eq!(DispatchStrategy::RuleBased.name(), "rule_based");
assert_eq!(DispatchStrategy::LLMDriven.name(), "llm_driven");
}
// -- Edge cases: 关键词混合边界 --
#[test]
fn decompose_review_triggers_read() {
let coord = make_coord();
// "review" 同时触发读关键词
let result = coord.decompose("review", "review this PR for me");
assert_eq!(result.subtasks.len(), 1);
assert_eq!(result.subtasks[0].id, "read");
}
#[test]
fn decompose_create_and_review_triggers_both() {
let coord = make_coord();
let result = coord.decompose("implement", "请先分析代码结构然后创建新模块");
assert_eq!(
result.subtasks.len(),
2,
"含分析和创建关键词应拆为两个子任务"
);
}
#[test]
fn decompose_plan_not_empty() {
let coord = make_coord();
let result = coord.decompose("chat", "随便聊聊");
assert!(!result.plan.is_empty(), "兜底场景也应有 Plan");
}
#[test]
fn decompose_result_holds_plan_consistent() {
let coord = make_coord();
let result = coord.decompose("modify", "读取配置文件然后更新它");
assert_eq!(
result.subtasks.len(),
result.plan.len(),
"DecompositionResult 的 subtasks 应与 plan 一致"
);
for st in &result.subtasks {
assert!(
result.plan.tasks.iter().any(|t| t.id == st.id),
"plan 应包含所有 subtask: {}",
st.id
);
}
}
// -- Token 预算池 --
#[test]
fn tok_01_budget_sufficient() {
let pool = TokenBudgetPool::new(100_000);
assert!(pool.try_reserve(10_000), "预算充足时应允许");
assert_eq!(pool.consumed(), 10_000);
assert!(pool.try_reserve(50_000), "剩余充足时应允许");
assert_eq!(pool.consumed(), 60_000);
}
#[test]
fn tok_02_budget_exceeded() {
let pool = TokenBudgetPool::new(100_000);
assert!(pool.try_reserve(98_000));
assert!(!pool.try_reserve(5_000), "超限应拒绝");
assert_eq!(pool.consumed(), 98_000, "拒绝后 consumed 不增");
}
#[test]
fn tok_03_concurrent_reserve() {
let pool = TokenBudgetPool::new(15_000);
// 模拟两个并发申请各 10k,总额 20k > 15k,只有第一个应成功
let pool1 = pool.clone();
let pool2 = pool.clone();
let r1 = pool1.try_reserve(10_000);
let r2 = pool2.try_reserve(10_000);
// 至少一个成功一个失败(顺序由调度决定)
assert!(r1 || r2, "至少一个成功");
assert!(!r1 || !r2, "不能两个都成功(总超预算)");
}
#[test]
fn tok_04_zero_means_unlimited() {
let pool = TokenBudgetPool::new(0);
assert!(pool.try_reserve(999_999_999), "total=0 不限制");
}
// -- 并行调度 --
#[tokio::test]
async fn par_01_diamond_dependency() {
// A → {B, C} → D,B/C 同层应并行,D 等 B+C 都 done
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("A", "base"),
SubTask::new("B", "branch1").with_deps(vec!["A".into()]),
SubTask::new("C", "branch2").with_deps(vec!["A".into()]),
SubTask::new("D", "final").with_deps(vec!["B".into(), "C".into()]),
]);
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id,
output: format!("executed {}", task.intent),
success: true,
}
}
}, None).await;
assert_eq!(results.len(), 4, "全部 4 个子任务应有结果");
// 结果按 plan.tasks 原始顺序
assert_eq!(results[0].subtask_id, "A");
assert_eq!(results[1].subtask_id, "B");
assert_eq!(results[2].subtask_id, "C");
assert_eq!(results[3].subtask_id, "D");
}
#[tokio::test]
async fn par_02_parallel_layer_executed() {
// 3 个无依赖任务应在同层并行执行
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("t1", "read file1"),
SubTask::new("t2", "read file2"),
SubTask::new("t3", "read file3"),
]);
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
async move {
ExecutionResult {
subtask_id: task.id.clone(),
persona_id,
output: format!("done {}", task.id),
success: true,
}
}
}, None).await;
assert_eq!(results.len(), 3);
for r in &results {
assert!(r.success, "所有子任务应成功");
}
}
#[tokio::test]
async fn par_03_budget_exceeded_degrades_to_serial() {
// Token 预算仅够 1 个并行,第 2 个应降级串行(不拒绝)
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("t1", "task1"),
SubTask::new("t2", "task2"),
SubTask::new("t3", "task3"),
]);
let pool = TokenBudgetPool::new(5_000); // 不够 3×10k
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id,
output: String::new(),
success: true,
}
}
}, Some(&pool)).await;
// 预算超限应降级串行,不拒绝执行
assert_eq!(results.len(), 3, "降级串行也应全部执行");
}
#[tokio::test]
async fn par_04_single_task_layer_serial() {
// 线性链(每层 1 任务)不走并行路径
let coord = make_coord();
let plan = Plan::from_tasks(vec![
SubTask::new("a", "step1"),
SubTask::new("b", "step2").with_deps(vec!["a".into()]),
]);
let results = coord.dispatch_with_budget(&plan, |task, persona_id: String| {
async move {
ExecutionResult {
subtask_id: task.id,
persona_id,
output: String::new(),
success: true,
}
}
}, None).await;
assert_eq!(results.len(), 2);
assert_eq!(results[0].subtask_id, "a");
assert_eq!(results[1].subtask_id, "b");
}
// -- Plan-driven LLM 规划开关 --
#[test]
fn plan_llm_gate_default_off() {
// 默认关:零回归(现有 ReAct 行为不变)
// 注:静态 AtomicBool 在测试间共享状态,此处仅断言默认值语义(关)。
// 不强测 set 后值(会污染其他测试的全局静态态),set/get 由 IPC 路径实测。
assert!(!aichat_plan_enabled(), "AICHAT_PLAN_ENABLED 应默认关");
}
#[test]
fn plan_llm_gate_set_get_roundtrip() {
// 保存原值,set 后 get 应一致,最后恢复(防污染其他测试)
let original = aichat_plan_enabled();
set_aichat_plan_enabled(true);
assert!(aichat_plan_enabled(), "set true 后 get 应为 true");
set_aichat_plan_enabled(false);
assert!(!aichat_plan_enabled(), "set false 后 get 应为 false");
// 恢复(防测试间全局态污染)
set_aichat_plan_enabled(original);
}
// -- plan_llm_system_prompt --
#[test]
fn plan_llm_system_prompt_lists_tools() {
let prompt = plan_llm_system_prompt(&["read_file".into(), "write_file".into()]);
assert!(prompt.contains("read_file"));
assert!(prompt.contains("write_file"));
assert!(prompt.contains("steps"));
assert!(prompt.contains("JSON"));
}
#[test]
fn plan_llm_system_prompt_empty_tools() {
let prompt = plan_llm_system_prompt(&[]);
// 空工具清单 → fallback 文案,不 panic
assert!(prompt.contains("JSON"));
}
// -- parse_plan_json: 容错解析 --
#[test]
fn parse_plan_json_pure_json() {
let raw = r#"{"steps":[{"id":"read","intent":"读","tools":["read_file"],"deps":[]}]}"#;
let p = parse_plan_json(raw).expect("纯 JSON 应解析");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].id, "read");
assert_eq!(p.steps[0].tools, vec!["read_file".to_string()]);
}
#[test]
fn parse_plan_json_with_markdown_fence() {
let raw = "```json\n{\"steps\":[{\"id\":\"a\",\"intent\":\"x\"}]}\n```";
let p = parse_plan_json(raw).expect("带 ```json 围栏应解析");
assert_eq!(p.steps.len(), 1);
assert_eq!(p.steps[0].id, "a");
}
#[test]
fn parse_plan_json_with_surrounding_text() {
let raw = "好的,这是计划:\n{\"steps\":[{\"id\":\"a\"}]}\n以上是计划。";
let p = parse_plan_json(raw).expect("前后杂文本应提取子串解析");
assert_eq!(p.steps.len(), 1);
}
#[test]
fn parse_plan_json_missing_optional_fields() {
// 缺 tools/deps/group → serde default 兜底空 Vec/None
let raw = r#"{"steps":[{"id":"a","intent":"do"}]}"#;
let p = parse_plan_json(raw).expect("缺可选字段应解析");
assert_eq!(p.steps[0].tools, Vec::<String>::new());
assert_eq!(p.steps[0].deps, Vec::<String>::new());
assert!(p.steps[0].group.is_none());
}
#[test]
fn parse_plan_json_empty_returns_none() {
assert!(parse_plan_json("").is_none());
assert!(parse_plan_json(" ").is_none());
}
#[test]
fn parse_plan_json_malformed_returns_none() {
// 非法 JSON → None(不 panic)
assert!(parse_plan_json("{not valid json}").is_none());
assert!(parse_plan_json("no braces here").is_none());
}
#[test]
fn parse_plan_json_empty_steps_array() {
// 合法 JSON 但 steps 空 → 解析成功(steps 空 Vec),由 into_subtasks/validate 兜底
let raw = r#"{"steps":[]}"#;
let p = parse_plan_json(raw).expect("空 steps 数组合法 JSON 应解析");
assert!(p.steps.is_empty());
}
// -- PlanLlmOutput::into_subtasks --
#[test]
fn into_subtasks_basic() {
let p = PlanLlmOutput {
steps: vec![
PlanLlmStep {
id: "read".into(),
intent: "读代码".into(),
tools: vec!["read_file".into()],
deps: vec![],
group: None,
},
PlanLlmStep {
id: "write".into(),
intent: "写代码".into(),
tools: vec!["write_file".into()],
deps: vec!["read".into()],
group: None,
},
],
};
let tasks = p.into_subtasks();
assert_eq!(tasks.len(), 2);
assert_eq!(tasks[0].id, "read");
assert_eq!(tasks[1].deps, vec!["read".to_string()]);
}
#[test]
fn into_subtasks_empty_id_gets_fallback() {
let p = PlanLlmOutput {
steps: vec![PlanLlmStep {
id: "".into(),
intent: "do".into(),
tools: vec![],
deps: vec![],
group: None,
}],
};
let tasks = p.into_subtasks();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].id, "step_0", "空 id 应兜底 step_<idx>");
}
#[test]
fn into_subtasks_dedup_duplicate_id() {
let p = PlanLlmOutput {
steps: vec![
PlanLlmStep { id: "a".into(), intent: "1".into(), tools: vec![], deps: vec![], group: None },
PlanLlmStep { id: "a".into(), intent: "2".into(), tools: vec![], deps: vec![], group: None },
],
};
let tasks = p.into_subtasks();
assert_eq!(tasks.len(), 1, "重复 id 应去重保留首个");
}
#[test]
fn into_subtasks_empty_intent_gets_fallback() {
let p = PlanLlmOutput {
steps: vec![PlanLlmStep {
id: "x".into(),
intent: "".into(),
tools: vec![],
deps: vec![],
group: None,
}],
};
let tasks = p.into_subtasks();
assert!(!tasks[0].intent.is_empty(), "空 intent 应兜底非空");
}
// -- decompose_with_llm: 用 mock provider 验全链路 --
/// 测试用 mock provider:返回预设的 CompletionResponse。
struct MockProvider {
response_text: String,
fail: bool,
}
#[async_trait::async_trait]
impl LlmProvider for MockProvider {
async fn complete(
&self,
_request: CompletionRequest,
) -> anyhow::Result<crate::provider::CompletionResponse> {
if self.fail {
anyhow::bail!("mock provider 故意失败");
}
Ok(crate::provider::CompletionResponse {
text: self.response_text.clone(),
model: "mock".to_string(),
usage: crate::provider::TokenUsage::default(),
tool_calls: None,
reasoning_content: None,
})
}
async fn stream(
&self,
_request: CompletionRequest,
) -> anyhow::Result<crate::provider::StreamResult> {
anyhow::bail!("mock provider 不支持 stream")
}
fn name(&self) -> &str {
"mock"
}
}
#[tokio::test]
async fn decompose_with_llm_success() {
let coord = make_coord();
let provider = MockProvider {
// 合法 Plan JSON:read → write
response_text: r#"{"steps":[
{"id":"read","intent":"读取代码","tools":["read_file"],"deps":[]},
{"id":"write","intent":"修改代码","tools":["write_file"],"deps":["read"]}
]}"#
.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(
&provider,
"mock-model",
"modify",
"帮我读取并修改代码",
&["read_file".into(), "write_file".into()],
)
.await
.expect("合法 JSON + validate 通过应返回 Some");
assert_eq!(result.subtasks.len(), 2);
assert_eq!(result.subtasks[0].id, "read");
assert_eq!(result.subtasks[1].id, "write");
assert_eq!(result.subtasks[1].deps, vec!["read".to_string()]);
assert!(!result.plan.is_empty());
}
#[tokio::test]
async fn decompose_with_llm_provider_failure_returns_none() {
// LLM 调用失败 → None(回退 ReAct,不 panic)
let coord = make_coord();
let provider = MockProvider {
response_text: String::new(),
fail: true,
};
let result = coord
.decompose_with_llm(&provider, "m", "modify", "text", &[])
.await;
assert!(result.is_none(), "provider 失败应返 None 回退");
}
#[tokio::test]
async fn decompose_with_llm_invalid_json_returns_none() {
// 非 JSON → None
let coord = make_coord();
let provider = MockProvider {
response_text: "这不是 JSON".to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "modify", "text", &[])
.await;
assert!(result.is_none(), "非法 JSON 应返 None 回退");
}
#[tokio::test]
async fn decompose_with_llm_empty_steps_returns_none() {
// 空 steps 数组 → None
let coord = make_coord();
let provider = MockProvider {
response_text: r#"{"steps":[]}"#.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "modify", "text", &[])
.await;
assert!(result.is_none(), "空 steps 应返 None 回退");
}
#[tokio::test]
async fn decompose_with_llm_cycle_fails_validate_returns_none() {
// LLM 出环依赖 → validate 拒 → None
let coord = make_coord();
let provider = MockProvider {
response_text: r#"{"steps":[
{"id":"a","intent":"x","tools":[],"deps":["b"]},
{"id":"b","intent":"y","tools":[],"deps":["a"]}
]}"#
.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "modify", "text", &[])
.await;
assert!(result.is_none(), "环依赖应 validate 拒返 None");
}
#[tokio::test]
async fn decompose_with_llm_dangling_dep_fails_validate() {
// 悬空 dep → validate 拒 → None
let coord = make_coord();
let provider = MockProvider {
response_text: r#"{"steps":[
{"id":"a","intent":"x","tools":[],"deps":["nonexistent"]}
]}"#
.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "modify", "text", &[])
.await;
assert!(result.is_none(), "悬空 dep 应 validate 拒返 None");
}
#[tokio::test]
async fn decompose_with_llm_single_step_no_tools_ok() {
// 单步骤无工具(require_tools=false 允许)→ Ok
let coord = make_coord();
let provider = MockProvider {
response_text: r#"{"steps":[{"id":"think","intent":"分析问题"}]}"#.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "chat", "解释一下", &[])
.await
.expect("单步无工具(require_tools=false)应通过");
assert_eq!(result.subtasks.len(), 1);
}
#[tokio::test]
async fn decompose_with_llm_markdown_fence_ok() {
// LLM 包 ```json 围栏 → parse_plan_json 剥围栏后正常解析
let coord = make_coord();
let provider = MockProvider {
response_text: "```json\n{\"steps\":[{\"id\":\"a\",\"intent\":\"x\"}]}\n```"
.to_string(),
fail: false,
};
let result = coord
.decompose_with_llm(&provider, "m", "chat", "text", &[])
.await;
assert!(result.is_some(), "带 markdown 围栏的合法 JSON 应解析成功");
}
}