From 8c0ff80cd40f7efa5d281eec180f63c91adfba6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sat, 1 Aug 2026 16:01:11 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20aichat=20Plan-driven=20Ph?= =?UTF-8?q?ase1(LLM=20=E5=87=BA=20Plan=20+=20=E5=BC=80=E5=85=B3=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=85=B3=20+=20=E4=B8=89=E9=87=8D=E5=85=9C=E5=BA=95?= =?UTF-8?q?=20+=20emit=20AiPlanCreated)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/df-ai/src/coordinator.rs | 652 ++++++++++++++++++++++- src-tauri/src/commands/ai/agentic/mod.rs | 139 +++++ 2 files changed, 789 insertions(+), 2 deletions(-) diff --git a/crates/df-ai/src/coordinator.rs b/crates/df-ai/src/coordinator.rs index cffe9d2..8c37adc 100644 --- a/crates/df-ai/src/coordinator.rs +++ b/crates/df-ai/src/coordinator.rs @@ -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 { + 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 { + // 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::(), + "[PLAN-LLM] JSON 解析失败,回退纯 ReAct" + ); + return None; + } + }; + + // 4) 转 SubTask/Plan + validate 兜底 + // require_tools=false:LLM 可能产「思考/协调」类无工具步骤(纯编排节点), + // 关 require_tools 避免误拒(对齐 plan_hint 场景允许无工具子任务)。 + let subtasks: Vec = 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 { 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, + /// 依赖前驱 id 列表。缺失 → 空 Vec。 + #[serde(default)] + deps: Vec, + /// 并行组 hint(可选)。缺失 → None。 + #[serde(default)] + group: Option, +} + +/// Plan JSON 顶层结构:仅含 steps 数组。 +#[derive(Debug, serde::Deserialize)] +struct PlanLlmOutput { + #[serde(default)] + steps: Vec, +} + +impl PlanLlmOutput { + /// 转换为 SubTask 列表(去空 id 兜底生成,去重 id 保留首个)。 + fn into_subtasks(self) -> Vec { + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + let mut out: Vec = Vec::new(); + for (idx, step) in self.steps.into_iter().enumerate() { + // 空 id → 按 step_ 兜底生成,避免 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 { + 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::(json_str) { + Ok(p) => Some(p), + Err(e) => { + tracing::debug!( + json_preview = %json_str.chars().take(200).collect::(), + 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::::new()); + assert_eq!(p.steps[0].deps, Vec::::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_"); + } + + #[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 { + 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 { + 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 应解析成功"); + } } diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 90385c8..cd25219 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -970,6 +970,90 @@ pub(crate) async fn run_agentic_loop( .unwrap_or_default() }; + // ── Plan-driven Phase 1:LLM 规划端(AICHAT_PLAN_ENABLED 门控) ── + // + // 对齐 docs/02-架构设计/专项设计/aichat-plan-driven-设计-2026-08-01.md Phase 1: + // 开启时由 LLM 生成 Plan JSON(替代 coordinator.decompose 关键词匹配),成功后 emit + // AiPlanCreated 事件(步骤 + 状态 pending)供前端展示(前端卡片 Phase 3 后续,本步只 emit)。 + // + // **职责边界(对齐设计文档)**: + // - Phase 1 只治「Plan 从哪来」(LLM 出 Plan)+ 事件可见性,**不改执行逻辑**; + // - 执行调度(Plan→to_layers→并行/串行 dispatch)是 Phase 2 范围,留 plan_executor 接通后做; + // - 故本块 emit 后**继续走 ReAct 主链**(不 early return),Plan 仅作规划可见性,执行仍单链 ReAct。 + // + // **开关默认关 + 兜底回退**(memory ai-improvement-principles): + // - AICHAT_PLAN_ENABLED=false(默认)→ 整块跳过,零行为变更(现有 ReAct 不受影响); + // - LLM 调用失败/JSON 解析失败/validate 失败 → decompose_with_llm 返 None → 整块跳过, + // 继续走 ReAct(不阻断,三重兜底); + // - 与 plan_executor::PLAN_EXECUTION_ENABLED 正交:执行开关独立治 dispatch,本块不碰。 + // + // LLM 调用复用主对话 provider + resolved_model(已解析,无需二次 build);available_tools + // 喂 LLM 限定 tool_hint 取值域防幻觉(取 tool_defs 名清单,即 LLM 可见工具子集)。 + if df_ai::coordinator::aichat_plan_enabled() && conf >= INTENT_CONF_THRESHOLD { + let coord = Coordinator::new(PersonaRegistry::new()); + // 可用工具名清单(供 LLM 填 tool_hint,防幻觉不存在的工具名) + let available_tools: Vec = tool_defs + .iter() + .map(|d| d.function.name.clone()) + .collect(); + // LLM 调用包 30s 超时(对齐 compress_via_llm 60s / title 20s 量级):规划属独立 LLM + // 调用,超时返 None 回退 ReAct,不拖垮 agentic loop。 + let plan_result = tokio::time::timeout( + std::time::Duration::from_secs(30), + coord.decompose_with_llm( + &*provider, + &resolved_model, + intent.as_str(), + &user_text, + &available_tools, + ), + ) + .await; + match plan_result { + Err(_) => { + tracing::warn!( + conv_id = %conv_id, + "[PLAN-LLM] LLM 规划调用超时(30s),回退纯 ReAct" + ); + } + Ok(Some(decompose_result)) => { + // emit AiPlanCreated(步骤 + 状态 pending)供前端展示(Phase 3 前端卡片后续) + let layers_payload = build_plan_layers_payload(&decompose_result.plan, &coord); + let plan_id = format!("plan-{}", ulid_like_id()); + tracing::info!( + conv_id = %conv_id, + plan_id = %plan_id, + layer_count = layers_payload.len(), + task_count = decompose_result.subtasks.len(), + "[PLAN-LLM] Plan 创建,emit AiPlanCreated" + ); + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiPlanCreated { + plan_id: plan_id.clone(), + layers: layers_payload, + conversation_id: Some(conv_id.clone()), + }); + let _ = app_handle.state::().ai_event_bus.publish_event( + AiChatEvent::AiPlanCreated { + plan_id, + // layers 已 move,复用 decompose_result 重建等价载荷(reborrow 避免 move) + layers: build_plan_layers_payload(&decompose_result.plan, &coord), + conversation_id: Some(conv_id.clone()), + } + ); + // Phase 1:emit 后继续走 ReAct 主链(执行调度是 Phase 2)。 + // Plan 仅作规划可见性 + 前端展示;执行仍单链 ReAct,不改 dispatch 逻辑。 + } + Ok(None) => { + // 三重兜底已触发(LLM 错/解析错/validate 错),decompose_with_llm 内已记 warn。 + // 静默继续 ReAct(不阻断)。 + tracing::info!( + conv_id = %conv_id, + "[PLAN-LLM] LLM 规划未产出有效 Plan,继续走 ReAct" + ); + } + } + } + if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD { let coord = Coordinator::new(PersonaRegistry::new()); let decompose_result = coord.decompose(intent.as_str(), &user_text); @@ -2119,3 +2203,58 @@ fn update_pinned_goals( } } } + +// ── Plan-driven Phase 1:Plan→事件载荷 + plan_id 生成辅助 ── +// +// 对齐 docs/02-架构设计/专项设计/aichat-plan-driven-设计-2026-08-01.md Phase 1: +// LLM 出 Plan 后 emit AiPlanCreated(步骤 + 状态 pending)。Plan 是 DAG(kahn to_layers +// 分层),事件载荷按 layer 分组(同层并行,层间串行),前端据此画 DAG 卡片(Phase 3)。 + +/// 生成 plan_id(时间戳 + 计数器,无需 ulid 依赖)。 +/// +/// 形如 `plan-1757000000000-42`,单调递增 + 进程内唯一。与 df-ai-core 的 new_message_id +/// 同思路(AtomicU64 计数),但本处 plan_id 仅供事件载荷标识,无落库无溯源需求。 +fn ulid_like_id() -> String { + use std::sync::atomic::{AtomicU64, Ordering as O}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let n = COUNTER.fetch_add(1, O::SeqCst); + format!("{}-{}", ts, n) +} + +/// 把 Plan(DAG)转为 AiPlanCreated 事件载荷(按 layer 分组)。 +/// +/// - 调 `plan.to_layers()`(kahn 拓扑分层)得层级顺序; +/// - 每层任务映射为 SubTaskInfo(id / persona_id / intent / status="pending"); +/// - persona_id 由 `registry.recommend_for_intent` 推荐(对齐 dispatch 路径); +/// - to_layers 失败(环,正常不会发生——decompose_with_llm 已 validate 兜底)→ +/// fallback 单层平铺(不阻断事件 emit)。 +fn build_plan_layers_payload( + plan: &df_ai::planner::Plan, + coord: &Coordinator, +) -> Vec { + let layers = plan.to_layers().unwrap_or_else(|_| { + // 环(unreachable:decompose_with_llm 已 validate 兜底)→ fallback 单层平铺 + vec![plan.tasks.clone()] + }); + layers + .into_iter() + .map(|layer| super::PlanLayerInfo { + items: layer + .into_iter() + .map(|t| { + let persona_id = coord.recommend_persona_id(&t.intent); + super::SubTaskInfo { + id: t.id, + persona_id, + intent: t.intent, + status: "pending".to_string(), + } + }) + .collect(), + }) + .collect() +}