后端: - 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展 - 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通 - 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦 - AI planner/plan_hint/intent:aichat B 路线并行多轮基础 - patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环 - 压缩超时兜底(F-15 卡死根治) - F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本 - 知识注入 DRY/skills/audit 扩展 清理: - aichat 技术债(误报 allow/死导入/过时注释 30 项) - URGENT.md 删除(11 项加急全解决/迁 todo) - 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
709 lines
25 KiB
Rust
709 lines
25 KiB
Rust
//! 单对话并行多轮 — 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<ParallelGroup>,
|
|
/// 顺序依赖边列表。空 `Vec` 表示「无跨组顺序约束」(组间全并行)。
|
|
pub sequence: Vec<SequentialDep>,
|
|
}
|
|
|
|
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<usize>> = 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<usize> = 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);
|
|
}
|
|
}
|