新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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 + 总线/技术债审查新文档)
This commit is contained in:
@@ -14,7 +14,10 @@
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//! 本文件仅保留 `ContextManager` 结构体及其 `impl`(Rust impl 块不可跨文件)。
|
||||
|
||||
use crate::context_helpers::{classify_group, PROTECT_COUNT, TOOL_MISSING_PREFIX};
|
||||
use crate::context_helpers::{
|
||||
classify_group, is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
TOOL_MISSING_PREFIX,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
@@ -155,7 +158,12 @@ impl ContextManager {
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
return (Self::sanitize_messages(self.all_messages_clone()), false);
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
@@ -189,7 +197,12 @@ impl ContextManager {
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
(Self::sanitize_messages(msgs), true)
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
@@ -209,7 +222,7 @@ impl ContextManager {
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
@@ -329,10 +342,165 @@ impl ContextManager {
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||
Self::drop_reverse_orphans(after_triplet)
|
||||
} else {
|
||||
after_triplet
|
||||
};
|
||||
|
||||
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||
Self::ensure_sequence_legal(after_triplet)
|
||||
Self::ensure_sequence_legal(after_reverse_orphan)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 sanitize_messages step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||
///
|
||||
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||
///
|
||||
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||
///
|
||||
/// **view-only**:本函数不改 self.messages,仅修改传入 Vec(clone),持久化全量保留。
|
||||
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||
///
|
||||
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !enabled {
|
||||
return messages;
|
||||
}
|
||||
// 收集现有头的 tool_call.id(已配对的)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||
let mut healed = 0u32;
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages.into_iter() {
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if let Some(id) = m.tool_call_id.as_deref() {
|
||||
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||
let head = ChatMessage::assistant_with_tools(
|
||||
String::new(),
|
||||
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||
);
|
||||
out.push(head);
|
||||
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||
let mut result = m;
|
||||
result.tool_call_id = Some(head_id);
|
||||
out.push(result);
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||
);
|
||||
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||
//
|
||||
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改 self.messages 持久化。
|
||||
return Self::ensure_sequence_legal(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||
///
|
||||
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||
@@ -548,10 +716,15 @@ impl ContextManager {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(阶段1 基础,零行为变化)──
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 这些方法供阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic loop
|
||||
// 自动压缩调用。本阶段只暴露方法 + 单测,不接 IPC/前端/agentic.rs。
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
@@ -668,10 +841,7 @@ impl ContextManager {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// ── 内部方法 ──
|
||||
|
||||
// (原 build_eviction_units / classify_group 等纯函数已在上方公开或文件级定义;
|
||||
// ContextManager 的私有辅助如需新增放在这里。)
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -781,6 +951,226 @@ mod tests {
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
let kept: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||
let healed_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.collect();
|
||||
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||
assert!(
|
||||
healed_heads[0].id.contains("call_pending_2"),
|
||||
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||
healed_heads[0].id
|
||||
);
|
||||
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||
let result_id = msgs
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||
// 持久化全量保留(view-only)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||
let missing_heads = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.count();
|
||||
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = ContextManager::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
let missing_heads: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing_heads.len(),
|
||||
1,
|
||||
"应补 1 个占位头, 实际 {:?}",
|
||||
missing_heads
|
||||
);
|
||||
|
||||
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||
let mut prev_is_assistant = false;
|
||||
let mut consecutive = 0u32;
|
||||
for m in &healed {
|
||||
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||
if is_assistant && prev_is_assistant {
|
||||
consecutive += 1;
|
||||
}
|
||||
prev_is_assistant = is_assistant;
|
||||
}
|
||||
assert_eq!(
|
||||
consecutive, 0,
|
||||
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||
healed
|
||||
.iter()
|
||||
.map(|m| format!("{:?}", m.role))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||
let assistant_msgs: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||
assistant_msgs.len(),
|
||||
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||
let merged = &assistant_msgs[0];
|
||||
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||
let merged_call_ids: Vec<&str> = merged
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& id.contains("call_pending_cc")),
|
||||
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||
merged_call_ids
|
||||
);
|
||||
|
||||
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||
let result_id = healed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert!(
|
||||
merged_call_ids.contains(&result_id.as_str()),
|
||||
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||
result_id,
|
||||
merged_call_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||
];
|
||||
let sanitized = ContextManager::sanitize_messages(msgs);
|
||||
let kept = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
|
||||
@@ -474,6 +474,69 @@ pub const PROTECT_COUNT: usize = 6;
|
||||
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
|
||||
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
|
||||
|
||||
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
|
||||
///
|
||||
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
|
||||
/// deepseek-v4-pro 等端点 400。本开关控制 sanitize step3.5(反向 orphan 丢弃) +
|
||||
/// 发送视图出口断言占位配对完整(失败降级 TOOL_MISSING_PREFIX 自愈)。
|
||||
///
|
||||
/// true(默认):启用反向 orphan 检测 + 出口占位配对断言自愈(根治 400)。
|
||||
/// false(回退):sanitize 仅做原正向 orphan(头无 result)处理,出口不断言(旧行为)。
|
||||
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
|
||||
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
|
||||
|
||||
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
|
||||
///
|
||||
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
|
||||
/// 如 "需要用户审批,等待确认__PENDING__:call_abc123"。读取处(sanitize/出口断言)按此前缀
|
||||
/// 切出 tc_id,据此把占位 tool_result 与其 tool_call 头强绑定:若头被裁/丢了,降级自愈
|
||||
/// (头补 TOOL_MISSING_PREFIX 或 tool_result 丢弃),防 orphan 触发 provider 400。
|
||||
///
|
||||
/// 设计权衡:不用独立字段(改 ChatMessage schema 跨 crate + DB 迁移成本高),而是 content
|
||||
/// 内嵌标记——占位文本固定且唯一(非用户内容),内嵌不污染语义(LLM 看到也理解"等待审批")。
|
||||
pub const PENDING_MARKER_PREFIX: &str = "__PENDING__:";
|
||||
|
||||
/// 判定 tool_result content 是否为审批挂起占位(含 PENDING_MARKER_PREFIX 标记)。
|
||||
///
|
||||
/// 兼容老占位(无标记,纯 PENDING_APPROVAL_PLACEHOLDER 文本)与新占位(带 __PENDING__:tc_id):
|
||||
/// - 新占位:content 含 PENDING_MARKER_PREFIX(`__PENDING__:` 是独占信号,权威判定)
|
||||
/// - 老占位:content == PENDING_APPROVAL_PLACEHOLDER(向前兼容,迁移期共存)
|
||||
///
|
||||
/// **收紧(误判防护)**:`__PENDING__` 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在
|
||||
/// 用户正文里 → 标记存在即权威。老占位分支原用 `starts_with("需要用户审批")` 过松:若用户真实
|
||||
/// tool_result(如读到的文档片段)恰好以"需要用户审批..."开头,会被误判占位 → sanitize 豁免保留
|
||||
/// 或 compress 跳过压缩,污染 LLM 上下文。收紧为**精确等于**老占位全文(与 audit/cache.rs
|
||||
/// `PENDING_APPROVAL_PLACEHOLDER = "需要用户审批,等待确认"` 字面量同步),杜绝前缀误伤。
|
||||
///
|
||||
/// 注:老占位字面量在 src-tauri audit/cache.rs 定义(pub(crate) 不可跨 crate 引用),
|
||||
/// 此处内联同步字面量并加测试锁定;改字面量须同步 audit/cache.rs 与下方单测。
|
||||
pub fn is_pending_placeholder(content: &str) -> bool {
|
||||
// 权威信号:`__PENDING__` 标记独占,存在即占位。
|
||||
if content.contains(PENDING_MARKER_PREFIX) {
|
||||
return true;
|
||||
}
|
||||
// 收紧:老占位精确全文匹配(非 starts_with 前缀),杜绝用户内容前缀误伤。
|
||||
content == LEGACY_PENDING_PLACEHOLDER_TEXT
|
||||
}
|
||||
|
||||
/// 老占位(无 __PENDING__ 标记)的精确全文,与 audit/cache.rs `PENDING_APPROVAL_PLACEHOLDER` 同步。
|
||||
///
|
||||
/// 单独常量便于:① 字面量漂移自检(改字面量搜此常量即定位所有引用);② 测试锁定。
|
||||
/// 独立于 PENDING_MARKER_PREFIX(新占位用),老占位是迁移期共存数据。
|
||||
pub const LEGACY_PENDING_PLACEHOLDER_TEXT: &str = "需要用户审批,等待确认";
|
||||
|
||||
/// 从占位 tool_result content 切出嵌入的 tc_id(供 sanitize 反向定位其 tool_call 头)。
|
||||
///
|
||||
/// content 形如 "...__PENDING__:call_abc123",切出 "call_abc123"。无标记 → None(老占位
|
||||
/// 或非占位,调用方按无 tc_id 处理:走常规反向 orphan 检测,占位无标记时不享受强绑定保护)。
|
||||
pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
|
||||
content
|
||||
.split_once(PENDING_MARKER_PREFIX)
|
||||
.map(|(_, id)| id.trim())
|
||||
.filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -929,4 +992,88 @@ mod tests {
|
||||
assert!(toks.contains(&"call".to_string()));
|
||||
assert!(toks.contains(&"now".to_string()));
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_new_with_marker() {
|
||||
// 新占位:占位文本 + __PENDING__:tc_id 标记
|
||||
let content = "需要用户审批,等待确认__PENDING__:call_abc123";
|
||||
assert!(is_pending_placeholder(content), "带标记的新占位应识别");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_legacy_without_marker() {
|
||||
// 老占位:纯文本无标记(向前兼容,迁移期共存)
|
||||
assert!(is_pending_placeholder("需要用户审批,等待确认"), "老占位(纯文本)应识别");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_rejects_normal_content() {
|
||||
// 非占位的正常 tool_result 内容不应误判
|
||||
assert!(!is_pending_placeholder("文件内容: hello world"));
|
||||
assert!(!is_pending_placeholder("{\"ok\":true}"));
|
||||
assert!(!is_pending_placeholder(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_rejects_user_content_with_prefix() {
|
||||
// 收紧(防误伤):用户真实 tool_result 内容恰好以"需要用户审批"开头(如读到的文档片段),
|
||||
// 但不是完整老占位全文 → 不应误判为占位(否则 sanitize 豁免保留/compress 跳过压缩污染 LLM)。
|
||||
assert!(
|
||||
!is_pending_placeholder("需要用户审批的文件清单如下:\n1. 文档A\n2. 文档B"),
|
||||
"以占位前缀开头但非完整老占位的用户内容,不应误判占位"
|
||||
);
|
||||
assert!(
|
||||
!is_pending_placeholder("需要用户审批一下这个改动,我看不太懂"),
|
||||
"前缀 + 后续不同文本不应误判"
|
||||
);
|
||||
// 仅完整老占位全文才匹配(精确等值,非前缀)
|
||||
assert!(
|
||||
is_pending_placeholder(LEGACY_PENDING_PLACEHOLDER_TEXT),
|
||||
"完整老占位全文应识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_pending_placeholder_marker_is_authoritative_signal() {
|
||||
// 对抗:__PENDING__: 标记是 audit/cache.rs 占位模板独占字符串,绝不会出现在用户正文里。
|
||||
// 故标记存在即权威信号(即使非开头也认占位)——独占语义保证不误判。
|
||||
assert!(
|
||||
is_pending_placeholder("任意内容__PENDING__:call_x"),
|
||||
"__PENDING__ 标记是独占信号,存在即识别为占位"
|
||||
);
|
||||
// 反向:无标记 + 非占位文本开头 → 不识别
|
||||
assert!(
|
||||
!is_pending_placeholder("用户问:这个需要审批吗?"),
|
||||
"无标记 + 非占位模板开头不应识别"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_new_marker() {
|
||||
// 新占位:切出 __PENDING__: 之后的 tc_id
|
||||
let content = "需要用户审批,等待确认__PENDING__:call_xyz789";
|
||||
assert_eq!(extract_pending_tc_id(content), Some("call_xyz789"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_legacy_no_marker() {
|
||||
// 老占位:无标记 → None
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_empty_id() {
|
||||
// 标记后无内容(异常)→ None(防空 tc_id 误判)
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__:"), None);
|
||||
assert_eq!(extract_pending_tc_id("需要用户审批,等待确认__PENDING__: "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_pending_tc_id_non_placeholder() {
|
||||
// 非占位内容 → None
|
||||
assert_eq!(extract_pending_tc_id("文件内容"), None);
|
||||
assert_eq!(extract_pending_tc_id(""), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -420,6 +420,106 @@ pub fn filter_tool_defs(
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ---- 工具子集过滤 + plan_hint 编排(B 路线 Phase 1 接入主 loop) --------------
|
||||
|
||||
/// 在 `filter_tool_defs` 收敛的扁平子集之上,叠加 `plan_hint` 的编排关系
|
||||
/// (并行组同批聚拢 / 顺序依赖源在前),供 LLM 看到一份**按编排意图排序**的工具列表。
|
||||
///
|
||||
/// **Phase 1 接入语义**(依据
|
||||
/// `docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md` §五 Phase 1 +
|
||||
/// `单对话并行多轮-Phase0落地路线图-2026-06-20.md` §八 Phase 1 硬前置):
|
||||
/// - **纯函数零延迟**(与 intent/plan_hint 一致,无 IO 无 LLM 调用)。
|
||||
/// - **不真正多轮并行**(Phase 1 仅提示层;真正子流并行 execution 留 Phase 3)。
|
||||
/// - 行为 = 先 `filter_tool_defs` 收敛 → 再按 `plan_hint` 产出排序。
|
||||
///
|
||||
/// **三重 fallback(与 filter_tool_defs 一致的安全语义)**:
|
||||
/// 1. `plan_hint` 返空 PlanHint(无编排信号/单工具/闲聊)→ 退 `filter_tool_defs` 扁平结果。
|
||||
/// 2. `plan_hint::validate` 判非法(环/空组/自环/重复边)→ 丢弃 hint 退扁平结果
|
||||
/// (主 loop 不能据非法 hint 调度,否则死锁)。
|
||||
/// 3. hint 引用的工具名不在 filtered 子集(registry 漂移)→ 该名跳过,剩余照常排序。
|
||||
///
|
||||
/// **排序规则**(确定性,便于单测固定行为):
|
||||
/// - 收集 hint 所有并行组里的工具名,按「组序 → 组内顺序」展开成有序列表 `ordered`。
|
||||
/// - 输出 = `ordered` 中能在 filtered 找到的(保 ordered 顺序) +
|
||||
/// filtered 中未被 ordered 命中的(保 filtered 原序,放尾部兜底,防丢工具)。
|
||||
/// - 顺序依赖(`sequence` 边)的「from」节点天然被并行组顺序吸收(组在前 = from 先出现),
|
||||
/// Phase 1 不额外重排依赖边(真正 DAG 调度留 Phase 3 Kahn 分层)。
|
||||
///
|
||||
/// **关键安全**(与 filter_tool_defs 同):本函数**只改 LLM 可见 tool_defs 的顺序/可见性**,
|
||||
/// 不改执行路径(audit 走 tools_arc 完整 registry)。LLM 即使幻觉被滤掉的顺序,
|
||||
/// audit 仍能查到/拒绝。
|
||||
///
|
||||
/// `intent`:主 loop 已识别的意图;其 `as_str()` 标签内部派生传给 plan_hint。
|
||||
/// `intent_label`:**冗余参数**(等价于 `intent.as_str()`),仅为调用方签名兼容保留,
|
||||
/// 内部已忽略——以 `intent.as_str()` 为权威源,防调用方传错标签与实际 intent 不一致。
|
||||
/// `context`:用户末条消息原文(plan_hint 关键词检测用)。
|
||||
pub fn filter_tool_defs_planned(
|
||||
all_defs: &[df_ai_core::types::ToolDefinition],
|
||||
intent: &Intent,
|
||||
intent_label: &str,
|
||||
context: &str,
|
||||
) -> Vec<df_ai_core::types::ToolDefinition> {
|
||||
// 第一步:先得扁平收敛子集(复用 filter_tool_defs 的全部 fallback 语义)。
|
||||
let filtered = filter_tool_defs(all_defs, intent);
|
||||
|
||||
// 第二步:产 PlanHint。PLAN_HINT_ENABLED 关时 plan_hint 内部返空 → 退扁平。
|
||||
// intent_label 内部由 intent.as_str() 派生(冗余参数权威性低于 intent 本身)。
|
||||
let label = intent.as_str();
|
||||
let _ = intent_label; // 冗余:签名兼容保留,内部以 intent.as_str() 为准。
|
||||
let hint = crate::plan_hint::plan_hint(label, context);
|
||||
|
||||
// 空 hint(无编排信号)→ 直接返扁平收敛结果(零行为变更 vs filter_tool_defs)。
|
||||
if hint.is_empty() {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 非法 hint(环/空组/自环/重复边)→ 丢弃,退扁平(主 loop 不能据非法 hint 调度)。
|
||||
if !matches!(crate::plan_hint::validate(&hint), crate::plan_hint::HintValidity::Ok) {
|
||||
return filtered;
|
||||
}
|
||||
|
||||
// 第三步:按 hint 编排关系重排 filtered。先展开「组序 → 组内序」的有序工具名表。
|
||||
let mut ordered_names: Vec<&'static str> = Vec::new();
|
||||
for g in &hint.groups {
|
||||
for t in &g.tools {
|
||||
if !ordered_names.contains(t) {
|
||||
ordered_names.push(*t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 第四步:用 HashMap 索引(filtered 名 → 其在该 Vec 中的位置)替代「ordered × filtered」
|
||||
// 嵌套线性扫描。原版对每个 ordered 名做一次 O(filtered) 内层扫描,总 O(ordered × filtered);
|
||||
// 索引化后 O(filtered) 建索引 + 每 ordered 名 O(1) 查(filtered 通常 5-20 个工具,
|
||||
// 索引开销极小,但消除最坏 O(n²) 当 ordered/filtered 同时增长)。
|
||||
use std::collections::HashMap;
|
||||
// name_to_idx:工具名 → filtered 中首次出现位置(filtered 内工具名唯一,无二义)。
|
||||
let mut name_to_idx: HashMap<&str, usize> = HashMap::with_capacity(filtered.len());
|
||||
for (i, d) in filtered.iter().enumerate() {
|
||||
// filtered 名唯一(同 domain 不会重复),with_capacity 后直接 insert 不冲突。
|
||||
name_to_idx.entry(d.function.name.as_str()).or_insert(i);
|
||||
}
|
||||
|
||||
// 按 ordered 顺序挑 filtered 里的(命中编排顺序的工具置前)。
|
||||
let mut result: Vec<df_ai_core::types::ToolDefinition> = Vec::with_capacity(filtered.len());
|
||||
let mut consumed: std::collections::HashSet<usize> = std::collections::HashSet::new();
|
||||
for name in &ordered_names {
|
||||
if let Some(&idx) = name_to_idx.get(*name) {
|
||||
if consumed.insert(idx) {
|
||||
result.push(filtered[idx].clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
// 未被 ordered 命中的(filtered 里有但 hint 没标)→ 保 filtered 原序追加尾部兜底(防丢工具)。
|
||||
for (i, d) in filtered.iter().enumerate() {
|
||||
if consumed.insert(i) {
|
||||
result.push(d.clone());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ---- 单测 -------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -455,7 +555,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognize_debug_zh() {
|
||||
fn recognize_code_wins_over_debug_in_specific_group() {
|
||||
let (i, c) = IntentRecognizer::recognize("这个 bug 怎么复现,帮我调试排查一下");
|
||||
// "bug" 在 Code 组,"复现/调试/排查" 在 Debug 组 —— SPECIFIC 组内 Code 命中先于 Debug
|
||||
// Code 命中(bug=1.0) 即胜出本组,不再看 Debug。断言优先级正确:Code 胜出。
|
||||
@@ -975,4 +1075,138 @@ mod tests {
|
||||
assert_eq!(http_count, 1, "Intent {:?} subset http_request 应去重为 1", intent);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== filter_tool_defs_planned(B 路线 Phase 1 接入主 loop 用) =====
|
||||
//
|
||||
// 单测覆盖 Phase 1 接入语义:flag 关(filter_tool_defs 旧行为)、
|
||||
// plan_hint 空 hint 退扁平、非法 hint 退扁平、registry 漂移跳过、有序重排。
|
||||
// PLANNING_ENABLED(planner.rs)门控主 loop 接入点;PLAN_HINT_ENABLED(plan_hint.rs)
|
||||
// 门控 plan_hint 函数内部产出。本组单测直接验证 filter_tool_defs_planned
|
||||
// 纯函数行为(两层 flag 均为当前默认值时 plan_hint 函数产非空 hint)。
|
||||
|
||||
#[test]
|
||||
fn planned_empty_intent_subset_falls_back_to_full() {
|
||||
// Unknown subset 空 → filter_tool_defs 回全量 → plan_hint(无编排信号或闲聊)→ 扁平全量。
|
||||
// 校验 fallback 链完整:不会因 plan_hint 误丢工具。
|
||||
let all = vec![tool_def("read_file"), tool_def("write_file"), tool_def("http_request")];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::Unknown, "unknown", "你好谢谢");
|
||||
assert_eq!(out.len(), all.len(), "Unknown + 闲聊应回全量(双 fallback)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_plain_chat_yields_filter_result_unordered() {
|
||||
// Chat subset 空 → filter_tool_defs 回全量;context 无编排信号 → plan_hint 空 → 退扁平。
|
||||
// 关键:即使 PLANNING_ENABLED(true 时)走 planned 路径,无编排信号时输出与
|
||||
// filter_tool_defs 一致(顺序 + 内容),零行为变更。
|
||||
let all = vec![
|
||||
tool_def("read_file"),
|
||||
tool_def("write_file"),
|
||||
tool_def("http_request"),
|
||||
];
|
||||
let planned = filter_tool_defs_planned(&all, &Intent::Chat, "chat", "你好谢谢");
|
||||
let flat = filter_tool_defs(&all, &Intent::Chat);
|
||||
let planned_names: Vec<&str> = planned.iter().map(|d| d.function.name.as_str()).collect();
|
||||
let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(planned_names, flat_names, "无编排信号 planned 应与 filter 扁平一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_read_then_modify_orders_read_first() {
|
||||
// 读后写编排信号 → plan_hint 产 read_file→write_file 依赖边 → planned 把 read_file 置前。
|
||||
// File intent subset 含 read_file/write_file/patch_file 等全 File domain。
|
||||
let all = vec![
|
||||
// 故意把 write_file 放前,验证 planned 把 read_file 重排到 write_file 之前
|
||||
tool_def("write_file"),
|
||||
tool_def("patch_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
tool_def("http_request"), // 不在 File domain,应被滤掉
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 这个文件再修改它");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
// http_request 应被滤掉(intent 收敛)
|
||||
assert!(!names.contains(&"http_request"), "File 意图应滤掉 http_request");
|
||||
// read_file 应出现在 write_file 之前(plan_hint 编排:读组在前)
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").expect("应含 read_file");
|
||||
let write_idx = names.iter().position(|n| *n == "write_file").expect("应含 write_file");
|
||||
assert!(read_idx < write_idx, "plan_hint 应把 read_file 排到 write_file 前, 实际顺序: {:?}", names);
|
||||
// 不丢工具:http_request 滤掉后剩 9 个(全 File domain 9 工具)
|
||||
assert_eq!(out.len(), 9, "planned 不应丢工具(除被 intent 滤掉), 实际: {:?}", names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_preserves_unordered_tail_for_hint_missing_tools() {
|
||||
// plan_hint 标了 read_file/write_file,但 filtered 里还有 hint 没标的工具(list_directory 等)
|
||||
// → 未被 hint 命中的按 filtered 原序追加尾部兜底(防丢工具)。
|
||||
let all = vec![
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("write_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("patch_file"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 文件再修改 write_file");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
// 全部 9 个工具都应保留(hint 未标的不丢)
|
||||
assert_eq!(out.len(), 9, "未命中 hint 的工具应追加尾部兜底, 不丢, 实际: {:?}", names);
|
||||
// read_file 仍置前(命中 hint 编排)
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
|
||||
let write_idx = names.iter().position(|n| *n == "write_file").unwrap();
|
||||
assert!(read_idx < write_idx);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_registry_drift_skips_missing_names() {
|
||||
// registry 漂移:hint 标 read_file/write_file 但 all_defs 没这俩(改了名)。
|
||||
// → ordered 命中 0 个,全部走「未被 ordered 命中」追加 = filtered 原序(退 filter_tool_defs 行为)。
|
||||
let all = vec![
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
// 这条消息会产 read→write hint,但 all_defs 里没 read_file/write_file
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "先 read 再修改 write_file");
|
||||
let flat = filter_tool_defs(&all, &Intent::File);
|
||||
// 漂移:hint 工具名都不在 → 退扁平(filtered 原序),不 panic,不丢不增。
|
||||
assert_eq!(out.len(), flat.len(), "漂移应退扁平, 实际 planned={}, flat={}", out.len(), flat.len());
|
||||
let out_names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
let flat_names: Vec<&str> = flat.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(out_names, flat_names, "漂移时 planned 应与 filter 扁平一致");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_multi_read_single_parallel_group_preserves_all() {
|
||||
// 规则 3:读多文件(无写)→ plan_hint 产单并行组 [read_file, list_directory]。
|
||||
// 全部命中 filtered → 两个工具置前(组内序),其余 filtered 工具追加尾部。
|
||||
let all = vec![
|
||||
tool_def("patch_file"),
|
||||
tool_def("read_file"),
|
||||
tool_def("write_file"),
|
||||
tool_def("list_directory"),
|
||||
tool_def("search_files"),
|
||||
tool_def("file_info"),
|
||||
tool_def("append_file"),
|
||||
tool_def("delete_file"),
|
||||
tool_def("rename_file"),
|
||||
];
|
||||
let out = filter_tool_defs_planned(&all, &Intent::File, "file", "read 文件再看 directory 目录");
|
||||
let names: Vec<&str> = out.iter().map(|d| d.function.name.as_str()).collect();
|
||||
assert_eq!(out.len(), 9, "planned 不应丢工具");
|
||||
// read_file 和 list_directory 都在 hint 单组,应置前
|
||||
let read_idx = names.iter().position(|n| *n == "read_file").unwrap();
|
||||
let dir_idx = names.iter().position(|n| *n == "list_directory").unwrap();
|
||||
assert!(read_idx < 4 && dir_idx < 4, "组内工具应置前, 实际 read_idx={} dir_idx={}", read_idx, dir_idx);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,16 @@ pub mod model_probe;
|
||||
// model_probe 的纯逻辑子模块(预设表加载 / 启发式推断 / 词素判定),
|
||||
// crate 内复用,private mod — 外部经 model_probe 间接访问,无直接路径需求。
|
||||
mod model_probe_helpers;
|
||||
// 单对话并行多轮 Phase 0a · 0.8 plan_hint 纯函数(扩 intent::tool_subset_for 编排语义)。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-Phase0落地路线图-2026-06-20.md 第 0.8 节。
|
||||
// 纯函数(零 IO 零状态),Phase 1 接入主 loop,LLM 调度留 Phase 2。
|
||||
pub mod plan_hint;
|
||||
pub mod openai_compat;
|
||||
pub mod openai_helpers;
|
||||
// Phase 0 (B 路线): Plan-driven 规划骨架(维度 0.8 Plan/SubTask 结构 + 0.7 规划校验/Kahn 分层)。
|
||||
// 纯函数模块(零 IO 零状态)。依据 docs/02-架构设计/单对话并行多轮-{设计,Phase0落地路线图}-2026-06-20.md。
|
||||
// Phase 1(plan_hint 接入主 loop)/Phase 2(planning)/Phase 3(并行 execution) 接入后续。
|
||||
pub mod planner;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
|
||||
|
||||
708
crates/df-ai/src/plan_hint.rs
Normal file
708
crates/df-ai/src/plan_hint.rs
Normal file
@@ -0,0 +1,708 @@
|
||||
//! 单对话并行多轮 — 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);
|
||||
}
|
||||
}
|
||||
1019
crates/df-ai/src/planner.rs
Normal file
1019
crates/df-ai/src/planner.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user