修复+重构: 全库走查真bug+架构+P1/P2 后端 crate
- df-nodes: schema required 对齐 + docker POSIX 注入防御 + HumanNode timeout 1800 + parse_review_json(verdict规范/score clamp/正则兜底) - df-mcp: update 实体校验(防跨实体 B-260801-01) - df-storage: keyring 迁移失败达阈值清除明文 - df-ai: router estimated_context+tier tiebreak+DataReadOnly 兜底 + sanitize step4 显式不制造 orphan - df-ideas: adversarial tier:None 对齐
This commit is contained in:
@@ -358,16 +358,50 @@ pub fn assert_placeholder_pairing(
|
||||
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
|
||||
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
|
||||
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||
///
|
||||
/// **不制造 orphan(自洽,根本修)**:开头 skip assistant 头时,**显式连带 skip 其后命中的 tool_result**
|
||||
/// (id 在被 skip 头的 tool_calls 内)。旧实现依赖隐式巧合——367 行 `Assistant | Tool` 联合判断 +
|
||||
/// `fixed.is_empty()` 在整组 skip 期间保持 true,使开头整组(head + result)被一起丢。这个契约脆弱:
|
||||
/// (1) 若有人改开头 skip 只针对 Assistant(为修连续 role),立刻制造 orphan;
|
||||
/// (2) step3.5(`drop_reverse_orphans`)在 step4 前跑,无法预见 step4 自身 skip 制造的 orphan;
|
||||
/// (3) memory `ai-router-sanitize-refactor-debt` 走查标 P1 隐式契约债。
|
||||
/// 根本修:step4 主动跟踪自己 skip 掉的 head 的 tool_call.id,后续 tool_result 命中即连带丢,
|
||||
/// 把"不制造 orphan"从隐式巧合变为显式机制——不依赖下游 `assert_placeholder_pairing` 出口断言兜底。
|
||||
/// 保留 367 行"开头 tool_result 也 skip"(防御性兜底:防 step3.5 未跑/开关关闭时孤儿 result 漏网)。
|
||||
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
let mut skipped = 0u32;
|
||||
let mut merged = 0u32;
|
||||
// 被 step4 自身 skip 掉的 assistant 头的 tool_call.id 集合。
|
||||
// 后续 tool_result 若命中(id 在此集合),说明其头被 step4 skip 掉了 → 连带丢(不制造 orphan)。
|
||||
let mut skipped_head_ids: HashSet<String> = HashSet::new();
|
||||
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages {
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)。
|
||||
// assistant 头被 skip 时,记录其 tool_call.id,后续 result 命中即连带丢(根本修:不制造 orphan)。
|
||||
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||
if matches!(m.role, MessageRole::Assistant) {
|
||||
if let Some(calls) = m.tool_calls.as_ref() {
|
||||
for c in calls {
|
||||
skipped_head_ids.insert(c.id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// 自洽兜底(根本修):tool_result 的 id 命中被 skip 头集合 → 其头已被 step4 自身丢,
|
||||
// 留下即 orphan(直送 provider 400)。显式连带丢,不依赖下游出口断言兜底。
|
||||
// 注:仅丢"step4 自己制造的 orphan",step3.5 已补头的 result 不受影响(其头在 fixed 中,id 不在集合)。
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if m.tool_call_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| skipped_head_ids.contains(id))
|
||||
{
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||
if let Some(last) = fixed.last_mut() {
|
||||
@@ -394,7 +428,7 @@ pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
"序列修复:skip 开头非 user(含连带丢 orphan result)+ 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
);
|
||||
}
|
||||
fixed
|
||||
@@ -1017,4 +1051,157 @@ mod tests {
|
||||
"result id 应与补头 id 一致(闭合配对)"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// step4 不制造 orphan(根本修:显式整组 skip,不依赖下游出口断言)
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn ensure_sequence_legal_skips_leading_assistant_head_with_its_tool_result() {
|
||||
// 根本修核心场景( memory ai-router-sanitize-refactor-debt P1 隐式契约债):
|
||||
// 开头 user 被 step0/裁剪裁掉后,序列以合法 assistant 工具三元组头开头:
|
||||
// [asst(head, tc_a)] [tool_result(tc_a)] [user "实问"]
|
||||
// step4 skip 开头 assistant 头(首条非 user)→ 必须连带 skip 其后的 tool_result(tc_a),
|
||||
// 否则 tool_result 无头成 orphan(直送 provider 400)。
|
||||
//
|
||||
// 旧实现依赖隐式巧合:367 行 `Assistant | Tool` 联合判断 + fixed.is_empty() 整组保持 true,
|
||||
// 使开头 tool_result 也被 skip。本测验证根本修后**显式机制**(跟踪被 skip head 的 id,
|
||||
// result 命中即连带丢)——即使将来有人改 367 行只 skip Assistant,本测仍通过(连带丢兜底)。
|
||||
let msgs = vec![
|
||||
ChatMessage::assistant_with_tools(
|
||||
"调用工具",
|
||||
vec![ToolCall::new("tc_a", "read_file", "{}")],
|
||||
),
|
||||
ChatMessage::tool_result("tc_a", "工具结果"),
|
||||
ChatMessage::user("实问"),
|
||||
];
|
||||
let fixed = ensure_sequence_legal(msgs);
|
||||
|
||||
// 输出仅剩首条 user(开头三元组整组丢,不留 orphan tool_result)
|
||||
assert_eq!(
|
||||
fixed.len(),
|
||||
1,
|
||||
"开头 asst(head)+tool_result 应整组 skip, 实际 {} 条: {:?}",
|
||||
fixed.len(),
|
||||
fixed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(fixed[0].role, MessageRole::User));
|
||||
assert_eq!(fixed[0].content, "实问");
|
||||
// 核心断言:输出中不得有任何 tool_result(无 orphan)
|
||||
let tool_count = fixed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(tool_count, 0, "step4 不应留下 orphan tool_result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_sequence_legal_no_orphan_when_leading_user_dropped_mid_sequence() {
|
||||
// 根本修进阶场景:开头 user 被裁 + 中间还有合法三元组(其 head 也在开头被 skip)。
|
||||
// 序列:[asst(head1, tc_a)] [tool(tc_a)] [asst(head2, tc_b)] [tool(tc_b)] [user "问"]
|
||||
// 旧隐式契约:整组都 skip(fixed 全程空,所有 Assistant|Tool 命中 367)。
|
||||
// 根本修后:head1 skip(记 tc_a),tc_a 连带丢(命中集合);head2 skip(记 tc_b),tc_b 连带丢。
|
||||
// 结果:只剩 [user "问"]。验证多组三元组在开头被整组 skip 不留 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::assistant_with_tools(
|
||||
"调1",
|
||||
vec![ToolCall::new("tc_a", "fn_a", "{}")],
|
||||
),
|
||||
ChatMessage::tool_result("tc_a", "结果A"),
|
||||
ChatMessage::assistant_with_tools(
|
||||
"调2",
|
||||
vec![ToolCall::new("tc_b", "fn_b", "{}")],
|
||||
),
|
||||
ChatMessage::tool_result("tc_b", "结果B"),
|
||||
ChatMessage::user("问"),
|
||||
];
|
||||
let fixed = ensure_sequence_legal(msgs);
|
||||
assert_eq!(
|
||||
fixed.len(),
|
||||
1,
|
||||
"开头两组三元组应整组 skip, 实际 {:?}",
|
||||
fixed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(matches!(fixed[0].role, MessageRole::User));
|
||||
// 不留任何 orphan tool_result
|
||||
let tool_count = fixed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(tool_count, 0, "不应留下 orphan tool_result");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_sequence_legal_preserves_well_formed_triplet_after_user() {
|
||||
// 根本修回归保护:user 在前 + 完整合法三元组在后 → 三元组原样保留(不被误丢)。
|
||||
// 验证根本修的"连带丢 orphan"只针对"被 step4 自己 skip 的头",
|
||||
// 不影响 user 之后正常三元组(其 head 进 fixed 不被 skip,id 不入 skipped_head_ids)。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("开场"),
|
||||
ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("tc_ok", "fn", "{}")],
|
||||
),
|
||||
ChatMessage::tool_result("tc_ok", "结果"),
|
||||
ChatMessage::user("收尾"),
|
||||
];
|
||||
let fixed = ensure_sequence_legal(msgs);
|
||||
// 全保留:首条 user 已合法,后续三元组闭合,无 skip 无合并
|
||||
assert_eq!(
|
||||
fixed.len(),
|
||||
4,
|
||||
"合法序列应原样保留, 实际 {} 条",
|
||||
fixed.len()
|
||||
);
|
||||
assert!(matches!(fixed[0].role, MessageRole::User));
|
||||
assert!(matches!(fixed[1].role, MessageRole::Assistant));
|
||||
assert!(matches!(fixed[2].role, MessageRole::Tool));
|
||||
assert!(matches!(fixed[3].role, MessageRole::User));
|
||||
// tool_result 保留(配对闭合,非 orphan)
|
||||
let tool = fixed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.expect("合法三元组的 tool_result 应保留");
|
||||
assert_eq!(tool.tool_call_id.as_deref(), Some("tc_ok"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_messages_step4_self_contained_no_orphan_without_exit_assert() {
|
||||
// 端到端根本修验证(不经出口断言):模拟"开头 user 被裁 + 直接调 sanitize_messages"
|
||||
// (即用户担心的"若直接调 sanitize_messages 不经出口,orphan 直送 provider 400"路径)。
|
||||
// 根本修后:step4 自洽,sanitize_messages 输出本身不含 step4 制造的 orphan,
|
||||
// 不依赖调用方再跑 assert_placeholder_pairing 兜底。
|
||||
//
|
||||
// 场景:step3.5(PLACEHOLDER_INTEGRITY_ENABLED=true)跑过后,开头三元组闭合,
|
||||
// 但 step4 skip 开头 assistant 头 → 根本修连带丢其 result。
|
||||
// 输入模拟"开头 user 被 step0 过滤掉":序列以 assistant head 开头。
|
||||
let orphan_inducing = vec![
|
||||
ChatMessage::assistant_with_tools(
|
||||
"调工具",
|
||||
vec![ToolCall::new("tc_step4", "fn", "{}")],
|
||||
),
|
||||
ChatMessage::tool_result("tc_step4", "工具结果"),
|
||||
ChatMessage::user("后续问题"),
|
||||
];
|
||||
let sanitized = sanitize_messages(orphan_inducing);
|
||||
|
||||
// 核心断言:sanitize_messages 输出无 orphan tool_result(不依赖出口断言)
|
||||
let orphan_tool_results: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert!(
|
||||
orphan_tool_results.is_empty(),
|
||||
"step4 根本修:sanitize_messages 输出不应含 orphan tool_result(自洽,不依赖出口断言), 实际 {:?}",
|
||||
sanitized.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 首条必须是 user(step4 序列修复后)
|
||||
assert!(
|
||||
sanitized
|
||||
.first()
|
||||
.is_some_and(|m| matches!(m.role, MessageRole::User)),
|
||||
"首条必须是 user, 实际 {:?}",
|
||||
sanitized.first().map(|m| format!("{:?}", m.role))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-32
@@ -8,8 +8,9 @@
|
||||
//! 1. `IntentRecognizer::recognize(message)` —— 规则/关键词匹配(方式 A,零延迟零成本)
|
||||
//! 返回 `(Intent, f32)`,置信度 0.0–1.0。低置信 → 上游 fallback 全量工具。
|
||||
//! 2. `tool_subset_for(intent)` —— 硬编码工具名→domain 映射,工具名子集(空 = 全量 fallback)。
|
||||
//! 3. `suggested_model_tier(intent)` —— 模态建议**接口预留**,当前恒返 `None`
|
||||
//! (待模型模态管理 Phase 落地后补充实际逻辑)。
|
||||
//! 3. `suggested_model_tier(intent)` —— 模型模态档位建议(意图 → `ModelTier` 映射):
|
||||
//! Code/Debug/Http → Heavy,File/Search/Task/Idea/Project/Conversation → Standard,
|
||||
//! Chat → Fast,Unknown → None(不强加偏好)。router 同 weight tiebreak + 模型路由用。
|
||||
//!
|
||||
//! ## 设计原则
|
||||
//! - **不碰** `tool_registry`:domain 映射在本文件内硬编码工具名常量,运行期不读 registry。
|
||||
@@ -70,12 +71,12 @@ impl Intent {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ModelTier 预留 ---------------------------------------------------------
|
||||
// ---- ModelTier 档位 ---------------------------------------------------------
|
||||
|
||||
/// 模型模态档位(**预留**)。
|
||||
/// 模型模态档位。
|
||||
///
|
||||
/// 待模型模态管理 Phase 落地后定义实际 provider/model 映射。
|
||||
/// 当前仅占位于 `suggested_model_tier` 返回类型,逻辑恒返 `None`。
|
||||
/// `suggested_model_tier` 据 Intent 映射到此档位,供 router 同 weight tiebreak
|
||||
/// (重档位优先)或后续 provider/model 路由(待模型模态管理 Phase 接入)使用。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ModelTier {
|
||||
/// 轻量快速(简单意图/闲聊)
|
||||
@@ -230,8 +231,14 @@ const GENERIC_GROUP: &[IntentGroup] = &[
|
||||
/// 本表硬编码,不读 registry 运行期状态(保持模块独立可单测)。
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ToolDomain {
|
||||
/// 数据/业务:项目/任务/灵感/工作流/回收站
|
||||
/// 数据/业务:项目/任务/灵感/工作流/回收站(全量,含写工具)
|
||||
Data,
|
||||
/// 数据只读子集:list_*/get_*_count(不含 create/update/delete/advance/run_workflow/restore/purge/bind_directory)
|
||||
///
|
||||
/// 用途:`Intent::Code` 收敛工具时,既保留编码场景用户常需要的"看一下项目/任务结构"只读
|
||||
/// 探索工具(list_projects/list_tasks/list_ideas/list_trash),又不放大暴露面(不暴露写工具,
|
||||
/// 对齐 `Intent::Code` 不含 `Intent::Debug` 才有的 run_command 那类收紧设计)。
|
||||
DataReadOnly,
|
||||
/// 文件:读写/patch/列目录/搜索(不含命令执行)
|
||||
File,
|
||||
/// 命令执行:run_command(shell 命令,独立 domain 防止被泛 File 意图带出)
|
||||
@@ -280,6 +287,20 @@ impl ToolDomain {
|
||||
// Code/File/Search 不带 → 减少 LLM 对 run_command 的偏好暴露。
|
||||
ToolDomain::Exec => &["run_command"],
|
||||
ToolDomain::Http => &["http_request"],
|
||||
// DataReadOnly:Data domain 的只读子集。
|
||||
// 源于 Code 意图收敛需求:用户在编码场景说"先 list_projects 看下项目结构"
|
||||
// "create_task 记一下"时,意图识别可能命中 Code(SPECIFIC > ENTITY),原 Code subset
|
||||
// 不含 Data domain → list_projects/create_task 对 LLM 不可见,agent 被迫反复 read_file。
|
||||
// 加 Data 全 domain 会暴露 create/update/delete 等写工具(放大暴露面),
|
||||
// 故取只读子集:list_*/get_*_count(纯查询,无副作用)。
|
||||
ToolDomain::DataReadOnly => &[
|
||||
"list_projects",
|
||||
"list_tasks",
|
||||
"list_ideas",
|
||||
"list_trash",
|
||||
"get_project_count",
|
||||
"get_task_count",
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -416,14 +437,17 @@ fn best_in_group(message: &str, group: &[IntentGroup]) -> Option<(Intent, f32)>
|
||||
/// 返回空 `Vec` 表示该意图**无工具收敛**(Chat)或**未识别**(Unknown),
|
||||
/// 上游应走**全量 fallback**(即不过滤工具,交全量给 LLM)。
|
||||
///
|
||||
/// 设计:Code → [file, http];File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||
/// 设计:Code → [file, http, data_readonly](加 data_readonly:编码场景用户常需"先 list_projects
|
||||
/// 看下结构/list_tasks 记一下",Code 命中优先级高于 Project/Task(SPECIFIC > ENTITY)会砍 Data domain,
|
||||
/// 取只读子集防"断手"又不放大写工具暴露面,见 ToolDomain::DataReadOnly);
|
||||
/// File → [file];Project/Task/Idea → [data, file](加 file 防「提项目/任务 → 误判 → 砍只读探索」,见 L1);
|
||||
/// Http → [http];Search → [file](含 search_files);Conversation → [];
|
||||
/// Chat → [];Debug → [file, exec, http, data](调试常需跑命令+读文件+查 API+查任务/工作流状态,CR-25 审查🟡-1 加 data 防"调试任务"丢 Data 工具);
|
||||
/// **仅 Debug 含 Exec**(用户明确"运行/测试/构建/调试"才暴露 run_command),
|
||||
/// Code/File/Search 不含 Exec → 收紧 run_command 暴露面;Unknown → [](全量)。
|
||||
pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
let domains: &[ToolDomain] = match intent {
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http],
|
||||
Intent::Code => &[ToolDomain::File, ToolDomain::Http, ToolDomain::DataReadOnly],
|
||||
Intent::Debug => &[ToolDomain::File, ToolDomain::Exec, ToolDomain::Http, ToolDomain::Data],
|
||||
Intent::File => &[ToolDomain::File],
|
||||
// Project/Task/Idea 加 File:用户提"项目/任务"时常是在其内编码/排查
|
||||
@@ -449,19 +473,36 @@ pub fn tool_subset_for(intent: &Intent) -> Vec<&'static str> {
|
||||
out
|
||||
}
|
||||
|
||||
// ---- 模态建议(接口预留) ---------------------------------------------------
|
||||
// ---- 模态建议(意图 → 模型 tier 映射) ----------------------------------------
|
||||
|
||||
/// 按 Intent 建议模型模态档位。
|
||||
/// 按 Intent 建议模型模态档位(意图 → 模型 tier 的语义映射)。
|
||||
///
|
||||
/// **预留接口**:当前恒返 `None`。待模型模态管理 Phase 落地后补充:
|
||||
/// - Chat/Conversation → `Fast`
|
||||
/// - Code/File/Task/Idea/Search → `Standard`
|
||||
/// - Debug/Http(复杂排查/多跳调用)→ `Heavy`
|
||||
/// **映射分组**(对齐 router 同 weight tiebreak 缺语义的根因修复):
|
||||
/// - `Code`/`Debug` → `Heavy`(复杂推理/重构/排查,需重模型)
|
||||
/// - `Http` → `Heavy`(多跳外部调用,链长易错,需重模型把关)
|
||||
/// - `File`/`Search`/`Task`/`Idea`/`Project`/`Conversation` → `Standard`(默认复杂度)
|
||||
/// - `Chat` → `Fast`(简单闲聊,轻量即可)
|
||||
/// - `Unknown` → `None`(未识别,fallback 全量工具时不强加 tier 偏好,
|
||||
/// 上游走默认档位)
|
||||
///
|
||||
/// 返回 `None` 时上游应使用默认档位(待模态管理 Phase 定义)。
|
||||
pub fn suggested_model_tier(_intent: &Intent) -> Option<ModelTier> {
|
||||
// TODO(model-tier-phase): 待模型模态管理落地后填实映射。
|
||||
None
|
||||
/// 返回 `Some(ModelTier)` 时上游可作为同 weight 候选间的 tiebreak 依据
|
||||
/// (重档位优先),或据此路由到不同 provider/model(待模型模态管理 Phase 接入)。
|
||||
pub fn suggested_model_tier(intent: &Intent) -> Option<ModelTier> {
|
||||
match intent {
|
||||
// 重型:复杂推理/重构/排查/多跳调用
|
||||
Intent::Code | Intent::Debug | Intent::Http => Some(ModelTier::Heavy),
|
||||
// 标准:默认复杂度
|
||||
Intent::File
|
||||
| Intent::Search
|
||||
| Intent::Task
|
||||
| Intent::Idea
|
||||
| Intent::Project
|
||||
| Intent::Conversation => Some(ModelTier::Standard),
|
||||
// 轻量:简单闲聊
|
||||
Intent::Chat => Some(ModelTier::Fast),
|
||||
// 未识别:不强加 tier 偏好,fallback 上游默认
|
||||
Intent::Unknown => None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 工具子集过滤(agentic loop 接入用,改进2 A) -----------------------------
|
||||
@@ -813,7 +854,55 @@ mod tests {
|
||||
assert!(s.contains(&"write_file"));
|
||||
assert!(s.contains(&"patch_file"));
|
||||
assert!(s.contains(&"http_request"));
|
||||
assert!(!s.contains(&"list_projects"));
|
||||
// Code 现含 DataReadOnly → list_projects 等只读工具保留(不再断言"不含")
|
||||
assert!(s.contains(&"list_projects"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_code_keeps_data_readonly_tools() {
|
||||
// 根因修复(Code 命中砍 Data domain 致 list_*/get_*_count 不可见):
|
||||
// Code subset 应含 DataReadOnly 全部 6 个只读工具,让"先 list_projects 看下结构"
|
||||
// "list_tasks 记一下"这类编码场景的口语不被 Code 意图砍工具。
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
for read_only in [
|
||||
"list_projects",
|
||||
"list_tasks",
|
||||
"list_ideas",
|
||||
"list_trash",
|
||||
"get_project_count",
|
||||
"get_task_count",
|
||||
] {
|
||||
assert!(
|
||||
s.contains(&read_only),
|
||||
"Code subset 应含只读工具 {}(DataReadOnly domain)",
|
||||
read_only
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subset_code_no_data_write_tools() {
|
||||
// 防回归:Code subset 只加 Data 的"只读子集",不得暴露写工具。
|
||||
// create_task 不应在 Code subset(只读 list_* 在,写 create_* 不在)。
|
||||
// create_project/update_project/delete_project/advance_task/run_workflow 同理。
|
||||
let s = tool_subset_for(&Intent::Code);
|
||||
for write_tool in [
|
||||
"create_task",
|
||||
"update_task",
|
||||
"delete_task",
|
||||
"create_project",
|
||||
"update_project",
|
||||
"delete_project",
|
||||
"advance_task",
|
||||
"run_workflow",
|
||||
] {
|
||||
assert!(
|
||||
!s.contains(&write_tool),
|
||||
"Code subset 不应含写工具 {}(只读子集,防放大暴露面), 实际 subset: {:?}",
|
||||
write_tool,
|
||||
s
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -907,26 +996,39 @@ mod tests {
|
||||
assert_eq!(ToolDomain::Http.tools(), &["http_request"]);
|
||||
}
|
||||
|
||||
// --- suggested_model_tier 预留 ---
|
||||
// --- suggested_model_tier 意图 → 档位映射 ---
|
||||
|
||||
#[test]
|
||||
fn model_tier_always_none_for_now() {
|
||||
// 接口预留:当前所有意图均返 None
|
||||
fn model_tier_mapping_now_returns_actual() {
|
||||
// Code/Debug/Http → Heavy(复杂推理/重构/排查/多跳调用)
|
||||
for i in [Intent::Code, Intent::Debug, Intent::Http] {
|
||||
assert_eq!(
|
||||
suggested_model_tier(&i),
|
||||
Some(ModelTier::Heavy),
|
||||
"intent {:?} 应映射 Heavy",
|
||||
i
|
||||
);
|
||||
}
|
||||
// File/Search/Task/Idea/Project/Conversation → Standard(默认复杂度)
|
||||
for i in [
|
||||
Intent::Code,
|
||||
Intent::Debug,
|
||||
Intent::File,
|
||||
Intent::Project,
|
||||
Intent::Search,
|
||||
Intent::Task,
|
||||
Intent::Idea,
|
||||
Intent::Project,
|
||||
Intent::Conversation,
|
||||
Intent::Search,
|
||||
Intent::Http,
|
||||
Intent::Chat,
|
||||
Intent::Unknown,
|
||||
] {
|
||||
assert_eq!(suggested_model_tier(&i), None, "intent {:?} 应返 None", i);
|
||||
assert_eq!(
|
||||
suggested_model_tier(&i),
|
||||
Some(ModelTier::Standard),
|
||||
"intent {:?} 应映射 Standard",
|
||||
i
|
||||
);
|
||||
}
|
||||
// Chat → Fast(简单闲聊)
|
||||
assert_eq!(suggested_model_tier(&Intent::Chat), Some(ModelTier::Fast));
|
||||
// Unknown → None(未识别,不强加 tier 偏好,fallback 上游默认)
|
||||
assert_eq!(suggested_model_tier(&Intent::Unknown), None);
|
||||
}
|
||||
|
||||
// --- IntentRecognizer Default ---
|
||||
@@ -1145,10 +1247,13 @@ mod tests {
|
||||
];
|
||||
for intent in all_intents {
|
||||
let subset = tool_subset_for(&intent);
|
||||
// 全 registry 工具名(四 domain 并集:Data + File + Exec + Http)
|
||||
// 全 registry 工具名(五 domain 并集:Data + DataReadOnly + File + Exec + Http)
|
||||
// 注:DataReadOnly 工具名是 Data 的子集,chain 它仅为语义显式(并集去重无副作用),
|
||||
// 防 Code subset 里 list_projects 等 DataReadOnly 工具被判"不在 registry"。
|
||||
let registry: std::collections::HashSet<&str> = ToolDomain::Data
|
||||
.tools()
|
||||
.iter()
|
||||
.chain(ToolDomain::DataReadOnly.tools().iter())
|
||||
.chain(ToolDomain::File.tools().iter())
|
||||
.chain(ToolDomain::Exec.tools().iter())
|
||||
.chain(ToolDomain::Http.tools().iter())
|
||||
|
||||
+212
-18
@@ -10,25 +10,36 @@
|
||||
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
|
||||
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
|
||||
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
|
||||
// ModelTier 从 crate::intent re-export(同 crate,无跨 crate 路径问题),供调用点构造
|
||||
// `tier: suggested_model_tier(&intent)` 传入,router 同 weight 时按 tier tiebreak。
|
||||
pub use crate::intent::ModelTier;
|
||||
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
|
||||
|
||||
/// 任务对模型的需求(3 维度)。
|
||||
/// 任务对模型的需求(4 维度)。
|
||||
///
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文,
|
||||
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文/档位,
|
||||
/// 交 ModelRouter::select 在候选池中选最优模型。
|
||||
///
|
||||
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
|
||||
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
|
||||
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
|
||||
/// 保留供未来出现真实判别源时再接回。
|
||||
///
|
||||
/// `tier`(子项 2 根因修复):任务建议的模型档位(由 `intent::suggested_model_tier` 派生,
|
||||
/// 或无意图场景传 None)。原 `max_by_key(weight)` 同 weight 返最后一个,顺序敏感无语义;
|
||||
/// 接 tier 后,同 weight 时优先选 `intelligence` 满足 tier 下限的候选(见 `tier_match`)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskRequirements {
|
||||
/// 任务所需的模态集合(全子集匹配:任务所需模态都必须在模型模态里)
|
||||
pub modalities: Vec<Modality>,
|
||||
/// 是否需要工具调用能力(needs_tool_use=true 时候选必须含 Capability::ToolUse)
|
||||
pub needs_tool_use: bool,
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)
|
||||
/// 预估上下文大小(tokens,模型 context_window 必须 >= 此值)。
|
||||
/// 调用点应传 TokenEstimator 估值而非 0(0 = 当前空操作,窗口过滤维度失效)。
|
||||
pub estimated_context: usize,
|
||||
/// 任务建议的模型档位(意图→ModelTier,无意图场景 None)。
|
||||
/// 同 weight 候选间按 tier tiebreak(满足 tier 下限的候选胜)。
|
||||
pub tier: Option<ModelTier>,
|
||||
}
|
||||
|
||||
/// 模型路由器(单元结构,无状态)。
|
||||
@@ -36,6 +47,33 @@ pub struct TaskRequirements {
|
||||
/// select 为关联函数:给定需求 + 候选池,执行过滤链选最优模型。
|
||||
pub struct ModelRouter;
|
||||
|
||||
/// ModelTier → IntelligenceTier 下限映射(子项 2 tier tiebreak 用)。
|
||||
///
|
||||
/// 任务建议档位(ModelTier:F-Heavy)映射到模型智力下限(IntelligenceTier),
|
||||
/// 同 weight 候选间优先选 `model.intelligence >= 下限` 的(满足任务复杂度需求)。
|
||||
/// - `Fast` → `Lite`(轻量意图,任何模型都满足)
|
||||
/// - `Standard` → `Standard`(日常,需 Standard 及以上)
|
||||
/// - `Heavy` → `Plus`(复杂推理,需 Plus 及以上)
|
||||
fn tier_min_intelligence(tier: ModelTier) -> IntelligenceTier {
|
||||
match tier {
|
||||
ModelTier::Fast => IntelligenceTier::Lite,
|
||||
ModelTier::Standard => IntelligenceTier::Standard,
|
||||
ModelTier::Heavy => IntelligenceTier::Plus,
|
||||
}
|
||||
}
|
||||
|
||||
/// 同 weight tiebreak:候选是否满足任务建议档位的智力下限。
|
||||
///
|
||||
/// 返 `bool`(满足 = true)。调用方在 `max_by` 闭包内 `a_match.cmp(&b_match)` 把 bool 转 Ordering:
|
||||
/// a 满足而 b 不满足 → Greater(a 胜);都满足/都不满足 → Equal(max_by 并列返最后一个)。
|
||||
/// - `req.tier = None`(无意图场景,标题/扫描/压缩):恒 true(所有候选等价,保留旧行为)。
|
||||
/// - `req.tier = Some(t)`:返 `model_intel >= tier_min_intelligence(t)`。
|
||||
fn tier_match(model_intel: IntelligenceTier, req_tier: Option<ModelTier>) -> bool {
|
||||
req_tier
|
||||
.map(|t| model_intel >= tier_min_intelligence(t))
|
||||
.unwrap_or(true) // None → 视作满足(tiebreak 维度不参与,保旧行为)
|
||||
}
|
||||
|
||||
impl ModelRouter {
|
||||
/// 在候选池中选出最优模型(过滤链)。
|
||||
///
|
||||
@@ -44,19 +82,38 @@ impl ModelRouter {
|
||||
/// 2. 模态匹配 — 任务所需模态全在模型模态里
|
||||
/// 3. 能力匹配 — needs_tool_use 时候选必须含 ToolUse
|
||||
/// 4. 窗口够大 — context_window >= estimated_context
|
||||
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
|
||||
/// 5. max_by 选最优:**主键 weight 降序**(权重高者胜),**同 weight 时按 tier tiebreak**
|
||||
/// (满足任务建议档位 `intelligence >= tier_min` 的候选胜)。
|
||||
///
|
||||
/// tier tiebreak(子项 2 根因修复):原 `max_by_key(weight)` 同 weight 返最后一个,
|
||||
/// 顺序敏感无语义(intent suggested_model_tier 恒 None)→ 现接 `req.tier`
|
||||
/// (由 intent→ModelTier 派生),同 weight 时优先选满足档位下限的候选。
|
||||
/// `req.tier = None` 时 tiebreak 维度退化为等价(保留旧行为,标题/扫描路径无回归)。
|
||||
///
|
||||
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
|
||||
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
|
||||
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
|
||||
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序主键 weight 主导,
|
||||
/// tiebreak 由 tier(基于 intelligence,有客观档位映射依据)替代纯 max_by_key 顺序。
|
||||
pub fn select<'a>(req: &TaskRequirements, pool: &'a [ModelConfig]) -> Option<&'a ModelConfig> {
|
||||
pool.iter()
|
||||
.filter(|m| m.enabled) // 1. 只选启用的
|
||||
.filter(|m| req.modalities.iter().all(|r| m.modalities.contains(r))) // 2. 模态匹配
|
||||
.filter(|m| !req.needs_tool_use || m.capabilities.contains(&Capability::ToolUse)) // 3. 能力匹配
|
||||
.filter(|m| m.context_window >= req.estimated_context) // 4. 窗口够大
|
||||
.max_by_key(|m| m.weight) // 5. 纯 weight 主导
|
||||
// 5. 主键 weight 降序,同 weight 时 tier tiebreak(满足档位下限的候选胜)。
|
||||
// max_by 语义:comparator 返 a 相对 b 的 Ordering,Greater = a 胜;
|
||||
// 同 key(全 Equal)时 max_by 返最后一个(对齐原 max_by_key 并列返最后的语义)。
|
||||
.max_by(|a, b| {
|
||||
// 主键:weight,a 大则 a 胜(Greater)。
|
||||
let by_weight = a.weight.cmp(&b.weight);
|
||||
if by_weight != std::cmp::Ordering::Equal {
|
||||
return by_weight;
|
||||
}
|
||||
// tiebreak:tier 满足度。a 满足档位下限而 b 不满足 → a 胜(Greater)。
|
||||
// tier_match 返 bool,bool 比较:true > false(满足 > 不满足)。
|
||||
let a_match = tier_match(a.intelligence, req.tier);
|
||||
let b_match = tier_match(b.intelligence, req.tier);
|
||||
a_match.cmp(&b_match)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,12 +151,13 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。
|
||||
/// 构造一个宽松需求(默认全过过滤,调用方按需覆盖字段)。tier=None 保留旧行为。
|
||||
fn req() -> TaskRequirements {
|
||||
TaskRequirements {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: false,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,7 +316,36 @@ mod tests {
|
||||
assert!(ModelRouter::select(&r, &pool).is_none());
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by_key (纯 weight) ──
|
||||
#[test]
|
||||
fn estimated_context_filters_small_window_model() {
|
||||
// 子项 1 根因修复回归测:estimated_context 非零(调用点传 TokenEstimator 估值,非死代码 0)
|
||||
// → 步骤 4 窗口过滤生效。两候选:小窗口(4K)weight 90(高诱惑)+ 大窗口(128K)weight 50。
|
||||
// 任务预估 8K 上下文 → 小窗口模型被滤,只剩大窗口候选胜(即使 weight 低)。
|
||||
// 若调用点回退传 0(原 bug),两候选窗口都 >= 0,weight 90 的小窗口模型会胜(误选)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
context_window: 4096, // 小窗口,高 weight 诱惑
|
||||
..model("small-window-heavy")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
context_window: 131072, // 大窗口,低 weight
|
||||
..model("large-window-light")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
estimated_context: 8000, // 任务预估 8K,小窗口模型装不下
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"large-window-light",
|
||||
"estimated_context 非零应滤掉小窗口候选,即使其 weight 更高"
|
||||
);
|
||||
}
|
||||
|
||||
// ── 步骤 5:max_by(weight 主键,tier tiebreak) ──
|
||||
|
||||
#[test]
|
||||
fn single_match_returns_it() {
|
||||
@@ -290,8 +377,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn same_weight_picks_first_match() {
|
||||
// 同 weight 70,纯 weight 主导(无 cost_tier tie-break):max_by_key 遇并列 key
|
||||
// 返回最后一个(rust Iterator::max_by_key 语义)。验证同 weight 不再按 cost 取舍。
|
||||
// 同 weight 70,tier=None(req() 默认):tiebreak 维度退等价,max_by 遇并列返最后一个
|
||||
// (rust Iterator::max_by 语义,与原 max_by_key 一致)。验证同 weight + tier=None
|
||||
// 不再按 cost 取舍,行为对齐接入 tier tiebreak 前的语义(标题/扫描路径无回归)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
@@ -312,17 +400,17 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn all_dimensions_match_picks_best() {
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
|
||||
// 3+ 候选各维度参差,验证过滤链全过 + max_by(weight, tier) 选最优。
|
||||
// (智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
|
||||
//
|
||||
// 候选:
|
||||
// a: weight 60 → 通过全部过滤,key=60
|
||||
// b: weight 80 → 通过,key=80 — weight 最高档(与 c 并列)
|
||||
// c: weight 80 → 通过,key=80 — 同 weight 80,max_by_key 并列返回最后
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜
|
||||
// a: weight 60 → 通过全部过滤
|
||||
// b: weight 80 → 通过 — weight 次高档(与 c 并列,但 tier=None 故 tiebreak 退等价)
|
||||
// c: weight 80 → 通过 — 同 weight 80,tier=None 时 max_by 返并列最后一个
|
||||
// d: weight 90 → 通过(intelligence 不参与过滤)— weight 最高,胜
|
||||
// e: enabled=false → 步骤 1 滤掉
|
||||
//
|
||||
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
|
||||
// 预期:d 胜(weight 90 最高,tier tiebreak 不触发因 weight 已决出胜负)
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 60,
|
||||
@@ -358,7 +446,113 @@ mod tests {
|
||||
modalities: vec![Modality::Text],
|
||||
needs_tool_use: true,
|
||||
estimated_context: 0,
|
||||
tier: None,
|
||||
};
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "d");
|
||||
}
|
||||
|
||||
// ── 步骤 5 tiebreak(子项 2):同 weight 时 tier 决胜 ──
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_heavy_prefers_meeting_model() {
|
||||
// 子项 2 根因修复:同 weight 时,任务建议 Heavy(req.tier=Some(Heavy))→ tier_min=Plus,
|
||||
// 满足 intelligence>=Plus 的候选胜过不满足的。
|
||||
// 候选 a:Standard(不满足 Plus),候选 b:Plus(满足),同 weight 50。
|
||||
// 预期:b 胜(满足 Heavy 档位下限)。原 max_by_key 会返最后一个(顺序敏感无语义)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_standard")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_plus")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"b_plus",
|
||||
"同 weight 时 Heavy 档位应优先选 Plus(满足)而非 Standard(不满足)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_none_preserves_max_by_key_semantics() {
|
||||
// tier=None(标题/扫描/压缩无意图场景)→ tiebreak 维度退等价,
|
||||
// max_by 同 key 返最后一个(对齐原 max_by_key 行为,无回归)。
|
||||
// 候选 a/b 同 weight 70,顺序 a 在前 b 在后 → 预期返 b(max_by 并列返最后)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 70,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b")
|
||||
},
|
||||
];
|
||||
// tier=None 时即使 b 的 intelligence 更高也不应胜(tiebreak 不参与),保 max_by_key 语义。
|
||||
assert_eq!(ModelRouter::select(&req(), &pool).unwrap().model_id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_chat_fast_any_model_meets_lite() {
|
||||
// 任务建议 Fast → tier_min=Lite,任何模型 intelligence>=Lite(Lite 是最低档)→ 都满足。
|
||||
// 故 Fast 档位下 tiebreak 退等价(都满足),max_by 同 weight 返最后一个,行为不变。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Lite,
|
||||
..model("a_lite")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Ultra,
|
||||
..model("b_ultra")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Fast),
|
||||
..req()
|
||||
};
|
||||
// 都满足 Lite 下限 → tiebreak 等价 → max_by 返最后一个 = b_ultra
|
||||
assert_eq!(ModelRouter::select(&r, &pool).unwrap().model_id, "b_ultra");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_tiebreak_weight_still_dominates() {
|
||||
// tier 不凌驾 weight:weight 高者永远胜,即使低 weight 候选满足 tier 而高 weight 不满足。
|
||||
// 候选 a:weight 90,Standard(不满足 Heavy/Plus)。
|
||||
// 候选 b:weight 50,Plus(满足 Heavy)。
|
||||
// 预期:a 胜(weight 主键优先,tiebreak 只在 weight 相同时触发)。
|
||||
let pool = vec![
|
||||
ModelConfig {
|
||||
weight: 90,
|
||||
intelligence: IntelligenceTier::Standard,
|
||||
..model("a_heavy_weight")
|
||||
},
|
||||
ModelConfig {
|
||||
weight: 50,
|
||||
intelligence: IntelligenceTier::Plus,
|
||||
..model("b_meets_tier")
|
||||
},
|
||||
];
|
||||
let r = TaskRequirements {
|
||||
tier: Some(ModelTier::Heavy),
|
||||
..req()
|
||||
};
|
||||
assert_eq!(
|
||||
ModelRouter::select(&r, &pool).unwrap().model_id,
|
||||
"a_heavy_weight",
|
||||
"weight 主键应凌驾 tier tiebreak"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user