新增: aichat Plan-driven Phase1(LLM 出 Plan + 开关默认关 + 三重兜底 + emit AiPlanCreated)
This commit is contained in:
@@ -9,10 +9,38 @@
|
||||
//! 4. **merge**:汇总子结果 → 合并产出 → 处理冲突
|
||||
|
||||
use crate::persona::PersonaRegistry;
|
||||
use crate::planner::{Plan, SubTask};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
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 无锁并发安全)
|
||||
@@ -276,6 +304,123 @@ impl Coordinator {
|
||||
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 依赖保证)
|
||||
@@ -537,6 +682,165 @@ fn extract_written_files(output: &str) -> Vec<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)]
|
||||
@@ -1110,4 +1414,348 @@ mod tests {
|
||||
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 {
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
total_tokens: 0,
|
||||
},
|
||||
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 应解析成功");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user