//! 单对话并行多轮 — Phase 0a · 0.8 plan_hint 纯函数 //! //! 依据:`docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md` 第 0.8 节。 //! 上游设计:`单对话并行多轮-设计-2026-06-20.md`。 //! //! ## 定位 //! Phase 0a 的**纯函数地基**:基于用户意图(`crate::intent::Intent`)/对话上下文关键词, //! 产出一份「编排提示」(`PlanHint`)——告诉主 loop「这批工具调用可以并行」/「这些有 //! 顺序依赖」。它**扩 `intent::tool_subset_for` 语义**:后者只返扁平 tool 子集,本模块 //! 在其之上叠加「并行组 / 顺序依赖」的编排关系。 //! //! ## 纯函数边界(零 IO 零状态) //! - 无 `.await` / 无 db / 无 provider / 无全局状态。 //! - 输入:意图标签 + 上下文关键词(纯字符串切片)。 //! - 输出:`PlanHint`(数据结构 + 启发式算法产物)。 //! - **不调 LLM**:LLM 调度(plan_hint 落 prompt 让模型产 Plan)留 Phase 2;本模块是 //! Phase 1 主 loop 接入前的「确定性降级路径」——主 loop 拿到 PlanHint 直接做编排, //! 无需等 Phase 2 LLM 介入也能跑通串行/并行调度。 //! //! ## 与 0.7 planner.rs 的关系 //! 本 agent 独立先做(避免等 agent A),用**自有轻量 `PlanHint` 结构**。 //! - `PlanHint` 是「提示」层(启发式,可错,主 loop 可忽略); //! - `Plan`(`planner.rs`,agent A 出)是「计划」层(LLM/用户确认后的硬编排)。 //! Phase 1 接入主 loop 时,两者字段(group/dep)对齐,主 loop 优先消费 `Plan`,无 `Plan` //! 时退 `PlanHint`。结构对齐工作放 Phase 1,本批互不阻塞。 //! //! ## feature flag //! `PLAN_HINT_ENABLED` 常量占位。关时单链 ReAct=旧行为(主 loop 不读 PlanHint)。 //! Phase 1 接入时主 loop 据 `PLAN_HINT_ENABLED` 决定是否走 plan_hint 分支。 // ---- feature flag 占位 ------------------------------------------------------ /// plan_hint 总开关。Phase 0a 先占位为 `true`(纯函数无副作用可单测)。 /// Phase 1 接入主 loop 时,关闭则主 loop 不读 PlanHint,退单链 ReAct(旧行为)。 /// 真正的运行时开关(配置驱动)留 Phase 1,本批用编译期常量便于单测固定行为。 pub const PLAN_HINT_ENABLED: bool = true; // ---- 数据结构 -------------------------------------------------------------- /// 并行组:一组可同时调度的工具名(组内无顺序约束,可并行执行)。 /// /// 工具名对齐 `intent::tool_subset_for` 返回的工具名常量(read_file/write_file/..., /// 见 `intent::ToolDomain::tools`)。空组无意义(构造时应保证非空,但 validate 兜底)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParallelGroup { /// 组内工具名列表(均为 `intent` crate 内声明的注册名)。 pub tools: Vec<&'static str>, } impl ParallelGroup { /// 构造一个并行组。空 tools 允许构造(validate 阶段标记非法),调用方应保证非空。 pub fn new(tools: Vec<&'static str>) -> Self { Self { tools } } /// 组内工具数。 pub fn len(&self) -> usize { self.tools.len() } /// 是否为空组。 pub fn is_empty(&self) -> bool { self.tools.is_empty() } } /// 顺序依赖边:`from` 必须先于 `to` 完成(A → B)。 /// /// 用于表达「读后写」「先查后改」等强顺序约束。`from`/`to` 是工具名 /// (或更细的工具调用标签,Phase 2 LLM 产 Plan 时可携带调用 id;本批仅工具名粒度)。 #[derive(Debug, Clone, PartialEq, Eq)] pub struct SequentialDep { /// 前置工具(先执行)。 pub from: &'static str, /// 后置工具(待 from 完成后执行)。 pub to: &'static str, } impl SequentialDep { /// 构造一条顺序依赖边。 pub fn new(from: &'static str, to: &'static str) -> Self { Self { from, to } } } /// 编排提示(轻量 PlanHint)。 /// /// - `groups`:可并行执行的分组(每组内工具无序,可同时调度)。 /// - `sequence`:跨组的顺序依赖边(DAG 边)。 /// /// **合法 PlanHint 约束**(与 0.7 Kahn 拓扑对齐): /// 1. `groups` 内各组 `tools` 非空; /// 2. `sequence` 不含自环(`from != to`); /// 3. `sequence` 无重复边(去重); /// 4. `sequence` 构成的有向图**无环**(DAG)——`validate` 用 Kahn 判定。 /// /// validate 失败时主 loop 应**丢弃** PlanHint 退单链(不能据此调度,否则死锁/无限循环)。 #[derive(Debug, Clone, Default, PartialEq, Eq)] pub struct PlanHint { /// 并行组列表。空 `Vec` 表示「无并行提示」(单链或全顺序)。 pub groups: Vec, /// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。 pub sequence: Vec, } impl PlanHint { /// 空提示(无并行、无顺序)。主 loop 见到此值应退旧行为。 pub fn empty() -> Self { Self::default() } /// 是否为空提示(无任何编排信息)。 pub fn is_empty(&self) -> bool { self.groups.is_empty() && self.sequence.is_empty() } /// 工具总数(所有并行组工具去重后的并集大小)。 pub fn tool_count(&self) -> usize { let mut seen = std::collections::HashSet::new(); for g in &self.groups { for t in &g.tools { seen.insert(*t); } } seen.len() } } // ---- 校验(Kahn 拓扑判 DAG 无环,对齐 0.7) -------------------------------- /// PlanHint 合法性校验结果。 #[derive(Debug, Clone, PartialEq, Eq)] pub enum HintValidity { /// 合法,可交付主 loop 调度。 Ok, /// 非法(附原因)。主 loop 应丢弃 PlanHint 退单链。 Invalid(&'static str), } /// 校验 PlanHint 合法性(纯函数)。 /// /// 检查四项(见 `PlanHint` 文档约束): /// 1. 组内非空; /// 2. 无自环(`from != to`); /// 3. 无重复边; /// 4. **Kahn 拓扑判无环**(与 0.7 `Plan::validate` 同算法,这里在 PlanHint 层独立实现, /// 避免依赖 agent A 的 planner.rs;Phase 1 接入时若 planner 已稳定可抽公共函数)。 /// /// 返回 `Invalid(reason)` 时 reason 是静态原因标签(便于日志/断言)。 pub fn validate(hint: &PlanHint) -> HintValidity { // 1. 组内非空 for g in &hint.groups { if g.tools.is_empty() { return HintValidity::Invalid("empty_parallel_group"); } } // 2. 无自环 for d in &hint.sequence { if d.from == d.to { return HintValidity::Invalid("self_loop_dep"); } } // 3. 无重复边(同 from→to 不重复) let mut seen_edges = std::collections::HashSet::new(); for d in &hint.sequence { if !seen_edges.insert((d.from, d.to)) { return HintValidity::Invalid("duplicate_dep_edge"); } } // 4. Kahn 拓扑判无环 if has_cycle(&hint.sequence) { return HintValidity::Invalid("cyclic_dep"); } HintValidity::Ok } /// Kahn 算法判有向边集合是否含环(纯函数)。 /// /// 输入是 `SequentialDep` 边列表,节点是工具名(`&'static str`)。 /// 返回 `true` = 有环(非法),`false` = DAG(合法)。 /// /// 实现:统计入度 → 入度 0 的节点入队 → BFS 出队并削减邻居入度 → /// 最终已出队节点数 < 总节点数 → 存在环。 fn has_cycle(edges: &[SequentialDep]) -> bool { if edges.is_empty() { return false; } // 收集所有节点(去重)。 let mut nodes: std::collections::HashSet<&'static str> = std::collections::HashSet::new(); for d in edges { nodes.insert(d.from); nodes.insert(d.to); } let n = nodes.len(); if n == 0 { return false; } // 节点 → 索引(便于用 Vec 计数)。 let mut idx: std::collections::HashMap<&'static str, usize> = std::collections::HashMap::with_capacity(n); for (i, name) in nodes.iter().enumerate() { idx.insert(*name, i); } // 入度表 + 邻接表。 let mut indeg = vec![0usize; n]; let mut adj: Vec> = vec![Vec::new(); n]; for d in edges { let u = idx[&d.from]; let v = idx[&d.to]; adj[u].push(v); indeg[v] += 1; } // BFS from 入度 0 节点。 let mut queue: std::collections::VecDeque = std::collections::VecDeque::new(); for (i, &d) in indeg.iter().enumerate() { if d == 0 { queue.push_back(i); } } let mut visited = 0usize; while let Some(u) = queue.pop_front() { visited += 1; for &v in &adj[u] { indeg[v] -= 1; if indeg[v] == 0 { queue.push_back(v); } } } // 访问到的节点数 < 总节点数 → 存在环(有节点入度永远不为 0)。 visited < n } // ---- 启发式产 PlanHint(纯函数,无 LLM) ----------------------------------- /// 输入:意图标签(来自 `intent::recognize`)+ 上下文关键词(原始用户消息)。 /// /// 关键词检测用 `str::contains`(中英文都支持,与 `intent::recognize` 一致风格)。 /// 输出基于**确定性启发式规则**(非 LLM),规则覆盖 devflow 常见编排场景: /// /// | 场景关键词 | 产出 | /// |---|---| /// | 读多个 + 写后改 | [读组 并行] → write_file/patch_file | /// | grep/find 后改 | search_files → write_file/patch_file | /// | http + 写文件 | http_request → write_file(先取再存) | /// | 仅读多文件 | 全并行单组 | /// | 单工具 / 无编排信号 | 空 PlanHint(主 loop 退单链) | /// /// **重要**:这是降级路径(Phase 2 前)。LLM 产 Plan 的质量一定高于本启发式, /// 但本函数保证 Phase 1 主 loop 接入后**无 LLM 也能跑通基本编排**(读后写/grep 后改)。 /// /// `intent_label` 仅为日志/未来扩展预留(当前规则用关键词驱动,intent_label 不参与判定, /// 保留参数对齐 Phase 1 主 loop 调用签名:`plan_hint(&intent, &message)`)。 pub fn plan_hint(intent_label: &str, context_keywords: &str) -> PlanHint { // 开关关闭:直接返空(主 loop 退单链)。Phase 1 接入时主 loop 据常量短路,这里也兜底。 if !PLAN_HINT_ENABLED { return PlanHint::empty(); } let _ = intent_label; // 预留:当前规则纯关键词驱动,不读 intent。 let ctx = context_keywords; let lower = ctx.to_lowercase(); // ---- 规则 1:grep/搜索 后改 → search_files → write_file/patch_file ---- let is_search = lower.contains("grep") || lower.contains("搜索") || lower.contains("查找") || lower.contains("find "); let is_modify = lower.contains("修改") || lower.contains("改") || lower.contains("write") || lower.contains("patch") || lower.contains("写入") || lower.contains("重命名") || lower.contains("rename"); if is_search && is_modify { return PlanHint { groups: vec![ ParallelGroup::new(vec!["search_files"]), ParallelGroup::new(vec!["write_file", "patch_file", "rename_file"]), ], sequence: vec![SequentialDep::new("search_files", "write_file")], }; } // ---- 规则 2:http 取数 + 写文件 → http_request → write_file ---- let is_http = lower.contains("http") || lower.contains("api") || lower.contains("请求") || lower.contains("fetch"); if is_http && (lower.contains("保存") || lower.contains("写入") || lower.contains("write")) { return PlanHint { groups: vec![ ParallelGroup::new(vec!["http_request"]), ParallelGroup::new(vec!["write_file"]), ], sequence: vec![SequentialDep::new("http_request", "write_file")], }; } // ---- 规则 3:读多文件(无写) → 全并行单组 ---- let read_count = [ ("read_file", lower.matches("read").count() + lower.matches("读取").count()), ("list_directory", lower.matches("目录").count() + lower.matches("directory").count()), ] .iter() .filter(|(_, c)| *c >= 1) .count(); if read_count >= 2 && !is_modify { // 多种读工具信号 → 单并行组(可同时调度) let mut tools: Vec<&'static str> = Vec::new(); if lower.contains("read") || lower.contains("读取") { tools.push("read_file"); } if lower.contains("目录") || lower.contains("directory") { tools.push("list_directory"); } if tools.len() >= 2 { return PlanHint { groups: vec![ParallelGroup::new(tools)], sequence: vec![], }; } } // ---- 规则 4:读后写 → read_file → write_file/patch_file ---- let is_read = lower.contains("read") || lower.contains("读取"); if is_read && is_modify { return PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file"]), ParallelGroup::new(vec!["write_file", "patch_file"]), ], sequence: vec![SequentialDep::new("read_file", "write_file")], }; } // ---- 兜底:无编排信号 → 空 PlanHint(主 loop 退单链) ---- PlanHint::empty() } // ---- 单测 ------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; // --- feature flag 占位 --- #[test] fn plan_hint_enabled_is_true_in_phase0a() { // Phase 0a 占位为 true,纯函数可单测。Phase 1 接入后改配置驱动。 assert!(PLAN_HINT_ENABLED, "Phase 0a 占位应为 true"); } // --- 数据结构基本 --- #[test] fn parallel_group_new_and_len() { let g = ParallelGroup::new(vec!["read_file", "write_file"]); assert_eq!(g.len(), 2); assert!(!g.is_empty()); } #[test] fn parallel_group_empty_construct_allowed_but_invalid() { // 空 tools 允许构造(调用方失误),validate 阶段标记非法 let g = ParallelGroup::new(vec![]); assert!(g.is_empty()); assert_eq!(g.len(), 0); } #[test] fn sequential_dep_new() { let d = SequentialDep::new("read_file", "write_file"); assert_eq!(d.from, "read_file"); assert_eq!(d.to, "write_file"); } #[test] fn plan_hint_empty_is_empty() { let h = PlanHint::empty(); assert!(h.is_empty()); assert_eq!(h.tool_count(), 0); } #[test] fn plan_hint_tool_count_dedup_across_groups() { // 两组含同名工具 → tool_count 去重 let h = PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file", "write_file"]), ParallelGroup::new(vec!["read_file", "patch_file"]), ], sequence: vec![], }; // read_file/write_file/patch_file = 3(去重) assert_eq!(h.tool_count(), 3); } // --- plan_hint 基本编排提示 --- #[test] fn hint_read_then_modify_yields_sequence() { // 规则 4:读后写 → read_file → write_file let h = plan_hint("code", "先 read 这个文件再修改它"); assert!(!h.is_empty(), "读后写应产非空 hint"); assert_eq!(h.groups.len(), 2, "应分两组(读组/改组)"); assert!(h.sequence.iter().any(|d| d.from == "read_file" && d.to == "write_file"), "应含 read_file→write_file 依赖边"); } #[test] fn hint_grep_then_modify_yields_search_first() { // 规则 1:grep 后改 → search_files → write_file let h = plan_hint("search", "grep 一下再修改 write_file"); assert!(!h.is_empty()); // 首组应是 search_files assert!(h.groups[0].tools.contains(&"search_files")); assert!(h.sequence.iter().any(|d| d.from == "search_files"), "search_files 应作为顺序依赖起点"); } #[test] fn hint_http_then_save_yields_http_first() { // 规则 2:http + 保存 → http_request → write_file let h = plan_hint("http", "请求这个 api 并保存写入到文件"); assert!(!h.is_empty()); assert!(h.sequence.iter().any(|d| d.from == "http_request" && d.to == "write_file")); } #[test] fn hint_multi_read_only_yields_single_parallel_group() { // 规则 3:读多文件(无写)→ 单并行组 let h = plan_hint("file", "read 文件,再看一下 directory 目录"); assert!(!h.is_empty()); assert_eq!(h.groups.len(), 1, "应单并行组"); let tools = &h.groups[0].tools; assert!(tools.contains(&"read_file"), "组内应含 read_file"); assert!(tools.contains(&"list_directory"), "组内应含 list_directory"); assert!(h.sequence.is_empty(), "全并行无顺序依赖"); } // --- 并行组识别 --- #[test] fn parallel_group_tools_are_registry_names() { // 对抗:产出的工具名必须在 intent ToolDomain 注册表内(防笔误)。 // 注:intent::ToolDomain::tools() 是 private 方法(同模块 intent.rs 内单测可用, // 跨模块不可访问)。本测用从 intent.rs:246-284 抄录的 registry 名常量做校验, // 不依赖私有方法访问(保持 plan_hint.rs 独立可单测)。若 registry 名漂移, // intent.rs 的 tool_domain_names_match_registry 测会先抓到,本测同步对齐。 let registry: std::collections::HashSet<&str> = [ // Data domain "list_projects", "create_project", "update_project", "delete_project", "bind_directory", "list_tasks", "create_task", "update_task", "advance_task", "delete_task", "list_ideas", "create_idea", "run_workflow", "list_trash", "restore_project", "purge_project", "get_project_count", "get_task_count", // File domain "read_file", "write_file", "patch_file", "list_directory", "search_files", "file_info", "append_file", "delete_file", "rename_file", // Exec domain "run_command", // Http domain "http_request", ] .iter() .copied() .collect(); let h = plan_hint("file", "read 再读取目录 directory"); if !h.is_empty() { for g in &h.groups { for t in &g.tools { assert!(registry.contains(*t), "工具名 {} 不在 registry", t); } } } } #[test] fn hint_groups_tools_non_empty_when_non_empty_hint() { // 非空 hint 的每组都应非空(由 plan_hint 产出保证,validate 兜底) let h = plan_hint("code", "read 后 write_file 修改"); if !h.is_empty() { for g in &h.groups { assert!(!g.tools.is_empty(), "产出组的 tools 不应空"); } } } // --- 顺序依赖 --- #[test] fn hint_sequence_edges_from_less_than_to() { // 顺序依赖边 from/to 都是合法工具名(from 先于 to) let h = plan_hint("code", "read 后修改 write_file"); for d in &h.sequence { assert_ne!(d.from, d.to, "依赖边不应自环"); assert!(!d.from.is_empty()); assert!(!d.to.is_empty()); } } #[test] fn hint_no_duplicate_sequence_edge() { // 产出的依赖边不应重复 let h = plan_hint("code", "read 后 write_file 修改"); let mut seen = std::collections::HashSet::new(); for d in &h.sequence { assert!(seen.insert((d.from, d.to)), "重复依赖边: {}→{}", d.from, d.to); } } // --- 空 hint --- #[test] fn hint_empty_for_plain_chat() { // 无编排信号 → 空 PlanHint let h = plan_hint("chat", "你好谢谢"); assert!(h.is_empty(), "闲聊应产空 hint, 退单链"); } #[test] fn hint_empty_for_unknown_intent_no_signal() { let h = plan_hint("unknown", "今天的天气不错"); assert!(h.is_empty()); } #[test] fn hint_empty_when_flag_off() { // 开关关闭 → 恒空(主 loop 退单链)。即使有强编排信号也忽略。 // 注:PLAN_HINT_ENABLED 是编译期常量,本测在 true 时验证 plan_hint 内部 // 短路逻辑(若常量改 false,此测会失败提醒对齐)。 // 用单工具避免命中多工具规则。 let h = plan_hint("file", "read"); // 单 read(不命中规则 3 需 ≥2 读工具,不命中规则 4 需 modify)→ 空 assert!(h.is_empty(), "单工具无编排信号应空 hint"); } // --- validate 合法性 --- #[test] fn validate_empty_hint_is_ok() { let h = PlanHint::empty(); assert_eq!(validate(&h), HintValidity::Ok); } #[test] fn validate_normal_dag_is_ok() { // read_file → write_file(单边 DAG)合法 let h = PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file"]), ParallelGroup::new(vec!["write_file"]), ], sequence: vec![SequentialDep::new("read_file", "write_file")], }; assert_eq!(validate(&h), HintValidity::Ok); } #[test] fn validate_empty_parallel_group_is_invalid() { let h = PlanHint { groups: vec![ParallelGroup::new(vec![])], sequence: vec![], }; assert_eq!(validate(&h), HintValidity::Invalid("empty_parallel_group")); } #[test] fn validate_self_loop_dep_is_invalid() { let h = PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file"]), ParallelGroup::new(vec!["write_file"]), ], sequence: vec![SequentialDep::new("read_file", "read_file")], }; assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep")); } #[test] fn validate_duplicate_edge_is_invalid() { let h = PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file"]), ParallelGroup::new(vec!["write_file"]), ], sequence: vec![ SequentialDep::new("read_file", "write_file"), SequentialDep::new("read_file", "write_file"), ], }; assert_eq!(validate(&h), HintValidity::Invalid("duplicate_dep_edge")); } // --- Kahn 环检测 --- #[test] fn has_cycle_empty_edges_false() { assert!(!has_cycle(&[])); } #[test] fn has_cycle_single_edge_no_cycle() { let edges = vec![SequentialDep::new("a", "b")]; assert!(!has_cycle(&edges)); } #[test] fn has_cycle_two_node_cycle_detected() { // a→b→a 环 let edges = vec![ SequentialDep::new("a", "b"), SequentialDep::new("b", "a"), ]; assert!(has_cycle(&edges), "a↔b 环应被检测"); } #[test] fn has_cycle_three_node_cycle_detected() { // a→b→c→a 环 let edges = vec![ SequentialDep::new("a", "b"), SequentialDep::new("b", "c"), SequentialDep::new("c", "a"), ]; assert!(has_cycle(&edges)); } #[test] fn has_cycle_dag_with_branch_no_cycle() { // a→b, a→c, b→d, c→d(菱形 DAG,无环) let edges = vec![ SequentialDep::new("a", "b"), SequentialDep::new("a", "c"), SequentialDep::new("b", "d"), SequentialDep::new("c", "d"), ]; assert!(!has_cycle(&edges)); } #[test] fn validate_cyclic_dep_is_invalid() { let h = PlanHint { groups: vec![ ParallelGroup::new(vec!["read_file"]), ParallelGroup::new(vec!["write_file"]), ], sequence: vec![ SequentialDep::new("read_file", "write_file"), SequentialDep::new("write_file", "read_file"), ], }; assert_eq!(validate(&h), HintValidity::Invalid("cyclic_dep")); } // --- 对抗/边界 --- #[test] fn validate_check_order_self_loop_before_cycle() { // 自环也构成环,但 validate 应先报 self_loop_dep(更具体的错误) let h = PlanHint { groups: vec![ParallelGroup::new(vec!["a"])], sequence: vec![SequentialDep::new("a", "a")], }; assert_eq!(validate(&h), HintValidity::Invalid("self_loop_dep")); } #[test] fn hint_intent_label_ignored_in_phase0a() { // 当前规则纯关键词驱动,intent_label 不参与判定(预留 Phase 1) // 同关键词不同 intent_label → 同产出 let h1 = plan_hint("code", "read 后修改 write_file"); let h2 = plan_hint("debug", "read 后修改 write_file"); assert_eq!(h1, h2, "Phase 0a 不读 intent_label, 产出应一致"); } #[test] fn hint_single_tool_no_signal_is_empty() { // 单 read(无写无多读)→ 不命中任何规则 → 空 let h = plan_hint("file", "读取文件"); assert!(h.is_empty()); } #[test] fn validate_ignores_nodes_not_in_groups() { // validate 不检查 sequence 边引用的工具是否在 groups 内(Phase 0a 宽松, // 主 loop 自行对齐;Phase 1 接入 planner.rs 后由 Plan::validate 收口) let h = PlanHint { groups: vec![ParallelGroup::new(vec!["read_file"])], sequence: vec![SequentialDep::new("read_file", "write_file")], }; // write_file 不在任何 group,但边合法且 DAG,validate 仍 Ok(宽松) assert_eq!(validate(&h), HintValidity::Ok); } }