Compare commits

...
23 Commits
Author SHA1 Message Date
lxy 6b68aa63e8 重构: tool_registry 剩余 21 工具迁声明式(task_graph/git/http/workflow/idea/trash) 2026-08-01 13:30:05 +08:00
lxy c27fd75d20 重构: tool_registry file 工具迁声明式(13 工具→tools/file.rs,tool_registry 减 1091 行) 2026-08-01 13:20:36 +08:00
lxy b68dc363d4 重构: tool_registry task 工具迁声明式(6 工具→tools/task.rs) 2026-08-01 13:10:16 +08:00
lxy 1920bcbd38 重构: tool_registry 项目工具迁声明式(7 工具→tools/project.rs) 2026-08-01 13:05:33 +08:00
lxy c002f0b352 重构: tool_registry 声明式注册基础设施(declare_tool! 宏 + list_projects 试点,零回归) 2026-08-01 12:56:19 +08:00
lxy 438f9692f2 新增: 任务创建关联灵感下拉(F-260619-01 补创建入口) 2026-08-01 12:47:12 +08:00
lxy c34ad062e7 优化: 消息落库增量写(Phase1:全量 DELETE+INSERT → 增量 INSERT + dirty 标记) 2026-08-01 12:42:49 +08:00
lxy 484080ac12 修复: unwrap 吞错 + lock 中毒 panic 高危项(数据吞 warn 跳过 / lock 降级返 Err) 2026-08-01 12:21:52 +08:00
lxy 143b859727 新增: 迁移全量测试(覆盖 V1-V37 + 关键表/列/schema_version) 2026-08-01 12:13:56 +08:00
lxy d7e2845881 修复: 断路器失败判定纯结构化(失败 content 包 JSON envelope,is_failure_content 只读字段不解析内容文本) 2026-08-01 12:00:23 +08:00
lxy 20ec571dbb 修复: checkpoint 参数化 + 压缩 permit 死锁降级 + 密钥归一化 + SSE CRLF/多字节 2026-08-01 11:30:05 +08:00
lxy c4ba920cf5 修复: spawn panic 兜底 + namespace 淘汰提示 + run_command 超时与破坏命令信任门控 2026-08-01 11:10:28 +08:00
lxy 468950616b 修复: namespace 占位符发 LLM 前未展开 + key 含斜杠丢原文 2026-08-01 10:42:09 +08:00
lxy bd9031d35d 重构: 巨函数拆分 + 清理历史标记注释 + custom_prompt/停止按钮/tunnel 改进 2026-07-31 21:36:12 +08:00
lxy 365af554da 修复: max_iterations 不收敛卡死 + stopBtnState 残留红 2026-07-21 00:31:59 +08:00
lxy 0b68c5bed9 修复: AI 回复以好问题等开场 + 达 max_iterations 不收敛卡死
日志分析发现:
- 零条 AiCompleted/AiError: agentic loop 达 max_iterations 后进入暂停态
  (F-260616-03), generating 保持 true 致前端卡在等待+发送队列
- LLM 持续调工具不收敛输出文本, 10 轮耗尽后不自动完成

system prompt 修复:
- 新增「直接回答用户问题,不要以评论开头」严禁用好问题等无信息开场白
- 新增「及时收敛」指令:目标达成后立即输出结果, 停止调无用工具
- concat! 重构 behavior_prompt 避免单行长字符串
2026-07-20 13:08:27 +08:00
lxy 7594a5b7f3 新增: MemoryAdapter trait + ContextManager 默认实现
crates/df-ai/src/memory_adapter.rs:
- MemoryAdapter trait: 15 个核心方法(写入/读取/压缩/生命周期)
- ContextManager 作为默认实现(行为零变化)
- 预留 VectorMemoryAdapter 接入点

lib.rs: pub mod memory_adapter 导出
2026-07-20 09:51:12 +08:00
lxy c8f35a7211 新增: V37 migration + T6 版本化快照(自动 checkpoint)
迁移 V37:
- conversation_checkpoints 表: id/conv_id/snapshot/token_total/label/created_at
- 索引: (conv_id, created_at DESC)

save_conversation_inner:
- 每 20 轮自动创建 checkpoint(snapshot = JSON 全量消息)
- 防撑爆: snapshot < 1MB 才写
- checkpoint ID: ck_{conv_id}_{token_total}(幂等,INSERT OR IGNORE)
- 为 T6 后续的 IPC list/restore 准备数据基础
2026-07-20 09:10:47 +08:00
lxy e222d38e7c 新增: T4 工作流 DAG 注入完整实现
PerConvState:
- workflow_id: Option<String> — 关联的工作流 ID
- workflow_dag_summary: Option<String> — 预计算 DAG 摘要

system_prompt 注入:
- 检测 workflow_dag_summary,非空时注入 [工作流] 块

workflow_context.rs:
- build_dag_summary: 从 DagDef 构建可读 DAG 摘要文本
- extract_active_path: 提取从根到当前节点的活跃路径
- topological_layers: 拓扑分层排序(含环检测)
- 3 个单元测试覆盖线性 DAG / 中途路径 / 空 DAG
2026-07-20 08:58:37 +08:00
lxy 1eb2ca7dc5 新增: 上下文管理演进 Wave3(T5 WorkingContext 层注入)
PerConvState: 新增 working_context 字段
- 初始化 WorkingContext::new() 默认空

system_prompt 注入:
- L1a: 当前步骤(每轮必带,调用 build_injection)
- L1b: 脏标记条件注入(dirty_window=3,有变更才带)
- 拼入「## 工作上下文」段

T3 联动: compress 成功后的 reset_all 占位已在 context_lifecycle.rs 预埋
T4: 工作流注入点保留为独立段(待 workflow_id 字段落地)
L1c (read_context 工具): 待注册(依赖 tool_registry 接线)
2026-07-20 08:11:40 +08:00
lxy 3943d68430 新增: 上下文管理演进 Wave2(T3结构化摘要 + T4工作流DAG占位)
T3: 压缩摘要双格式(向后兼容)
- compress_via_llm 返回类型 Result<String> → Result<CompressedSummary>
- 新增 parse_sections_to_json: 从四段式 Markdown 摘要解析 JSON 卡片
- context_lifecycle: 插入 NL 摘要(向后兼容),预留 WorkingContext 联动
- manual compress IPC (chat.rs): 适配新返回类型

T4: 工作流 DAG 注入(占位)
- workflow_context.rs: 空壳函数,待 PerConvState.workflow_id 落地后接入
- mod.rs: 注入点已就位(日志占位)
- 后续需补: DAG 加载 + 活跃路径裁剪 + system_prompt 注入
2026-07-20 08:01:34 +08:00
lxy faa61a9ba5 新增: 上下文管理演进 Wave1(契约定义 + T1动态阈值 + T2命名空间)
Agent 3: 共享契约类型定义
- CompressedSummary(T3输出格式)
- WorkflowContextBlock(T4工作流DAG上下文)
- WorkingContext + GoalItem/DecisionItem/StepInfo(T5常驻上下文)
- 脏标记版本追踪 + reset_all 压缩联动

T1: 动态压缩阈值
- maybe_auto_compress 加 sys_tokens 参数,触发条件从 budget*0.6 改为 available*0.6
- 调用处传 sys_tokens(loop顶部已缓存)
- 3 行核心改动

T2: 工具命名空间存储
- crates/df-ai/src/namespace_store.rs: 完整实现 + 单元测试
- process_tool_calls: Low/Med/High 路径 namespace 路由
- save_conversation: namespace 引用展开为原文再落 DB
- AiSession 新增 namespace_store 字段
2026-07-20 01:05:20 +08:00
lxy b1d7deece1 新增: 上下文管理演进设计文档(发散思考 + 任务技术设计)
发散思考文档涵盖:
- ContextManager 三维评估(效率/合理性/成本)
- 业界方案对标(Claude Code/OpenAI SDK/Mem0/LangGraph 等)
- 融合设计: 结构化分层上下文引擎(L1常驻/L2历史/L3摘要)
- 9 种方法交叉论证(决策矩阵/ROI/帕累托/Kano/风险矩阵等)
- 7 个发散方向与冲突分析,推荐派系 A 路线

任务技术设计文档涵盖:
- T2 工具命名空间: 大工具结果不进主队列,NamespaceStore 详细设计
- T4 工作流 DAG 注入: 活跃路径裁剪,结构化 DAG 进 system prompt
- T3 L3 结构化摘要: JSON+NL 双格式压缩摘要
- T1 动态压缩阈值: 精确到行的 3 处改动
- 4 种边界情况推演,AI coding 多 agent 并行策略
2026-07-20 00:53:53 +08:00
104 changed files with 8360 additions and 3501 deletions
+9
View File
@@ -10,3 +10,12 @@
[target.x86_64-unknown-linux-musl]
linker = "rust-lld"
rustflags = ["-C", "link-self-contained=y"]
# ── 主机编译加速(Windows MSVC) ────────────────────────────
# rust-lld 比 MSVC link.exe 快 3-5x,且 Rust 自带零安装
[target.x86_64-pc-windows-msvc]
linker = "rust-lld.exe"
# 并行编译单元,留部分核心给 Zed UI 和系统
[build]
jobs = 8
+134 -17
View File
@@ -1,41 +1,71 @@
{
// 项目级 Zed 配置:优化 rust-analyzer 性能
// ── 项目级 Zed 配置: rust-analyzer 性能极限优化 ──────────────
//
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
// workspace: src-tauri + 7 个 df-* crate 的 Rust Monorepo
// flcheck 单次 600+ 行 artifact JSON → Zed 解析+渲染冻结 UI
// 核心策略: 关保存即 check · 限制检查范围 · 裁切 LSP 负载 · 排 IO 争抢
// ─────────────────────────────────────────────────────────────────
"lsp": {
"rust-analyzer": {
"initialization_options": {
// ── cargo 编译参数 ───────────────────────────────────
"cargo": {
"features": [], // 不编 default features 之外的重头
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
"targetDir": null, // 留空用 cargo 默认,避免双写
"features": [],
"allTargets": false, // 只为 host target 解析
"noDefaultFeatures": false,
"targetDir": null,
"buildScripts": {
"enable": true,
"invocationStrategy": "once", // 只跑一次,不反复
"rerun": "on-save"
}
},
"extraArgs": []
},
// ── check / flycheck ────────────────────────────────
"check": {
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
"workspace": false, // 即便手动 check 也只查当前 crate
"command": "check", // 用 cargo check(非 clippy),首次开销低
"onSave": false, // ★ 核心!关保存即 check
"workspace": false, // 手动 check 也只查当前 crate
"command": "check", // 用 check,非 clippy
"invocationStrategy": "once", // 只跑一轮,不反复
"features": [],
"allTargets": false
"allTargets": false,
"ignore": [ // 跳过不常用的目录
"benches",
"examples",
"xtask",
"tests"
]
},
// ── 缓存预热(关掉,省首次启动 CPU 爆发) ──────────────
"cachePriming": {
"enable": false
},
// ── 诊断 ────────────────────────────────────────────
"diagnostics": {
"enable": true, // 保留 rust-analyzer 自身诊断
"enable": true,
"experimental": {
"enable": false
}
},
"disabled": [ // 关掉非关键诊断,降 CPU
"unresolved-proc-macro",
"inactive-code",
"macro-error"
]
},
// ── proc-macro(必须开,否则 tauri 全报红) ────────────
"procMacro": {
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
"enable": true,
"attributes": {
"enable": true
}
},
// ── 符号检索 ────────────────────────────────────────
"workspace": {
"symbol": {
"search": {
@@ -44,21 +74,108 @@
}
}
},
// ── 补全(限流减少 LSP 计算量) ──────────────────────
"completion": {
"autoclose": true,
"autoimport": {
"enable": true
},
"callable": {
"snippets": "fill_arguments"
},
"fullFunction": {
"enable": false
},
"limit": 200, // 补全条目上限
"postfix": {
"enable": false // 关 postfix snippet 减少候选
}
},
// ── 内联提示(大文件里关掉多数减少 UI 重绘) ──────────
"inlayHints": {
"bindingModeHints": {
"enable": false
},
"chainingHints": {
"enable": false
},
"closingBraceHints": {
"enable": false
},
"closureReturnTypeHints": {
"enable": "never"
},
"constructorHints": "never",
"discriminantHints": {
"enable": "never"
},
"expressionAdjustmentHints": {
"mode": "never"
},
"implicitDrops": "never",
"lifetimeElisionHints": {
"enable": "skip_trivial"
},
"maxLength": 100,
"parameterHints": {
"enable": true
},
"reborrowHints": {
"enable": "never"
},
"renderColons": true,
"typeHints": {
"enable": true,
"hideNamedConstructor": false
}
},
// ── 语义高亮(大文件关掉字符串部分减少重绘) ──────────
"semanticHighlights": {
"strings": {
"enable": false
}
},
// ── 高亮关联(关掉减少 LSP 图构建) ──────────────────
"highlightRelated": {
"references": {
"enable": false
}
},
// ── 测试解释器(关掉) ───────────────────────────────
"interpret": {
"tests": false
},
// ── rust-analyzer 文件扫描排除 ─────────────────────
"files": {
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
"excludeDirs": [
".cargo",
"target",
"target-musl",
"node_modules",
"dist",
".zed",
".git"
]
}
}
}
}
},
// ── Zed 文件监视排除(减少 File Watcher IO 争抢) ────────────
// 和全局 ~/.config/zed/settings.json 的 file_scan_exclusions 叠加
"file_scan_exclusions": [
"**/target/**",
"**/target-musl/**",
"**/node_modules/**",
"**/dist/**",
"**/.cargo/**",
"**/.git/**",
"**/build/**"
]
}
+5 -5
View File
@@ -1,4 +1,4 @@
//! 模型能力数据模型 — F-01 阶段1
//! 模型能力数据模型。
//!
//! 单模型的完整描述(4 维度 + 路由控制)。纯数据结构,零 IO / 零 DB 依赖。
//! df-storage 反序列化 DB 行时直接消费本模块类型;df-ai 探测器/路由器阶段 2-4 再用。
@@ -45,9 +45,9 @@ pub enum Capability {
///
/// 序列化为 snake_case。原设计文档 §2.2 列 Free/Low/Medium/High,但 Free 变体形同虚设:
/// 预设表(presets/models.json)0 条 free + 启发式从不赋 Free(只 Low/Medium/High),
/// B-260618-05 删除 Free 死档变体。
/// Free 死档变体已删除。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 cost_tier,
/// 路由已解耦:provider /v1/models API 不返回 cost_tier,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `cost_tier <= max_cost` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -63,7 +63,7 @@ pub enum CostTier {
/// 设计文档 §2.2:Lite/Standard/Plus/Ultra。序列化为 snake_case。
/// 派生 Ord:Lite < Standard < Plus < Ultra。
///
/// 路由已解耦(2026-06-18 B-260618-03):provider /v1/models API 不返回 intelligence,
/// 路由已解耦:provider /v1/models API 不返回 intelligence,
/// 此维度 100% 靠预设表写死 + 模型名启发式猜,数据无客观依据不可信,不再参与硬路由
/// (原 §6.1 `intelligence >= min_intelligence` 过滤已删除)。枚举保留供未来出现真实判别源时再接回。
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord)]
@@ -302,7 +302,7 @@ mod tests {
assert_eq!(serde_json::from_str::<CostTier>(expected).unwrap(), variant);
}
// Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。
// Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。
// Free 变体已删除(预设/启发式从不赋 Free,死档)。
assert!(CostTier::Low < CostTier::Medium);
assert!(CostTier::Medium < CostTier::High);
}
+8 -9
View File
@@ -90,8 +90,8 @@ impl ChatMessage {
/// 是否处于 active 态(status 为 None 或 "active")。其余状态一律 false。
///
/// 正面白名单(F-15 §3.2):仅认 None / "active",新状态
/// (如阶段2 引入的 "archived_segment" / "compressed")自动落入不 active 分支,
/// 正面白名单:仅认 None / "active",新状态
/// (如"archived_segment" / "compressed")自动落入不 active 分支,
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
pub fn is_active(&self) -> bool {
@@ -155,10 +155,9 @@ mod tests {
#[test]
fn is_active_whitelist() {
// F-15 §3.2 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 正面白名单:仅 None / "active" 为 true,其余一律 false。
// 零行为变化:None / "active" / "truncated" 与旧反面排除完全等价;
// "archived_segment" / "compressed"(阶段2 引入,当前代码未赋值)由白名单
// matches! 只认 None/active 自动落入 false,面向未来验证。
// "archived_segment" / "compressed" 由白名单 matches! 只认 None/active 自动落入 false。
// None(构造默认值,向前兼容老 JSON)
let m = ChatMessage::user("hi");
@@ -174,18 +173,18 @@ mod tests {
m.status = Some(MessageStatus::Truncated);
assert!(!m.is_active(), "truncated 应不 active");
// "archived_segment" — 阶段2 待引入,白名单自动隔离
// "archived_segment" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::ArchivedSegment);
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
// "compressed" — 阶段2 待引入,白名单自动隔离
// "compressed" — 白名单自动隔离
let mut m = ChatMessage::user("hi");
m.status = Some(MessageStatus::Compressed);
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
}
// ---------- F-260614-05 Phase 2a ContentPart ----------
// ---------- ContentPart ----------
/// 老 JSON(无 parts 字段)反序列化时 parts 应为 None(向前兼容)
#[test]
@@ -333,7 +332,7 @@ mod tests {
assert_eq!(deserialized.reasoning_content, Some("thinking process".to_string()));
}
// ---------- F-260619-04 消息级溯源:id 字段 ----------
// ---------- 消息级溯源:id 字段 ----------
/// 所有便捷构造函数默认生成非 None 的 id(ULID 风格)
#[test]
+3 -3
View File
@@ -43,7 +43,7 @@ pub struct CompletionRequest {
/// 多模态消息内容片。Text 片为字符串;Image 片可走 url 或 base64(二选一,base64 非空时 url 忽略)。
///
/// F-260614-05 Phase 2a 后端:ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// ContentPart 作为 `ChatMessage.parts` 的元素类型。
/// 设计上 `content: String`(纯文本主载荷)保持不变,多模态片挂在 `parts`:
/// 这样未接入多模态的调用方(audit/title/commands/knowledge_inject 等读 content 当字符串)
/// 零回归,避免一次性改全仓。provider 转换层在 `has_image()` 为真时把 parts 透传给
@@ -79,13 +79,13 @@ pub enum ContentPart {
pub struct ChatMessage {
/// 消息全局唯一 ID。用于消息级溯源(source_ref / audit message_id / idea source)。
/// 构造时由 `new_message_id()` 生成;老 JSON 反序列化为 None(向前兼容)。
/// 消息拆分存储(F-260619-03)后,此 ID 即 `ai_messages.id` 列主键。
/// 消息拆分存储后,此 ID 即 `ai_messages.id` 列主键。
/// 临时/派生消息(如 title 摘要)可显式赋 None(不溯源不落库)。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<String>,
pub role: MessageRole,
pub content: String,
/// 多模态内容片(F-260614-05 Phase 2a)。
/// 多模态内容片。
///
/// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。
/// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts
+154
View File
@@ -0,0 +1,154 @@
//! 声明式工具注册基础设施(tool_registry 拆分第一步)
//!
//! 背景:`tool_registry.rs` 4224 行,每加一个工具手写一段
//! `registry.register(name, desc, object_schema(...), RiskLevel::X, { ... Box::new ... })`,
//! 5 段重复样板 + 闭包捕获 + 缩进极易出错。本模块提供声明式宏 `declare_tool!`,
//! 把工具定义收敛为「名字 / 描述 / schema / 风险 / handler 块」五要素一行式声明。
//!
//! 设计取舍(三选一权衡):
//! - A. proc-macro `#[ai_tool]` 属性宏:需新建 proc-macro crate + syn/quote 重依赖,违反
//! 「不引新依赖」。否决。
//! - B. `inventory` crate 自动收集:新外部依赖 + 拉入 linkme/ctor 运行期注册语义,
//! 与现有 `register_*(&mut registry)` 命令式收集并存需双重 source-of-truth。否决。
//! - C. **`macro_rules!` 声明式宏(本方案)**:零新依赖(纯 std `macro_rules!`),
//! 展开为等价的 `registry.register(...)` 调用——与现有 48 个 `register` 调用**完全并存**,
//! 旧工具零改动,新工具可选声明式。handler 块就地书写,`$db`/`$registry` 等捕获变量
//! 原样透传,语义 1:1 等价(schema/risk/handler 同源不变)。
//!
//! 试点:`list_projects` 已迁至声明式(见 commands/ai/tools/list_projects.rs),验证编译过 +
//! 行为等价 + 48 工具基线不破。其余 47 个工具后续批次渐进迁移,不在此步。
//!
//! 宏展开示例(输入):
//! ```ignore
//! declare_tool!(registry, db: Arc<Database>, "list_projects",
//! "列出所有项目...", RiskLevel::Low,
//! schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
//! args => {
//! let repo = ProjectRepo::new(&db);
//! let items = repo.list_active().await?;
//! // ...
//! Ok(json!({ "items": items, ... }))
//! });
//! ```
//! 展开后等价于现有手写的:
//! ```ignore
//! registry.register(
//! "list_projects", "...",
//! object_schema(vec![...]), RiskLevel::Low,
//! { let db = db.clone(); Box::new(move |args: serde_json::Value| {
//! let db = db.clone();
//! Box::pin(async move { /* handler body */ })
//! })},
//! );
//! ```
/// 声明式注册一个 AI 工具到 `$registry`。
///
/// 与手写 `registry.register(name, desc, schema, risk, handler)` 语义 1:1 等价,
/// 仅消除闭包包装样板(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone(); Box::pin(async move { ... }) }) }`)。
///
/// 形参:
/// - `$registry`: `&mut AiToolRegistry` 注册表句柄。
/// - `$capture: $cap_ty`: handler 需捕获的外部变量(如 `db: Arc<Database>`)。宏自动 clone 进闭包,
/// handler body 内以 `$capture` 名访问。无捕获工具用 `_` 占位(并保证 body 不引用它)。
/// - `$name`: 工具名 `&str`。
/// - `$desc`: 工具描述 `&str`(发给 LLM)。
/// - `$risk`: `RiskLevel`(如 `RiskLevel::Low`)。
/// - `$schema`: 参数 JSON Schema(`serde_json::Value`),常用 `object_schema(...)`。
/// - `$handler`: 一个 **block 表达式**(花括号体),返回 `anyhow::Result<serde_json::Value>`。
/// 体内在 `$args`(serde_json::Value)与 `$capture`(克隆后的捕获变量)上工作。
/// 宏负责把它包进 `async move { ... }` 并 `Box::pin`——故调用方只写「同步语义的体」,
/// 不写 `async move`/`Box::pin`/`Box::new` 三层样板。
///
/// 注意:此宏不替代 `register`,而是包装它——`register` 仍是 `AiToolRegistry` 的唯一注册入口,
/// 宏仅是语法糖。现有 48 个手写 `register` 调用不动,新工具改用 `declare_tool!`。
#[macro_export]
macro_rules! declare_tool {
(
$registry:expr,
$capture:ident : $cap_ty:ty,
$name:expr,
$desc:expr,
$risk:expr,
schema: $schema:expr,
$args:ident => $handler:block
) => {{
let __cap: $cap_ty = $capture.clone();
$registry.register(
$name, $desc, $schema, $risk,
{
let $capture = __cap.clone();
::std::boxed::Box::new(move |$args: ::serde_json::Value| {
let $capture = $capture.clone();
::std::boxed::Box::pin(async move {
let $capture: $cap_ty = $capture;
$handler
})
as ::std::pin::Pin<::std::boxed::Box<
dyn ::std::future::Future<Output = ::anyhow::Result<::serde_json::Value>>
+ ::std::marker::Send,
>>
})
},
);
}};
}
#[cfg(test)]
mod tests {
use crate::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
/// 声明式宏注册的工具与手写 register 行为等价(name/schema/risk/执行结果一致)。
/// 这是「现有工具零回归 + 语义等价」的最小验证(试点 list_projects 迁移的微观镜像)。
#[tokio::test]
async fn declare_tool_equivalent_to_register() {
let mut reg = AiToolRegistry::new();
// 声明式注册(新基础设施)
let counter: std::sync::Arc<std::sync::atomic::AtomicUsize> =
std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
declare_tool!(
reg,
counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
"echo_decl",
"声明式 echo 工具",
RiskLevel::Low,
schema: object_schema(vec![("msg", "string", true)]),
args => {
counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
let msg = args["msg"].as_str().unwrap_or("");
Ok(serde_json::json!({ "echo": msg }))
}
);
// 等价断言:定义层(name 存在 / risk 正确 / schema 透传)
assert_eq!(reg.len(), 1, "声明式宏应注册 1 个工具");
let tool = reg.get("echo_decl").expect("echo_decl 应已注册");
assert_eq!(tool.risk_level, RiskLevel::Low);
assert_eq!(tool.definition.function.name, "echo_decl");
assert_eq!(tool.definition.function.parameters["properties"]["msg"]["type"], "string");
// 执行等价:handler 收到 args、捕获变量可访问、返回 handler body 的 Value
let out = reg.execute("echo_decl", serde_json::json!({ "msg": "hi" })).await.unwrap();
assert_eq!(out["echo"], "hi");
assert_eq!(counter.load(std::sync::atomic::Ordering::SeqCst), 1, "捕获变量应在执行时递增");
}
/// 无捕获工具(纯计算/常量返回)用占位 capture,handler 不引用它。
#[tokio::test]
async fn declare_tool_no_capture() {
let mut reg = AiToolRegistry::new();
let dummy: std::sync::Arc<()> = std::sync::Arc::new(()); // 无真实捕获,占位
declare_tool!(
reg,
dummy: std::sync::Arc<()>,
"const_tool",
"无捕获常量工具",
RiskLevel::Medium,
schema: object_schema(vec![]),
_args => { Ok(serde_json::json!({ "ok": true })) }
);
let out = reg.execute("const_tool", serde_json::json!({})).await.unwrap();
assert_eq!(out["ok"], true);
}
}
+95 -76
View File
@@ -137,11 +137,11 @@ impl AnthropicCompatProvider {
}
MessageRole::User => {
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// F-260614-05 Phase 2a: 多模态 user 消息 → content blocks 数组(text/image)。
// 多模态 user 消息 → content blocks 数组(text/image)。
// 含图时把 content + parts 拍平成 blocks:Text 片 → {type:text},
// Image 片 → {type:image, source:{type:base64, media_type, data}}。
// Anthropic 协议要求 image 必须内嵌 base64(不接受 URL 直传)。
// 现状:前端(Phase2b)只产 base64 模式图片片,url 模式当前不可达。
// 现状:前端只产 base64 模式图片片,url 模式当前不可达。
// 未来若加 url 图片输入,必须在 commands 层补 url→base64 预拉
//(provider 不发额外 HTTP),否则下方兜底会发空 data 致 Anthropic 400。
// 纯文本消息(无图)保持原字符串简写,与现有端点零回归。
@@ -149,36 +149,7 @@ impl AnthropicCompatProvider {
let blocks: Vec<serde_json::Value> = m
.flattened_parts()
.into_iter()
.map(|p| match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
// p 已被 match 取得所有权,直接 move media_type/base64 避免大 base64 clone。
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端 Phase2b 只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
})
.map(Self::content_part_to_block)
.collect();
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
} else {
@@ -193,7 +164,7 @@ impl AnthropicCompatProvider {
}
if let Some(calls) = &m.tool_calls {
for tc in calls {
// B-260618-25: arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// arguments 非法 JSON(流式中断残留 / ToolCall::new 默认空串)
// → 空 object 兜底。Anthropic/GLM 要求 tool_use.input 必为 object,
// null 直触发 1214「messages 参数非法」。
let input: serde_json::Value = serde_json::from_str(&tc.function.arguments)
@@ -215,13 +186,13 @@ impl AnthropicCompatProvider {
}
Self::flush_tool_results(&mut messages, &mut pending_tool_results);
// B-260619-03: 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 合并相邻 user 块。Anthropic 协议要求 user/assistant 严格交替,连续 user
// 触发 GLM 1214。场景:drainQueue 续发(前一轮以 tool_result 结尾 + 新 user)→ flush 把
// tool_result 转成 user 后紧跟 push 新 user → 连续两 user。合并成一条 user 含
// [tool_result..., text] blocks(Anthropic 允许一条 user 多 blocks),打破恶性循环。
Self::merge_consecutive_users(&mut messages);
// B-260626-01: 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 保证首条为 user(Anthropic 协议硬性要求 messages[0].role == "user")。
// 上游绕过 ContextManager::sanitize_messages 的调用方(标题生成 / 知识注入 / 工作流 AI
// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、
// 续发或历史片段截取时,真正的首条 user 已被裁剪/压缩掉,直接发触发 precheck
@@ -237,7 +208,7 @@ impl AnthropicCompatProvider {
.map(|d| AnthropicToolDef {
name: d.function.name,
description: Some(d.function.description).filter(|s| !s.is_empty()),
// B-260618-25: input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// input_schema 非 object(未来误用)→ 兜底 {"type":"object"},
// 防 Anthropic 拒非法 tool schema(当前全走 object_schema 恒 object,纯防御)。
input_schema: if d.function.parameters.is_object() {
d.function.parameters
@@ -260,6 +231,43 @@ impl AnthropicCompatProvider {
}
}
/// 单个 ContentPart → Anthropic content block(text/image)。
/// - Text 片 → {type:text, text}
/// - Image 片 → {type:image, source:{type:base64, media_type, data}}
/// base64 内嵌;url 模式当前不可达,兜底发空 data + warn(保留原行为)。
/// match 取得 p 所有权后直接 move media_type/base64,避免大 base64 clone。
fn content_part_to_block(p: crate::provider::ContentPart) -> serde_json::Value {
match p {
crate::provider::ContentPart::Text { text } => serde_json::json!({
"type": "text",
"text": text,
}),
crate::provider::ContentPart::Image { url, base64, media_type, alt: _ } => {
let mt = media_type.unwrap_or_else(|| "image/png".into());
let data = base64.unwrap_or_else(|| {
// 完整性兜底:当前 url 模式不可达(前端只产 base64 图片片)。
// 若未来接入 url 图片输入而 commands 层未补 url→base64 预拉,
// 此处会发空 data 致 Anthropic 400,warn 留痕但不阻塞(避免静默吞数据)。
if url.is_some() {
warn!(
url = ?url,
"Anthropic user 消息含 Image(url) 但 base64 缺失,将发空 data(commands 层未补 url→base64 预拉)"
);
}
String::new()
});
serde_json::json!({
"type": "image",
"source": {
"type": "base64",
"media_type": mt,
"data": data,
}
})
}
}
}
/// 将累积的 tool_result 块作为一条 user 消息 flush 进消息列表
fn flush_tool_results(
messages: &mut Vec<serde_json::Value>,
@@ -272,7 +280,7 @@ impl AnthropicCompatProvider {
messages.push(serde_json::json!({ "role": "user", "content": blocks }));
}
/// B-260619-03: 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// 合并相邻 user 消息为一条(content 拼成 blocks 数组)。
/// Anthropic 协议要求 user/assistant 严格交替,连续 user 触发 1214。
/// 触发场景:flush_tool_results 把 tool_result 转 user 后紧跟新 user(drainQueue 续发,
/// 前一轮以 tool_result 结尾)。合并成一条 user 含 [tool_result..., text] blocks,合法。
@@ -305,7 +313,7 @@ impl AnthropicCompatProvider {
}
}
/// B-260626-01: 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
/// 保证 messages 首条为 user(Anthropic 协议硬性要求 messages[0].role=="user")。
///
/// 上游绕过 `ContextManager::sanitize_messages` 的调用方(标题生成 / 知识注入 / 工作流 AI
/// 节点等直接构造 CompletionRequest 的路径)可能传入首条 assistant 的序列——会话恢复、续发
@@ -342,7 +350,7 @@ impl AnthropicCompatProvider {
}
/// 生成 messages 诊断摘要(每条 role + content 形态 + tool 标记),不含敏感数据。
/// B-260618-27: 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
/// 1214 类错误时随 bail 文案直达前端 raw,定位哪条/字段非法。
fn summarize_messages(messages: &[serde_json::Value]) -> String {
let lines: Vec<String> = messages
.iter()
@@ -385,7 +393,7 @@ impl AnthropicCompatProvider {
format!("{} msgs: {}", lines.len(), lines.join(" | "))
}
/// B-260618-27: 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 协议预检——扫 messages 发现确定非法形态,命中返回原因(仅诊断不修复)。
/// 覆盖:首条非 user / 连续同 role / tool_use input 非 object / 空 content / orphan tool_result
/// (tool_use_id 无前置 tool_use,常见于裁剪/过滤后 assistant 被删但 tool_result 留)。
fn precheck_messages(messages: &[serde_json::Value]) -> Result<(), String> {
@@ -415,25 +423,7 @@ impl AnthropicCompatProvider {
return Err(format!("#{} user content 空数组", i));
}
for b in blocks {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", i));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
i, tid
));
}
}
_ => {}
}
Self::check_block(b, i, &mut tool_use_ids)?;
}
}
_ => {}
@@ -442,6 +432,36 @@ impl AnthropicCompatProvider {
Ok(())
}
/// 校验单个 content block(precheck_messages 内部用)。
/// - tool_use: 收集 id,校验 input 为 object
/// - tool_result: 校验 tool_use_id 有前置 tool_use(非 orphan)
fn check_block<'a>(
b: &'a serde_json::Value,
idx: usize,
tool_use_ids: &mut Vec<&'a str>,
) -> Result<(), String> {
match b.get("type").and_then(|t| t.as_str()).unwrap_or("") {
"tool_use" => {
let id = b.get("id").and_then(|t| t.as_str()).unwrap_or("");
tool_use_ids.push(id);
if !b.get("input").map(|v| v.is_object()).unwrap_or(false) {
return Err(format!("#{} tool_use input 非 object", idx));
}
}
"tool_result" => {
let tid = b.get("tool_use_id").and_then(|t| t.as_str()).unwrap_or("");
if !tid.is_empty() && !tool_use_ids.contains(&tid) {
return Err(format!(
"#{} orphan tool_result(tid={} 无前置 tool_use)",
idx, tid
));
}
}
_ => {}
}
Ok(())
}
/// 统一鉴权头:x-api-key + anthropic-version
fn auth_headers(&self, rb: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
rb.header("x-api-key", &self.api_key)
@@ -457,7 +477,7 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = false;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
@@ -466,9 +486,8 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 同时补 FR-R4 遗漏: Anthropic 同步路径此前无单请求 timeout(建连后挂起会无限 hang),
// 此处加 60s timeout,与 OpenAI 路径对齐。
// 指数退避重试: 包裹 send + 状态码判定。
// 同时补单请求 timeout(Anthropic 同步路径无 timeout 会 hang),此处加 60s,与 OpenAI 路径对齐。
let label = format!("Anthropic[{}]", body.model);
retry_with_backoff(&label, move |_| {
let client = self.client.clone();
@@ -488,7 +507,7 @@ impl LlmProvider for AnthropicCompatProvider {
let resp = match rb.send().await {
Ok(r) => r,
Err(e) => {
// B-260618-26: 记 reqwest 错误源因链(is_*/source)。原仅 Display
// 记 reqwest 错误源因链(is_*/source)。原仅 Display
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
tracing::error!(
is_timeout = e.is_timeout(),
@@ -580,7 +599,7 @@ impl LlmProvider for AnthropicCompatProvider {
req.stream = true;
let body = self.convert_request(req);
// B-260618-27: 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
// 协议预检——命中非法 bail 含 messages 摘要,把 GLM 模糊 1214 转明确诊断
if let Err(reason) = Self::precheck_messages(&body.messages) {
let summary = Self::summarize_messages(&body.messages);
warn!(%reason, %summary, "Anthropic messages 协议预检失败");
@@ -589,9 +608,9 @@ impl LlmProvider for AnthropicCompatProvider {
debug!(model = %body.model, "Anthropic 流式调用");
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
// 流式长生成任务会被误砍。改用 tokio::time::timeout
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
let send_future = self
@@ -638,9 +657,9 @@ impl LlmProvider for AnthropicCompatProvider {
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
}
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
// 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
let mut usage_accum: Option<TokenUsage> = None;
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
// MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
let messages_summary = Self::summarize_messages(&body.messages);
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
@@ -870,7 +889,7 @@ mod tests {
assert!(acc.is_none());
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
// ---------- 多模态 convert_request ----------
/// 含图 user 消息 → content blocks(text + image source.base64)
#[test]
@@ -931,7 +950,7 @@ mod tests {
assert_eq!(user_msg.get("content").and_then(|c| c.as_str()), Some("hello"));
}
// ---------- B-260626-01: ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
// ---------- ensure_leading_user(首条 assistant → 补 user 占位,保留上下文)----------
/// 辅助:构造 assistant(tool_use) 消息
fn msg_assistant_with_tool_use(text: &str, tool_id: &str, tool_name: &str) -> ChatMessage {
@@ -941,7 +960,7 @@ mod tests {
)
}
/// B-260626-01: 精确复现线上 bug——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 精确复现线上场景——多轮 [asst(tool_use), tool_result] 链,首条 assistant。
/// 补一条 user 占位后:首条 user、tool_use/tool_result 配对完整保留、precheck 通过。
/// (原"砍"策略会把每对三元组砍掉,多轮砍到空,丢失全部工具调用历史——"补"策略零丢失。)
#[test]
@@ -981,7 +1000,7 @@ mod tests {
);
}
/// B-260626-01: 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
/// 首条 assistant 无 tool_use → 补占位,首条 user,原上下文保留。
#[test]
fn anthropic_ensure_leading_user_plain_assistant() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
@@ -1007,7 +1026,7 @@ mod tests {
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归验证。
/// 正常序列(user 开头)不补占位——零回归验证。
#[test]
fn anthropic_ensure_leading_user_normal_sequence_unchanged() {
let provider = AnthropicCompatProvider::new("https://api.anthropic.com", "k", "claude-3-5-sonnet");
@@ -1033,7 +1052,7 @@ mod tests {
assert!(AnthropicCompatProvider::precheck_messages(&body.messages).is_ok());
}
/// B-260626-01: 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 线上 3 轮工具调用场景(6 条 [asst(tu),tool_result]×3,首条 assistant)。
/// 补一个 user 占位后全部保留,验证多轮链不丢数据、precheck 通过(原"砍"策略此场景砍到空)。
#[test]
fn anthropic_ensure_leading_user_three_round_chain() {
@@ -1070,7 +1089,7 @@ mod tests {
);
}
/// B-260626-01: 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 空 messages(异常会话经 sanitize 清空)→ convert 补 1 条 user 占位,
/// 避免发空 messages 触发 precheck "messages 为空"(降级让会话能继续)。
#[test]
fn anthropic_ensure_leading_user_empty_messages_gets_placeholder() {
+223 -3
View File
@@ -20,7 +20,7 @@ fn cfg(max_tokens: u32) -> ContextConfig {
#[test]
fn estimate_message_counts_parts_tokens() {
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 多模态回归:含图消息的大段 base64 必须计入 token 预算,
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
let est = TokenEstimator::default();
@@ -263,7 +263,7 @@ fn system_over_budget_trims_to_protect_zone() {
);
}
// ── F-15 阶段1 辅助方法单测 ──
// ── 辅助方法单测 ──
#[test]
fn compress_old_messages_marks_compressed_and_returns_refs() {
@@ -430,7 +430,7 @@ fn build_eviction_units_keeps_triplet_atomic_public() {
);
}
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
// ── 消息级溯源:last_assistant/last_user message_id ──
#[test]
fn last_assistant_message_id_returns_latest() {
@@ -717,3 +717,223 @@ fn topic_marker_take_is_idempotent_and_clears() {
// 第二次 take 必 None
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
}
// ============================================================
// msg-split-phase1:增量写路径基线(persisted_msg_count)+ 全量重写标志(needs_full_rewrite)
// ============================================================
//
// 这些测试验证 save_conversation 增量优化的核心不变量:
// - 纯 push(append)不置 needs_full_rewrite,persisted_msg_count 由 save 推进(此处只测标志语义)
// - 任何修改既有消息 / 缩短 / 中段插入 / 清空的入口都置 needs_full_rewrite=true
// (漏置位 = 静默 DB 不同步,是本优化最危险的回归点)
// - restore_from_messages(DB 加载)后基线对齐到 len + 清 needs_full_rewrite
//
// persisted_msg_count 的推进/读取由 save_conversation(src-tauri) 调 advance_persisted_count 完成,
// 此处仅测 ContextManager 侧的字段初值 + advance 语义(max 防回退 + 清标志)。
#[test]
fn phase1_new_manager_zero_baseline_no_rewrite_needed() {
// 新建 ContextManager:基线 0,无需全量重写(append-only 起点)
let mgr = ContextManager::new(cfg(100_000));
assert_eq!(mgr.persisted_msg_count(), 0);
assert!(!mgr.needs_full_rewrite(), "新 manager 不应需全量重写");
}
#[test]
fn phase1_push_only_keeps_clean_no_dirty_flag() {
// 纯 append(push 多条)不置 needs_full_rewrite —— 这是常态(agentic loop 每轮追加),
// 必须走增量路径只插新消息,不能误触发全量重写。
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问题1"));
assert!(!mgr.needs_full_rewrite(), "push 不应置 dirty");
mgr.push(ChatMessage::assistant("回答1"));
mgr.push(ChatMessage::user("问题2"));
assert!(!mgr.needs_full_rewrite(), "连续 push 不应置 dirty");
assert_eq!(mgr.len(), 3);
}
#[test]
fn phase1_advance_persisted_count_sets_baseline_and_clears_dirty() {
// advance_persisted_count(new_count):推进基线 + 清 needs_full_rewrite(DB 已与内存一致)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("x"));
mgr.push(ChatMessage::user("y"));
// 模拟 save 成功后推进
mgr.advance_persisted_count(2);
assert_eq!(mgr.persisted_msg_count(), 2);
assert!(!mgr.needs_full_rewrite());
}
#[test]
fn phase1_advance_persisted_count_max_no_regression() {
// 防回退:advance 传更小值不回退基线(防误传 / 并发致基线丢失 → 下次增量漏写)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.advance_persisted_count(5);
mgr.advance_persisted_count(3); // 误传更小
assert_eq!(mgr.persisted_msg_count(), 5, "基线不可回退");
}
#[test]
fn phase1_compress_old_messages_sets_dirty() {
// compress 改既有消息 status(active→compressed) → 必须全量重写收敛
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧1"));
mgr.push(ChatMessage::user("旧2"));
mgr.push(ChatMessage::user("保护区"));
assert!(!mgr.needs_full_rewrite());
let compressed = mgr.compress_old_messages(2);
assert_eq!(compressed.len(), 2, "应压缩 2 条");
assert!(mgr.needs_full_rewrite(), "compress 后必须全量重写");
}
#[test]
fn phase1_compress_no_op_does_not_set_dirty() {
// 边界:compress_end 范围内无可压缩(全已 compressed)→ 状态未变,不应置 dirty
// (避免无意义全量重写,compress 重试场景常见)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
let _ = mgr.compress_old_messages(1);
assert!(mgr.needs_full_rewrite(), "首次压缩应置 dirty");
mgr.advance_persisted_count(1); // 模拟 save 后清 dirty
// 二次压缩同范围:全已 compressed,无新标 → 不应再置 dirty
let compressed = mgr.compress_old_messages(1);
assert!(compressed.is_empty());
// 注:此处 dirty 在二次 compress 时未变(未置位),但 advance 已清,故仍 false
assert!(!mgr.needs_full_rewrite(), "无新压缩不应置 dirty");
}
#[test]
fn phase1_replace_tool_result_content_sets_dirty() {
// 审批 replace_tool_result 改既有 tool 消息 content → 必须全量重写
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::assistant_with_tools(
"调用工具",
vec![ToolCall {
id: "call_1".into(),
call_type: "function".into(),
function: crate::provider::ToolCallFunction {
name: "read_file".into(),
arguments: "{}".into(),
},
}],
));
mgr.push(ChatMessage::tool_result("call_1", "占位结果"));
assert!(!mgr.needs_full_rewrite());
let replaced = mgr.replace_tool_result_content("call_1", "真实结果");
assert!(replaced, "应找到并替换");
assert!(mgr.needs_full_rewrite(), "replace content 后必须全量重写");
}
#[test]
fn phase1_pop_last_assistant_round_sets_dirty() {
// 重新生成 pop 末尾 assistant 轮 → len 缩短 → 必须全量重写(删 DB 尾行)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("问"));
mgr.push(ChatMessage::assistant("答"));
assert!(!mgr.needs_full_rewrite());
let popped = mgr.pop_last_assistant_round();
assert!(popped);
assert!(mgr.needs_full_rewrite(), "pop 后必须全量重写");
assert_eq!(mgr.len(), 1, "应只剩 user");
}
#[test]
fn phase1_pop_no_op_does_not_set_dirty() {
// 边界:无可弹 assistant(末尾是 user)→ 不变,不应置 dirty
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("只有 user"));
let popped = mgr.pop_last_assistant_round();
assert!(!popped);
assert!(!mgr.needs_full_rewrite(), "pop no-op 不应置 dirty");
}
#[test]
fn phase1_truncate_after_user_message_sets_dirty() {
// 编辑重生成 truncate 后续消息 status → 必须全量重写
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("原始问题"));
mgr.push(ChatMessage::assistant("答1"));
mgr.push(ChatMessage::assistant("答2"));
assert!(!mgr.needs_full_rewrite());
let count = mgr.truncate_after_user_message("原始问题").expect("应找到");
assert_eq!(count, 2);
assert!(mgr.needs_full_rewrite(), "truncate 后必须全量重写");
}
#[test]
fn phase1_replace_last_active_user_content_sets_dirty() {
// 编辑 user 消息 content → 必须全量重写
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧问题"));
assert!(!mgr.needs_full_rewrite());
mgr.replace_last_active_user_content("新问题").expect("应替换");
assert!(mgr.needs_full_rewrite(), "replace user content 后必须全量重写");
}
#[test]
fn phase1_insert_at_sets_dirty() {
// 中段插入(压缩摘要/话题标记)改变 seq → 必须全量重写
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.push(ChatMessage::user("b"));
assert!(!mgr.needs_full_rewrite());
mgr.insert_at(0, ChatMessage::system("[摘要]"));
assert!(mgr.needs_full_rewrite(), "insert_at 后必须全量重写( seq 错位)");
assert_eq!(mgr.len(), 3);
}
#[test]
fn phase1_clear_resets_baseline_and_sets_dirty() {
// clear:基线重置 0 + 置 dirty(下次 save 全量重写空列表清 DB)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.advance_persisted_count(1);
assert_eq!(mgr.persisted_msg_count(), 1);
mgr.clear();
assert_eq!(mgr.persisted_msg_count(), 0, "clear 应重置基线到 0");
assert!(mgr.needs_full_rewrite(), "clear 应置 dirty");
assert_eq!(mgr.len(), 0);
}
#[test]
fn phase1_restore_from_messages_aligns_baseline_clears_dirty() {
// DB 加载(restore_from_messages)后:基线对齐到 len + 清 dirty
// (DB 已是该真相源,append 基线应对齐当前长度,不需全量重写)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("旧内存")); // 制造一个 dirty + 非零状态
mgr.mark_needs_full_rewrite();
// 模拟从 DB 加载 3 条消息
let loaded = vec![
ChatMessage::user("db1"),
ChatMessage::assistant("db2"),
ChatMessage::user("db3"),
];
mgr.restore_from_messages(loaded);
assert_eq!(mgr.persisted_msg_count(), 3, "基线应对齐到 DB 加载的消息数");
assert!(!mgr.needs_full_rewrite(), "DB 加载后不应需全量重写");
assert_eq!(mgr.len(), 3);
}
#[test]
fn phase1_mark_needs_full_rewrite_idempotent() {
// 幂等:重复 mark 不报警不改变语义(供 messages_mut 调用方多次调用兜底)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.mark_needs_full_rewrite();
mgr.mark_needs_full_rewrite();
mgr.mark_needs_full_rewrite();
assert!(mgr.needs_full_rewrite());
}
#[test]
fn phase1_append_after_dirty_stays_dirty_until_save() {
// dirty 后再 push(append):dirty 保持(下次 save 仍走全量重写收敛,不会因 push 重置)
// 验证 push 不清 dirty(否则 compress 后追加新消息会漏收敛旧行)
let mut mgr = ContextManager::new(cfg(100_000));
mgr.push(ChatMessage::user("a"));
mgr.mark_needs_full_rewrite(); // 模拟 compress
mgr.push(ChatMessage::user("b")); // 追加
assert!(mgr.needs_full_rewrite(), "dirty 后 push 不应清 dirty");
// 模拟 save(全量重写)成功后推进 + 清
mgr.advance_persisted_count(2);
assert!(!mgr.needs_full_rewrite());
}
+100 -9
View File
@@ -54,6 +54,30 @@ pub struct ContextManager {
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
pending_topic_marker: Option<String>,
/// msg-split-phase1:已落库(ai_messages 表)的消息条数。
///
/// 增量写路径用:save_conversation 据此判定 `[persisted_msg_count..len)` 是"自上次 save
/// 后新增的消息"(纯 append),走 `insert_batch`(INSERT OR IGNORE)只插新行,跳过昂贵的
/// `replace_conversation`(DELETE 整对话 + 全量重插)。
///
/// 仅 append-only 路径才有效——一旦 [`needs_full_rewrite`] 被置位(DB 与内存可能脱钩:
/// compress 改 status / replace_tool_result_content 改 content / pop_last_assistant_round
/// 缩短 / insert_at 中段插入改 seq / clear 清空),save 必须走全量重写收敛,本字段随之重置。
///
/// restore_from_messages(DB 加载)后设为 len(DB 已是该真相源,append 基线对齐到当前长度)。
/// 纯内存态,不落库(下次进程重启从 DB reload 时由 restore_from_messages 重置)。
persisted_msg_count: usize,
/// msg-split-phase1:全量重写需求标志。
///
/// `true` = 自上次 save 后发生过修改既有消息 / 缩短 / 中段插入 / 清空,内存与 DB 可能脱钩,
/// 下次 save 必须走 `replace_conversation` 全量重写收敛(DELETE + INSERT 全部行)。
/// save 全量重写成功后清零;append-only save(insert_batch)成功后保持 false。
///
/// 任何修改既有消息(非纯 push append)的入口置位:clear / pop_last_assistant_round /
/// truncate_after_user_message / replace_tool_result_content / replace_last_active_user_content /
/// insert_at / compress_old_messages / messages_mut(可变借用兜底,调用方可能改 status/content)。
/// 误置位零代价(只是下次 save 多走一次全量重写,正确性不变);漏置位才会丢更新——故保守置位。
needs_full_rewrite: bool,
}
impl ContextManager {
@@ -65,6 +89,8 @@ impl ContextManager {
estimator: TokenEstimator::default(),
is_compressing: false,
pending_topic_marker: None,
persisted_msg_count: 0,
needs_full_rewrite: false,
}
}
@@ -122,6 +148,11 @@ impl ContextManager {
self.history_tokens = 0;
self.is_compressing = false;
self.pending_topic_marker = None;
// msg-split-phase1:全清后 DB 必须也清(调用方 chat.rs:1149 走 delete_range),
// 全量重写基线重置到 0。needs_full_rewrite=true 保险(若 save 先于 delete_range 触发,
// 全量重写空列表也会清 DB)。
self.persisted_msg_count = 0;
self.needs_full_rewrite = true;
}
/// 消息数量
@@ -165,7 +196,7 @@ impl ContextManager {
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
if self.history_tokens <= available {
let sanitized = Self::sanitize_messages(self.all_messages_clone());
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
return (
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
false,
@@ -191,7 +222,7 @@ impl ContextManager {
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
self.history_tokens, available, PROTECT_COUNT
);
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
@@ -214,7 +245,7 @@ impl ContextManager {
trim_end, removed
);
let sanitized = Self::sanitize_messages(msgs);
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
// 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
(
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
true,
@@ -246,10 +277,16 @@ impl ContextManager {
/// 从 Vec 恢复(兼容从 DB 加载)
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
// clear() 会置 needs_full_rewrite=true + persisted_msg_count=0;但本入口是"DB 刚加载",
// 加载后的 messages 与 DB 完全一致(它们就是从 DB 来的),append 基线应对齐到当前 len,
// 且不需要全量重写(DB 已是真相源)。故 clear 后立即覆盖这两个字段。
let len = messages.len();
self.clear();
for msg in messages {
self.push(msg);
}
self.persisted_msg_count = len;
self.needs_full_rewrite = false;
}
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
@@ -271,6 +308,8 @@ impl ContextManager {
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有消息 content,DB 需全量重写收敛(append-only 路径不覆盖旧行)。
self.needs_full_rewrite = true;
true
}
@@ -298,6 +337,11 @@ impl ContextManager {
popped_any = true;
}
}
// msg-split-phase1:从末尾弹出 → len 缩短,append-only 基线(persisted_msg_count)
// 会大于新 len。下次 save 必须全量重写(删 DB 中已不存在的尾行)。
if popped_any {
self.needs_full_rewrite = true;
}
popped_any
}
@@ -325,6 +369,10 @@ impl ContextManager {
count += 1;
}
}
// msg-split-phase1:改既有消息 status(truncated),DB 需全量重写收敛。
if count > 0 {
self.needs_full_rewrite = true;
}
Ok(count)
}
@@ -342,6 +390,8 @@ impl ContextManager {
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
self.messages[i].token_count = new_tokens;
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
// msg-split-phase1:改既有 user 消息 content,DB 需全量重写收敛。
self.needs_full_rewrite = true;
Ok(())
}
@@ -350,7 +400,7 @@ impl ContextManager {
self.messages.iter().map(|t| &t.message)
}
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
/// 消息级溯源:取末条指定 role 消息的 id(ULID)。
///
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
@@ -417,17 +467,49 @@ impl ContextManager {
&self.config
}
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
/// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token)
///
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
///
/// msg-split-phase1:**若调用方借此句柄改了既有消息(status/content),必须紧接着调
/// [`mark_needs_full_rewrite`]**,否则下次 save 走 append-only 增量路径会漏更新旧行,
/// DB 与内存脱钩。仅读(过滤后 clone)不需调。本方法无法自行置位(借用冲突:返回 &mut
/// 切片时不能再持有 &mut self 标志)。
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
&mut self.messages
}
/// msg-split-phase1:显式标记"下次 save 须全量重写"。
///
/// 供 [`messages_mut`] 的调用方在改完既有消息后调用(本 struct 无法在返回 &mut 切片时
/// 自行置位)。也可供任何绕过本 impl 直接改 messages 的路径兜底。幂等(重复置 true 无害)。
pub fn mark_needs_full_rewrite(&mut self) {
self.needs_full_rewrite = true;
}
/// msg-split-phase1:读取已落库消息条数(append-only 增量写路径的基线)。供 save_conversation
/// 判定 `[persisted_msg_count..len)` 是否为"自上次 save 后新增"。
pub fn persisted_msg_count(&self) -> usize {
self.persisted_msg_count
}
/// msg-split-phase1:推进已落库消息条数(insert_batch/replace_conversation 成功后调用)。
/// 同时清 needs_full_rewrite(DB 已与内存一致)。供 save_conversation 在写库成功后调用。
pub fn advance_persisted_count(&mut self, new_count: usize) {
// 防回退:若调用方误传更小值(理论上不应发生),取 max 保不丢基线(下次 save 仍能收敛)。
self.persisted_msg_count = self.persisted_msg_count.max(new_count);
self.needs_full_rewrite = false;
}
/// msg-split-phase1:是否需要全量重写(改过既有消息 / 缩短 / 中段插入 / 清空)。
pub fn needs_full_rewrite(&self) -> bool {
self.needs_full_rewrite
}
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
///
/// 供阶段2 在压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 供压缩点插入摘要 system 消息。`index` 越界则 panic(对齐 Vec::insert 语义,
/// 调用方负责算合法 index,如 `compress_end` 已由 `compress_old_messages` 校验)。
pub fn insert_at(&mut self, index: usize, message: ChatMessage) {
let tokens = self.estimator.estimate_message(&message);
@@ -441,6 +523,9 @@ impl ContextManager {
group,
topic: None,
});
// msg-split-phase1:中段插入会改变 index 及之后所有消息的 seq(append-only 基线
// persisted_msg_count 按"末尾追加"语义计算 seq,中段插入后 seq 错位),DB 必须全量重写。
self.needs_full_rewrite = true;
}
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
@@ -449,7 +534,7 @@ impl ContextManager {
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
///
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
/// 公开会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
let mut units = Vec::new();
let mut i = 0usize;
@@ -488,7 +573,7 @@ impl ContextManager {
/// 不参与二次压缩,幂等)。`protect_start` 为保护区起点(如 `len - PROTECT_COUNT`)。
pub fn has_compressible_messages(&self, protect_start: usize) -> bool {
let end = protect_start.min(self.messages.len());
// BUG-260624-05:排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 排除 system 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
@@ -498,7 +583,7 @@ impl ContextManager {
}
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供喂 LLM 摘要)。
///
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
@@ -517,6 +602,12 @@ impl ContextManager {
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
}
}
// msg-split-phase1:改了既有消息 status(active → compressed),DB 需全量重写收敛
// (append-only 增量路径不覆盖旧行)。仅在确实标了新 compressed 时置位(无可压缩时
// newly_compressed 为空,状态未变,无需全量重写)。
if !newly_compressed.is_empty() {
self.needs_full_rewrite = true;
}
newly_compressed
}
+3 -3
View File
@@ -151,7 +151,7 @@ pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
sanitized
};
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
// step 3.5(占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
//
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
@@ -227,7 +227,7 @@ pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
filtered
}
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 发送视图出口断言(占位配对完整性):确保所有 tool_result(含审批占位)都有
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
///
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
@@ -470,7 +470,7 @@ mod tests {
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
}
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
// ── 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
#[test]
fn sanitize_drops_reverse_orphan_tool_result() {
+177 -11
View File
@@ -1,4 +1,4 @@
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
//! 上下文管理纯函数与数据类型 — 从 context.rs 抽离的无 `self` 依赖部分
//!
//! 职责:
//! - Token 粗估器(字符级近似,无 tokenizer 依赖)
@@ -47,7 +47,7 @@ impl Default for TokenEstimator {
impl TokenEstimator {
/// 估算单条消息的 token 数(保守估计)
///
/// F-260614-05 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 多模态回归修正:`msg.parts` 中的 Image.base64 与 Text.text 同样计入预算。
/// 此前只算 `content`,含图消息的大段 base64(可达 25 万 tokens)被完全忽略,致
/// `history_tokens` 严重低估 → build_for_request 误判未超预算 → provider 超限 400/500。
/// 这里把 parts 的文本/base64 按同一 chars_ratio 粗估累加(base64 视为密集字符,0.35 偏保守)。
@@ -58,7 +58,7 @@ impl TokenEstimator {
match p {
crate::provider::ContentPart::Text { text } => char_count += text.chars().count(),
crate::provider::ContentPart::Image { base64, url, .. } => {
// CR-260618-11#2:0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 0.35 按 base64 字节数粗估,显著高于厂商实际(OpenAI 按像素非字节)。
// 偏保守致含图消息 token 高估、过度裁剪;降值(如 0.10~0.15)需独立评估裁剪边界,本次不改值仅标注。
// base64 优先(多模态主载荷),url 次之;url 模式无字节,仅按 URL 长度估
if let Some(b) = base64 {
@@ -145,7 +145,7 @@ pub enum MessageGroup {
/// 带有 token 缓存和分组信息的消息条目
///
/// 字段 `pub`:供阶段2 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
/// 字段 `pub`:供 IPC 经 `ContextManager::messages_mut()` 拿到可变切片后,
/// 直接改 `message.status` / 读 `token_count` 做 token 重算(Mutex 单线程访问,
/// 同 crate 内安全)。结构体本身也 `pub`(返回类型对外可见)。
pub struct TrackedMessage {
@@ -408,7 +408,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
*field = serde_json::Value::String(out.join("\n"));
truncated = true;
} else if s.chars().count() > TOOL_RESULT_JSON_STR_FIELD_MAX {
// BUG-260628-01:单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 单行/少行大字符串绕过行级截断(实测 53/94 次零效果)。
// 按字符数截断保留头尾,保证压缩至少生效。
let head: String = s.chars().take(TOOL_RESULT_JSON_STR_FIELD_MAX / 2).collect();
let tail: String = s.chars().skip(s.chars().count().saturating_sub(TOOL_RESULT_JSON_STR_FIELD_MAX / 2)).collect();
@@ -439,7 +439,7 @@ pub fn extract_key_info(content: &str, tool_name: &str) -> String {
let total = lines.len();
let kept_boundary = TOOL_RESULT_HEAD_LINES + TOOL_RESULT_TAIL_LINES;
if total <= kept_boundary {
// BUG-260628-01:行数少但内容超大的情况(单行 50KB),行级截断无效。
// 行数少但内容超大的情况(单行 50KB),行级截断无效。
// 按字符数截断保证压缩至少生效。
let char_count = content.chars().count();
if char_count > TOOL_RESULT_CHAR_LIMIT {
@@ -524,7 +524,7 @@ fn is_error_line(line: &str) -> bool {
/// 淘汰单元:连续消息范围 [..end) + token 总和
///
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(阶段2/3 调用方读 `end` / `token_sum`)。
/// `pub` 供 `build_eviction_units` 的返回类型对外可见(调用方读 `end` / `token_sum`)。
pub struct EvictionUnit {
pub end: usize,
pub token_sum: u32,
@@ -541,7 +541,7 @@ pub const PROTECT_COUNT: usize = 6;
/// 此类 id 必然无匹配 tool_result,是历史中毒的标志,sanitize 时据此剔除畸形三元组。
pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
/// 阶段2(path_auth 审批链重构):占位配对完整性(解 400 orphan)常量开关。
/// 占位配对完整性(解 400 orphan)常量开关。
///
/// 根因:审批挂起占位 tool_result(内容为 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
/// 与其 tool_call 头经 sanitize/compress 裁剪后丢配对头 → orphan tool_result(无头)→
@@ -553,6 +553,9 @@ pub const TOOL_MISSING_PREFIX: &str = "tool_missing_";
/// 兜底:sanitize view-only 不改持久化,失败只影响单请求,flag 关→行为完全等价改动前。
pub const PLACEHOLDER_INTEGRITY_ENABLED: bool = true;
/// namespace 引用路径前缀(转发至 namespace_store,供 context.rs 统一路径引用)
pub const NAMESPACE_REF_PREFIX: &str = crate::namespace_store::NAMESPACE_REF_PREFIX;
/// 占位 tool_result 内容中嵌入的唯一标记前缀(供 sanitize 识别"审批挂起占位,不可裁")。
///
/// 写入处(audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)格式:`{占位文本}{__PENDING__}{tc_id}`,
@@ -604,6 +607,169 @@ pub fn extract_pending_tc_id(content: &str) -> Option<&str> {
.filter(|id| !id.is_empty())
}
// ============================================================
// Wave 共享契约类型(由 上下文管理演进-执行计划 Agent 3 定义)
// ============================================================
/// 压缩摘要双格式(T3 输出, T5 消费)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CompressedSummary {
/// JSON 卡片序列化(主题/决策/待办/token节省)
pub json_card: String,
/// 自然语言摘要(向前兼容关键词兜底降级)
pub nl_summary: String,
}
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkflowContextBlock {
pub workflow_id: String,
pub workflow_name: String,
pub total_nodes: usize,
pub completed_nodes: usize,
pub current_node: Option<String>,
pub next_nodes: Vec<String>,
}
impl WorkflowContextBlock {
/// 格式化为 system prompt 可读文本
pub fn to_system_text(&self) -> String {
let mut s = format!("工作流: {} (ID: {})", self.workflow_name, self.workflow_id);
s.push_str(&format!("\n进度: {}/{}", self.completed_nodes, self.total_nodes));
if let Some(ref cur) = self.current_node {
s.push_str(&format!("\n当前步骤: {}", cur));
}
if !self.next_nodes.is_empty() {
s.push_str(&format!("\n后续: {}", self.next_nodes.join(" → ")));
}
s
}
}
/// 目标状态(T5 WorkingContext)
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GoalStatus {
Active,
Completed,
Cancelled,
}
/// 步骤状态
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum StepStatus {
Pending,
InProgress,
Done,
Blocked,
}
/// 目标条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GoalItem {
pub id: String,
pub text: String,
pub status: GoalStatus,
}
/// 决策条目
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DecisionItem {
pub question: String,
pub decision: String,
pub reason: String,
}
/// 步骤信息
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepInfo {
pub step_name: String,
pub status: StepStatus,
}
/// WorkingContext 版本号(脏标记追踪)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingContextVersion {
pub goals_changed_at: u32,
pub decisions_changed_at: u32,
pub unresolved_changed_at: u32,
pub step_changed_at: u32,
pub current_turn: u32,
}
impl WorkingContextVersion {
pub fn new() -> Self {
Self {
goals_changed_at: 0,
decisions_changed_at: 0,
unresolved_changed_at: 0,
step_changed_at: 0,
current_turn: 0,
}
}
/// 压缩后重置:将所有字段标记为本轮变更
pub fn reset_all(&mut self, _summary: &str, current_turn: u32) {
self.goals_changed_at = current_turn;
self.decisions_changed_at = current_turn;
self.unresolved_changed_at = current_turn;
self.current_turn = current_turn;
}
}
/// WorkingContext 常驻上下文(T5 实现)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WorkingContext {
pub goals: Vec<GoalItem>,
pub decisions: Vec<DecisionItem>,
pub unresolved: Vec<String>,
pub current_step: Option<StepInfo>,
pub version: WorkingContextVersion,
}
impl WorkingContext {
pub fn new() -> Self {
Self {
goals: Vec::new(),
decisions: Vec::new(),
unresolved: Vec::new(),
current_step: None,
version: WorkingContextVersion::new(),
}
}
/// 构建本轮需要注入的 L1 内容(L1a 必带 + L1b 条件注入)
pub fn build_injection(&self, recent_turns: u32) -> String {
let mut parts: Vec<String> = Vec::new();
// L1a: 当前步骤(每轮必带)
if let Some(ref step) = self.current_step {
parts.push(format!("当前步骤: {} [{:?}]", step.step_name, step.status));
}
// L1b: 过去 N 轮内有变更才注入
let threshold = self.version.current_turn.saturating_sub(recent_turns);
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
if has_active && self.version.goals_changed_at >= threshold {
let active: Vec<&str> = self.goals.iter()
.filter(|g| g.status == GoalStatus::Active)
.map(|g| g.text.as_str())
.collect();
parts.push(format!("目标({}): {}", active.len(), active.join("; ")));
}
if self.version.decisions_changed_at >= threshold && !self.decisions.is_empty() {
let recent: Vec<&str> = self.decisions.iter().map(|d| d.decision.as_str()).collect();
parts.push(format!("近期决策: {}", recent.join("; ")));
}
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
parts.push(format!("待解: {}", self.unresolved.join("; ")));
}
parts.join("\n")
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -952,7 +1118,7 @@ mod tests {
#[test]
fn extract_key_info_single_huge_line_no_newline_compressed() {
// BUG-260628-01:单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
// 单行超大内容(50KB)原本逃逸压缩,现按字符数截断保留头尾。
let content = "x".repeat(50_000);
let result = extract_key_info(&content, "read_file");
assert!(result.len() < content.len(), "单行超长应压缩: {} >= {}", result.len(), content.len());
@@ -964,7 +1130,7 @@ mod tests {
#[test]
fn extract_key_info_json_huge_string_field_truncated() {
// BUG-260628-01:JSON 对象中大字符串字段(单行少行)逃逸压缩。
// JSON 对象中大字符串字段(单行少行)逃逸压缩。
// 如 `{"path":"src/main.rs","content":"单行超大文本..."}`。
let large = "z".repeat(10_000);
let content = format!("{{\"path\":\"src/main.rs\",\"content\":\"{}\"}}", large);
@@ -1077,7 +1243,7 @@ mod tests {
assert!(toks.contains(&"now".to_string()));
}
// ── 阶段2 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
// ── 占位配对完整性:is_pending_placeholder / extract_pending_tc_id ──
#[test]
fn is_pending_placeholder_new_with_marker() {
+9 -2
View File
@@ -1,10 +1,16 @@
//! df-ai: AI 编排 — LLM Provider、Agent 协调、上下文管理、流式处理、工具注册
pub mod ai_tools;
// 声明式工具注册宏 `declare_tool!`(tool_registry 拆分第一步基础设施)。
// macro_rules + #[macro_export]:零新依赖,展开为等价 `register` 调用,与现有 48 工具并存。
// 试点:list_projects 已迁至声明式(commands/ai/tools/list_projects.rs),验证可行+零回归。
#[macro_use]
pub mod ai_tools_decl;
pub mod anthropic_compat;
pub mod anthropic_helpers;
pub mod context;
pub mod context_helpers;
pub mod memory_adapter;
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
pub mod coordinator;
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
@@ -37,11 +43,12 @@ pub mod persona;
pub mod plan_executor;
pub mod provider;
pub mod router;
// CR-30-1: 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// 流前重试退避对外复用。complete() 的 retry_with_backoff 仍 crate 内用,
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
// 避免重写退避/分类逻辑。改 pub mod 后对外仅暴露纯函数 + 常量。
pub mod retry;
pub mod sse_parser;
pub mod namespace_store;
use provider::LlmProvider;
use reqwest::Client;
+86
View File
@@ -0,0 +1,86 @@
//! MemoryAdapter trait — 上下文记忆层抽象
//!
//! 将 ContextManager 的核心接口提取为 trait,支持多种记忆后端:
//! - InMemoryAdapter(当前 ContextManager 的包装,默认实现)
//! - VectorMemoryAdapter(未来:接入向量检索,支持语义记忆)
//!
//! 使用方式:`Box<dyn MemoryAdapter>` 替代 `ContextManager`。
use crate::context::ContextManager;
use crate::context_helpers::{ContextConfig, TrackedMessage};
use crate::provider::ChatMessage;
/// 上下文记忆层抽象接口
///
/// 覆盖 agentic loop / IPC 最常用的 15 个方法。
/// 保留 `ContextManager` 作为默认实现(`InMemoryAdapter`),行为零变化。
pub trait MemoryAdapter: Send + Sync {
// ── 写入 ──
fn push(&mut self, msg: ChatMessage);
fn clear(&mut self);
fn insert_at(&mut self, index: usize, message: ChatMessage);
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool;
fn pop_last_assistant_round(&mut self) -> bool;
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()>;
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()>;
// ── 读取 ──
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool);
fn all_messages_clone(&self) -> Vec<ChatMessage>;
fn len(&self) -> usize;
fn is_empty(&self) -> bool;
fn history_tokens(&self) -> u32;
fn budget_limit(&self) -> u32;
fn config(&self) -> &ContextConfig;
// ── 压缩 ──
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage>;
fn has_compressible_messages(&self, protect_start: usize) -> bool;
fn is_compressing(&self) -> bool;
fn set_compressing(&mut self, v: bool);
// ── 生命周期 ──
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>);
fn take_topic_marker(&mut self) -> Option<String>;
fn messages_mut(&mut self) -> &mut [TrackedMessage];
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_>;
}
/// ContextManager 作为默认 MemoryAdapter 实现
impl MemoryAdapter for ContextManager {
fn push(&mut self, msg: ChatMessage) { self.push(msg); }
fn clear(&mut self) { self.clear(); }
fn insert_at(&mut self, index: usize, message: ChatMessage) { self.insert_at(index, message); }
fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
self.replace_tool_result_content(tool_call_id, new_content)
}
fn pop_last_assistant_round(&mut self) -> bool { self.pop_last_assistant_round() }
fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
self.truncate_after_user_message(target_content)
}
fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
self.replace_last_active_user_content(new_content)
}
fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
self.build_for_request(sys_tokens)
}
fn all_messages_clone(&self) -> Vec<ChatMessage> { self.all_messages_clone() }
fn len(&self) -> usize { self.len() }
fn is_empty(&self) -> bool { self.is_empty() }
fn history_tokens(&self) -> u32 { self.history_tokens() }
fn budget_limit(&self) -> u32 { self.budget_limit() }
fn config(&self) -> &ContextConfig { self.config() }
fn compress_old_messages(&mut self, end: usize) -> Vec<ChatMessage> { self.compress_old_messages(end) }
fn has_compressible_messages(&self, protect_start: usize) -> bool { self.has_compressible_messages(protect_start) }
fn is_compressing(&self) -> bool { self.is_compressing() }
fn set_compressing(&mut self, v: bool) { self.set_compressing(v); }
fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) { self.restore_from_messages(messages); }
fn take_topic_marker(&mut self) -> Option<String> { self.take_topic_marker() }
fn messages_mut(&mut self) -> &mut [TrackedMessage] { self.messages_mut() }
fn iter(&self) -> Box<dyn Iterator<Item = &ChatMessage> + '_> {
Box::new(self.iter())
}
}
+1 -1
View File
@@ -1,4 +1,4 @@
//! 厂商模型列表拉取 — F-01 阶段3
//! 厂商模型列表拉取。
//!
//! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。
//! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。
+4 -4
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — F-01 阶段2
//! 模型探测器。
//!
//! 给定模型名,产出完整 `ModelConfig`(4 维度 + 路由控制 + 探测来源标注)。
//!
@@ -100,7 +100,7 @@ mod tests {
assert_eq!(m.model_id, "glm-4");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
assert_eq!(m.modalities, vec![Modality::Text]);
// B-260618-04:预设表不再写死 cost_tier/intelligence,由 serde default 兜底中性值
// 预设表不写死 cost_tier/intelligence,由 serde default 兜底中性值
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
@@ -177,7 +177,7 @@ mod tests {
fn heuristic_flash_keeps_neutral_tier() {
let m = probe("unknown-flash");
assert_eq!(m.probe_source, Some(ProbeSource::Heuristic));
// B-260618-04:cost/intel 一律中性,不靠名字猜
// cost/intel 一律中性,不靠名字猜
assert_eq!(m.intelligence, IntelligenceTier::Standard);
assert_eq!(m.cost_tier, CostTier::Medium);
}
@@ -261,7 +261,7 @@ mod tests {
#[test]
fn probe_preset_beats_heuristic() {
// "glm-4-flash" 精确命中预设表;B-260618-04 后预设/启发式档位都中性,
// "glm-4-flash" 精确命中预设表;预设/启发式档位都中性,
// 此处仅校验 source 标注为 PresetTable
let m = probe("glm-4-flash");
assert_eq!(m.probe_source, Some(ProbeSource::PresetTable));
+3 -3
View File
@@ -1,4 +1,4 @@
//! 模型探测器 — 纯逻辑子模块(F-01 阶段2)
//! 模型探测器 — 纯逻辑子模块。
//!
//! 从 `model_probe.rs` 抽离的纯函数实现(预设表加载 / 启发式推断 / 词素判定)。
//! 无 IO、无外部状态,crate 内经 `model_probe::probe` 间接复用 —
@@ -36,7 +36,7 @@ pub(super) fn presets() -> &'static [ModelConfig] {
/// 模型名启发式推断。仅推断功能性维度(modalities/capabilities),
/// cost_tier / intelligence 一律返中性默认(Medium / Standard)。
///
/// 取舍:B-260618-04 — 模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 取舍:模型名关键词猜档位(flash→Lite、4o→High、pro→Plus)无依据,
/// 厂商定价/智力与命名无关,瞎填会污染路由器过滤(intelligence >= min / cost_tier <= max)。
/// 改中性默认,真实档位由用户手填或更高阶探测源(如厂商 API/定价表)提供。
///
@@ -49,7 +49,7 @@ pub(super) fn heuristic_infer(model_id: &str) -> ModelConfig {
let name = model_id.to_lowercase();
let mut modalities: Vec<Modality> = Vec::new();
let mut capabilities: Vec<Capability> = Vec::new();
// 中性默认:不靠模型名猜档位(B-260618-04)
// 中性默认:不靠模型名猜档位
let cost_tier = CostTier::Medium;
let intelligence = IntelligenceTier::Standard;
+279
View File
@@ -0,0 +1,279 @@
//! 工具命名空间存储 — 大工具结果不进主消息队列
//!
//! 当工具执行结果超过阈值时,存入 `NamespaceStore`,主队列只保留轻量引用路径。
//! 运行时为内存 HashMap(LRU 淘汰),持久化时由调用方展开引用写 DB。
//!
//! 存储格式: "namespace://tool_name/args_hash"
//! 例: namespace://read_file/a1b2c3d4
use std::collections::HashMap;
use std::time::Instant;
/// 引用路径前缀
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
/// namespace 条目被 LRU 淘汰(或跨会话残留引用)后,展开点 read_only()=None 时的提示文案。
///
/// 旧实现 None 分支保留 "namespace://" 字面 URI,LLM 收到无意义串且无告警。
/// 现统一替换为此文案:既告知用户结果已淘汰,又提示可重新调用工具取回。
pub const EVICTED_PLACEHOLDER: &str = "[该工具结果已超出内存上限被淘汰,如需请重新调用对应工具]";
/// namespace 字节阈值(> 2048 bytes 触发)
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
/// namespace 行数阈值(> 50 行 触发)
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
/// 始终进 namespace 的工具名(其输出几乎总是大结果)
pub const ALWAYS_LARGE_TOOLS: &[&str] = &["read_file", "list_directory", "grep", "diff_files"];
/// 判定工具结果是否应进入 namespace
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
if ALWAYS_LARGE_TOOLS.contains(&tool_name) {
return true;
}
content.len() > NAMESPACE_BYTE_THRESHOLD
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
}
/// 判定 tool_result 内容是否为 namespace 引用路径
pub fn is_namespace_ref(content: &str) -> bool {
content.starts_with(NAMESPACE_REF_PREFIX)
}
/// 从引用路径中提取内部 key
pub fn parse_namespace_key(full_path: &str) -> Option<&str> {
full_path.strip_prefix(NAMESPACE_REF_PREFIX)
.and_then(|s| s.split('/').nth(1))
}
/// namespace 条目
#[derive(Debug, Clone)]
pub struct NamespaceEntry {
pub key: String,
pub tool_name: String,
pub content: String,
pub created_at: Instant,
pub access_count: u64,
}
/// namespace 存储(内存 HashMap + LRU 淘汰)
#[derive(Debug, Clone)]
pub struct NamespaceStore {
entries: HashMap<String, NamespaceEntry>,
max_bytes: usize,
current_bytes: usize,
}
impl NamespaceStore {
/// 创建 namespace 存储
pub fn new(max_bytes: usize) -> Self {
Self {
entries: HashMap::new(),
max_bytes,
current_bytes: 0,
}
}
/// 默认大小:10MB
pub fn default() -> Self {
Self::new(10 * 1024 * 1024)
}
/// 存入 namespace。返回引用路径字符串。
pub fn store(&mut self, tool_name: &str, content: &str) -> String {
let key = simple_hash(content);
let path = format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key);
// 已存在 → 刷新 access_count,不重复占用空间
if self.entries.contains_key(&key) {
if let Some(entry) = self.entries.get_mut(&key) {
entry.access_count += 1;
}
return path;
}
// 检查容量,超限淘汰
let new_bytes = content.len();
self.evict_if_needed(new_bytes);
let entry = NamespaceEntry {
key: key.clone(),
tool_name: tool_name.to_string(),
content: content.to_string(),
created_at: Instant::now(),
access_count: 1,
};
self.current_bytes += new_bytes;
self.entries.insert(key, entry);
path
}
/// 读取完整内容
pub fn read(&mut self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
let entry = self.entries.get_mut(key)?;
entry.access_count += 1;
Some(entry.content.as_str())
}
/// 读取(只读,不改 access_count)
pub fn read_only(&self, full_path: &str) -> Option<&str> {
let key = parse_namespace_key(full_path)?;
self.entries.get(key).map(|e| e.content.as_str())
}
/// 检查 key 是否存在
pub fn contains(&self, full_path: &str) -> bool {
parse_namespace_key(full_path)
.and_then(|k| self.entries.get(k))
.is_some()
}
/// 条目数
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// LRU 淘汰:删除最旧条目直到腾出所需空间
fn evict_if_needed(&mut self, needed: usize) {
while self.current_bytes + needed > self.max_bytes && !self.entries.is_empty() {
// 找最旧的条目
let oldest_key = self.entries.iter()
.min_by_key(|(_, e)| e.created_at)
.map(|(k, _)| k.clone());
if let Some(key) = oldest_key {
if let Some(entry) = self.entries.remove(&key) {
self.current_bytes = self.current_bytes.saturating_sub(entry.content.len());
}
}
}
}
/// 清空
pub fn clear(&mut self) {
self.entries.clear();
self.current_bytes = 0;
}
}
/// 内容 → 引用 key 段(确定性,不含 `/` 等会破坏 `parse_namespace_key` 的字符)。
///
/// 历史 bug:旧实现用 `"{len}_{前16字符}"`,前 16 字符原样进 key;内容前缀含 `/`(目录列表、
/// 带路径的 grep 结果、文件头是路径)时,`namespace://tool/{key}` 被 `parse_namespace_key`
/// 的 `split('/').nth(1)` 从首个 `/` 截断 → read/read_only 拿回 None → 原文彻底丢失。
/// 现改用 SipHash u64 → 16 位 hex:确定性 + 极低碰撞 + 不含特殊字符。
fn simple_hash(content: &str) -> String {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
content.hash(&mut hasher);
format!("{:016x}", hasher.finish())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn should_use_namespace_small_content() {
assert!(!should_use_namespace("short", "custom_tool"));
}
#[test]
fn should_use_namespace_large_bytes() {
let large = "x".repeat(3000);
assert!(should_use_namespace(&large, "custom_tool"));
}
#[test]
fn should_use_namespace_always_large_tool() {
assert!(should_use_namespace("short", "read_file"));
assert!(should_use_namespace("short", "list_directory"));
}
#[test]
fn should_use_namespace_many_lines() {
let many = (0..60).map(|i| format!("line {}", i)).collect::<Vec<_>>().join("\n");
assert!(should_use_namespace(&many, "custom_tool"));
}
#[test]
fn is_namespace_ref_detects_prefix() {
assert!(is_namespace_ref("namespace://read_file/abc"));
assert!(!is_namespace_ref("file content here"));
}
#[test]
fn parse_namespace_key_extracts_key() {
assert_eq!(parse_namespace_key("namespace://read_file/abc_123"), Some("abc_123"));
assert_eq!(parse_namespace_key("namespace://grep/xyz"), Some("xyz"));
assert_eq!(parse_namespace_key("no_prefix"), None);
}
#[test]
fn store_and_read() {
let mut ns = NamespaceStore::new(100_000);
let path = ns.store("read_file", "hello world content");
assert!(is_namespace_ref(&path));
assert!(path.contains("read_file"));
assert_eq!(ns.read(&path), Some("hello world content"));
}
#[test]
fn store_duplicate_key_does_not_double_count() {
let mut ns = NamespaceStore::new(100_000);
let content = "test content for duplicate check";
let p1 = ns.store("tool_a", content);
let p2 = ns.store("tool_b", content);
assert_eq!(ns.len(), 1, "相同内容应去重");
// read(&mut self) 返回 Option<&str> 借用 ns,两次调用须各自转 owned 避免双重可变借用
let r1 = ns.read(&p1).map(str::to_owned);
let r2 = ns.read(&p2).map(str::to_owned);
assert_eq!(r1, r2);
}
#[test]
fn store_content_with_slash_in_prefix_reads_back() {
// 回归:旧 simple_hash 把内容前 16 字符原样进 key,前缀含 '/' 时(目录列表/带路径 grep
// 结果/文件头是路径)namespace://tool/{key} 被 parse_namespace_key 的 split('/') 截断
// → read None,原文丢失。hex hash 修复后 store→read 正确取回。
let mut ns = NamespaceStore::new(100_000);
let content = "foo/bar/baz/qux\nline2\nline3";
let path = ns.store("list_directory", content);
assert!(is_namespace_ref(&path));
assert_eq!(ns.read(&path).map(str::to_owned), Some(content.to_string()));
}
#[test]
fn eviction_oldest_removed_when_over_limit() {
let mut ns = NamespaceStore::new(100); // 极小容量
let small = "a"; // 1 byte
ns.store("t1", small);
assert_eq!(ns.len(), 1);
// 填充到超限
let big = "x".repeat(200);
ns.store("t2", &big);
// t1 可能被淘汰
if ns.current_bytes > 100 {
assert!(ns.len() < 2, "超限时应已淘汰旧条目");
}
}
#[test]
fn clear_resets_all() {
let mut ns = NamespaceStore::new(100_000);
ns.store("t1", "content");
assert!(!ns.is_empty());
ns.clear();
assert!(ns.is_empty());
assert_eq!(ns.current_bytes, 0);
}
}
+11 -11
View File
@@ -114,7 +114,7 @@ impl OpenAICompatProvider {
crate::provider::MessageRole::Assistant => "assistant",
crate::provider::MessageRole::Tool => "tool",
};
// F-260614-05 Phase 2a: 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 多模态 content(须在 move m.tool_calls 之前算,借用 m)。
// 含图消息走 content 数组(text/image_url);纯文本走字符串简写
// (保持与现有纯文本端点零回归)。image_url 支持 data URI(base64)与 http(s) URL。
let content = if m.has_image() {
@@ -176,7 +176,7 @@ impl OpenAICompatProvider {
})
.collect();
// B-260626-01: 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 保证首条 user/system(OpenAI 协议要求首条非 assistant/tool)。
// 对齐 AnthropicCompatProvider::ensure_leading_user:上游绕过 sanitize 的调用方
// (标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
@@ -231,7 +231,7 @@ impl OpenAICompatProvider {
}
}
/// B-260626-01: 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
/// 保证 messages 首条为 user/system(OpenAI 协议要求首条非 assistant/tool)。
///
/// 对齐 `AnthropicCompatProvider::ensure_leading_user`。上游绕过 `ContextManager::sanitize_messages`
/// 的调用方(标题生成/知识注入/工作流 AI 节点等直构造 CompletionRequest 的路径)可能传入首条
@@ -307,8 +307,8 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 同步调用");
// 指数退避重试(B-260616-07): 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变(FR-R4),重试是额外层: 3 次 × 60s 最坏 180s,
// 指数退避重试: 包裹 send + 状态码判定。
// 单请求 60s timeout 保持不变,重试是额外层: 3 次 × 60s 最坏 180s,
// 由 retry_with_backoff 内部 30s 总预算主动止损。
let label = format!("OpenAI[{}]", openai_req.model);
retry_with_backoff(&label, move |_| {
@@ -386,7 +386,7 @@ impl LlmProvider for OpenAICompatProvider {
debug!(model = %openai_req.model, "OpenAI 流式调用");
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// send 阶段需 timeout 防 hang(同 Anthropic 路径)。
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
let send_future = self
.client
@@ -414,7 +414,7 @@ impl LlmProvider for OpenAICompatProvider {
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
}
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
// 原生 SSE 解析器替代 eventsource-stream 库。
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
@@ -616,7 +616,7 @@ mod tests {
assert!(!c.finished);
}
// ---------- F-260614-05 Phase 2a 多模态 convert_request ----------
// ---------- 多模态 convert_request ----------
/// 含图消息 → content 数组(text + image_url data URI);纯文本 → 字符串简写
#[test]
@@ -672,9 +672,9 @@ mod tests {
assert_eq!(msg.content, serde_json::Value::String("hello".into()));
}
// ---------- B-260626-01: ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
// ---------- ensure_leading_user(首条非 user/system → 补 user 占位,OpenAI 对称 Anthropic)----------
/// B-260626-01: 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 首条 assistant → 补 user 占位(对齐 Anthropic)。上游绕过 sanitize 的
/// 调用方(title/knowledge_inject/工作流节点)可能传入首条 assistant 序列,补占位保留上下文。
#[test]
fn openai_ensure_leading_user_first_assistant_gets_placeholder() {
@@ -699,7 +699,7 @@ mod tests {
assert_eq!(out.messages[2].role.as_str(), "user");
}
/// B-260626-01: 正常序列(user 开头)不补占位——零回归。
/// 正常序列(user 开头)不补占位——零回归。
#[test]
fn openai_ensure_leading_user_normal_unchanged() {
let provider = OpenAICompatProvider::new("https://api.openai.com", "k", "gpt-4o");
+3 -3
View File
@@ -20,7 +20,7 @@ use std::time::Duration;
use rand::Rng;
use tracing::warn;
/// 最多尝试次数(含初次)。B-260616-07: 3 次 = 初次 + 2 次重试。
/// 最多尝试次数(含初次)。3 次 = 初次 + 2 次重试。
///
/// 配置化 TODO: 未来接入 per-provider config(`AiProviderRecord.config` JSON)或全局开关
/// (`useSetting('df-ai-max-retries')`)时改为读取配置。当前低频后台调用,常量足够。
@@ -71,8 +71,8 @@ pub fn is_status_retryable(status: u16) -> bool {
/// jitter 用 `rand::thread_rng().gen_range(-0.5..0.5)` 生成 ±50% 比例,避免多客户端同步重试风暴。
/// 以毫秒粒度计算后向下取整(避免秒级截断把 0.9s 砍成 0)。
///
/// CR-30-1: 暴露 pub 供 src-tauri/agentic.rs 流前重试复用(对齐决策 F-260616-07 a1
/// "复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
/// 暴露 pub 供 src-tauri/agentic.rs 流前重试复用
/// ("复用 retry.rs backoff_delay 退避 1s→2s→4s+jitter"),避免重写退避逻辑。
pub fn backoff_delay(attempt: u32) -> Duration {
let base_ms = BASE_BACKOFF_SECS.saturating_mul(1u64 << (attempt - 1)) * 1000;
// ±50% jitter,相对 base 时长的浮动比例
+14 -17
View File
@@ -1,26 +1,23 @@
//! 模型路由器 — F-01 阶段4
//! 模型路由器。
//!
//! 纯函数核心,零 IO / 零状态。给定 TaskRequirements + 候选池,返回最优 ModelConfig。
//! 不接调用点(那是阶段5:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! 不接调用点(那是调用方:agentic.rs / title.rs / knowledge_inject.rs / project.rs /
//! df-ideas / df-nodes ai_node.rs)。
//!
//! 设计来源:docs/02-架构设计/已编号方案/F-01-模型能力系统与智能路由设计-2026-06-16.md §6.1。
//!
//! ModelRouter 为单元结构,select 是无状态关联函数(对齐任务规格,非设计文档的 `&self` 方法)。
//! ModelRouter 为单元结构,select 是无状态关联函数。
// 阶段5: 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements(对齐任务规格 import 风格),re-export 避免调用点
// 调用点经 `df_ai::router::{Modality, Capability, CostTier, IntelligenceTier}`
// 直接 import 维度枚举构造 TaskRequirements,re-export 避免调用点
// 各自从 df_ai_core::model 取(跨 crate 路径冗长)。select/select_model_id 仅借用枚举,无重定义。
// 注:CostTier/IntelligenceTier 路由已解耦(2026-06-18 B-260618-03)——provider /v1/models API
// 注:CostTier/IntelligenceTier 路由已解耦——provider /v1/models API
// 不返回这两维度,数据无客观依据不可信,不参与硬路由;re-export 保留供未来真实判别源。
pub use df_ai_core::model::{Capability, CostTier, IntelligenceTier, Modality, ModelConfig};
/// 任务对模型的需求(3 维度)。
///
/// 由调用点构造(阶段5),描述本次调用需要什么模态/能力/上下文,
/// 由调用点构造,描述本次调用需要什么模态/能力/上下文,
/// 交 ModelRouter::select 在候选池中选最优模型。
///
/// 路由已解耦(2026-06-18 B-260618-03):原 `min_intelligence`/`max_cost` 两字段删除。
/// 路由已解耦:原 `min_intelligence`/`max_cost` 两字段删除。
/// provider /v1/models API 不返回 cost_tier/intelligence,这两维度 100% 靠预设表写死 +
/// 模型名启发式猜,数据无客观依据不可信,不应参与硬路由。枚举(CostTier/IntelligenceTier)
/// 保留供未来出现真实判别源时再接回。
@@ -49,7 +46,7 @@ impl ModelRouter {
/// 4. 窗口够大 — context_window >= estimated_context
/// 5. max_by_key 选最优:纯 weight 主导(权重高者胜)
///
/// 路由已解耦(2026-06-18 B-260618-03):原「智力达标」/「成本可控」两步删除,
/// 路由已解耦:原「智力达标」/「成本可控」两步删除,
/// 原第 7 步排序的 `Reverse(cost_tier)` 同权重选便宜也已删除——排序纯 weight 主导。
/// cost_tier/intelligence 数据无客观依据(provider /v1/models 不返回,靠预设表+模型名
/// 启发式猜),不参与硬路由。枚举保留供未来真实判别源再接回。
@@ -63,7 +60,7 @@ impl ModelRouter {
}
}
/// 阶段5 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
/// 调用点 helper — 路由选模型并直接返回 model_id(纯函数)。
///
/// 给定 TaskRequirements + 候选池,返回最优模型的 `model_id`。
/// 调用点用法:`provider.model_configs`(Vec<ModelConfig>)→ `select_model_id(&req, &pool)`
@@ -106,7 +103,7 @@ mod tests {
}
}
// ── 阶段5 select_model_id helper ──
// ── select_model_id helper ──
#[test]
fn select_model_id_empty_pool_returns_none() {
@@ -248,7 +245,7 @@ mod tests {
);
}
// ── 步骤 4(原智力/成本过滤已解耦 B-260618-03):窗口够大 ──
// ── 步骤 4(原智力/成本过滤已解耦):窗口够大 ──
#[test]
fn context_window_insufficient() {
@@ -316,13 +313,13 @@ mod tests {
#[test]
fn all_dimensions_match_picks_best() {
// 3+ 候选各维度参差,验证过滤链全过 + max_by_key 纯 weight 选最优。
// (B-260618-03:智力/成本过滤已解耦,原步骤 4/5 删除,候选 d 不再因 intelligence 滤掉)
// (智力/成本过滤已解耦,原步骤 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 → 通过(B-260618-03 后 intelligence 不参与过滤),key=90 — weight 最高,胜
// d: weight 90 → 通过(intelligence 不参与过滤),key=90 — weight 最高,胜
// e: enabled=false → 步骤 1 滤掉
//
// 预期:d 胜(weight 90 最高,不再被 intelligence 滤掉)
+83 -14
View File
@@ -1,6 +1,6 @@
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
//!
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
//!
//! 根因分析:
@@ -11,9 +11,13 @@
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
//!
//! 本解析器实现:
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
//! - 容错:解析失败时跳过该事件继续,不中断流
//! - 宽松的 UTF-8 处理(用 bytes 累积,优先 `String::from_utf8` 严格解码保留多字节完整,
//! 失败再降级 `from_utf8_lossy` 不报错)
//! - SSE 协议简单解析(分隔符兼容 `\n\n` / `\r\n\r\n` / `\r\r` 三种行尾归一,
//! data: 前缀提取)
//! - 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节字符在
//! buffer 中天然拼接复原(分隔符为 ASCII,不落在多字节序列中间)
//! - 容错:解析失败时跳过该事件继续,不中断流;buffer 设 `BUF_MAX` 上限防 OOM
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
use futures::Stream;
@@ -23,6 +27,10 @@ use std::task::{Context, Poll};
/// SSE 事件流的 data 字段内容
pub type SseEvent = String;
/// 缓冲区字节上限(防御性):正常流会被 parse_events 持续消费,
/// 仅畸形上游(持续不发分隔符)时触发裁剪,避免无界增长 OOM。
const BUF_MAX: usize = 1 << 20; // 1 MiB
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
pub struct SseStream<S> {
inner: S,
@@ -40,19 +48,42 @@ where
}
}
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
/// 从 buffer 解析完整的 SSE 事件,返回事件列表。
///
/// 事件分隔符兼容三种行尾归一:
/// - `\n\n` (LF LF,规范形态)
/// - `\r\n\r\n` (CRLF CRLF,部分中转站/反代发送)
/// - `\r\r` (CR CR,极少见但同源)
///
/// 取最早出现的分隔符;事件体内部的 `\r\n` 在解码后归一为 `\n`,
/// 确保 `lines()` / `strip_prefix` 等行级解析正常工作。
///
/// 多字节续接:事件体只在定位到完整分隔符后才解码,跨 chunk 的多字节
/// 字符在 buffer 中天然拼接复原(分隔符为 ASCII,不会落在多字节序列中间)。
/// 解码用 `String::from_utf8`(失败再降级 lossy),避免把合法多字节误判为残缺。
fn parse_events(&mut self) -> Vec<SseEvent> {
let mut events = Vec::new();
loop {
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
if sep_pos.is_none() {
break;
// 同时识别三种分隔符,取最早出现的位置与该分隔符的字节长度
let sep = Self::find_event_boundary(&self.buffer);
let (sep_pos, sep_len) = match sep {
Some(v) => v,
None => break,
};
// 取出事件体 + 分隔符整体(buffer 前 sep_pos+sep_len 字节)
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + sep_len).collect();
// 去掉末尾的分隔符,得到事件体
let body_end = event_bytes.len().saturating_sub(sep_len);
let body_bytes = &event_bytes[..body_end];
// 解码:优先严格 UTF-8(保留多字节完整),失败再降级 lossy(不崩)
let mut event_text = match std::str::from_utf8(body_bytes) {
Ok(s) => s.to_owned(),
Err(_) => String::from_utf8_lossy(body_bytes).into_owned(),
};
// 事件体内 CRLF / 裸 CR 归一为 LF,保证后续行级解析一致
if event_text.contains('\r') {
event_text = event_text.replace("\r\n", "\n").replace('\r', "\n");
}
let sep_pos = sep_pos.unwrap();
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
// 去掉末尾的 \n\n
let body_end = event_bytes.len().saturating_sub(2);
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
let data = Self::extract_data_fields(&event_text);
if !data.is_empty() {
events.push(data);
@@ -61,6 +92,29 @@ where
events
}
/// 在 buffer 中查找最早的事件分隔符,返回 `(起始位置, 分隔符字节长度)`。
/// 扫描 `\r\n\r\n` / `\n\n` / `\r\r` 三种形态,取最小起始位置(取最早分隔符)。
fn find_event_boundary(buf: &[u8]) -> Option<(usize, usize)> {
// candidates: (pattern, length)
const PATTERNS: &[(&[u8], usize)] = &[
(b"\r\n\r\n", 4),
(b"\n\n", 2),
(b"\r\r", 2),
];
let mut best: Option<(usize, usize)> = None;
for &(pat, len) in PATTERNS {
// windows 匹配;找到首次出现位置
if let Some(pos) = buf.windows(pat.len()).position(|w| w == pat) {
match best {
None => best = Some((pos, len)),
Some((bpos, _)) if pos < bpos => best = Some((pos, len)),
_ => {}
}
}
}
best
}
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
fn extract_data_fields(event_text: &str) -> String {
let mut data_parts: Vec<&str> = Vec::new();
@@ -94,6 +148,12 @@ where
match self.inner.poll_next_unpin(cx) {
Poll::Ready(Some(Ok(chunk))) => {
self.buffer.extend_from_slice(&chunk);
// 防御:缓冲区不应无限增长。正常情况下 parse_events 会持续消费,
// 仅当上游持续不发分隔符(畸形流)时才触发,此处裁掉头部旧数据避免 OOM。
if self.buffer.len() > BUF_MAX {
let drop_n = self.buffer.len() - BUF_MAX;
self.buffer.drain(..drop_n);
}
continue;
}
Poll::Ready(Some(Err(e))) => {
@@ -102,8 +162,17 @@ where
Poll::Ready(None) => {
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
if !self.buffer.is_empty() {
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
// 解码:优先严格 UTF-8(EOF 无下一 chunk 可拼接,
// 残缺多字节尾部只能尽力而为,降级 lossy 不崩)
let mut remaining = match String::from_utf8(self.buffer.clone()) {
Ok(s) => s,
Err(_) => String::from_utf8_lossy(&self.buffer).into_owned(),
};
self.buffer.clear();
// 同主解析路径:CRLF / 裸 CR 归一为 LF
if remaining.contains('\r') {
remaining = remaining.replace("\r\n", "\n").replace('\r', "\n");
}
let data = Self::extract_data_fields(&remaining);
if !data.is_empty() {
return Poll::Ready(Some(Ok(vec![data])));
+1 -1
View File
@@ -51,7 +51,7 @@ impl EnvSnapshot {
return snap;
}
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
// BUG-2026-07-18 根治: probe_version 内 std::process::Command::output() 无 timeout,
// probe_version 内 std::process::Command::output() 无 timeout,
// Windows 上 python/node 若是 Microsoft Store App Execution Alias(用户未装但开了
// "应用执行别名"),`python --version` 触发 Store 重定向、process 不退出 → output()
// 永久阻塞 → spawn_blocking 线程永不返回 → detect().await 永久挂 → run_agentic_loop
+3 -3
View File
@@ -33,8 +33,8 @@ pub enum ShellType {
impl Default for ShellType {
fn default() -> Self {
// L1 环境感知:Windows 默认 PowerShell 系(非 Cmd)。PowerShell 对引号/$变量/Unicode 处理
// 远优于 cmd,从根上避 kms 类引号转义地狱(seq26-52 撞墙 20+ 次)。AI 写文件执行见 env_profile。
// BUG-260623-04:优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// 远优于 cmd,从根上避 kms 类引号转义地狱。AI 写文件执行见 env_profile。
// 优先 pwsh(PS7,支持 && 运算符)——LLM 训练数据 Unix 多,普遍生成 `cd x && y`,
// PS5 不支持 && 致命令失败(实测会话 6acb7f9b `cd ... && git init` InvalidEndOfLine)。
// 探测失败(未装 pwsh)回退 PS5。探测结果 OnceLock 缓存(只探一次)。
// 注:Default trait 为同步签名,这里只能读取已探测的缓存结果(若未探测则返回 false,退回 PowerShell)。
@@ -146,7 +146,7 @@ pub async fn execute(request: ShellRequest) -> anyhow::Result<ShellResult> {
// 对齐 tool_registry.rs:514「进程已终止」文案名副其实。tokio 1.52.3 支持。
cmd.kill_on_drop(true);
// B-260619-01: Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// Windows 下创建子进程默认弹控制台窗口(cmd/powershell 黑窗闪现)。
// CREATE_NO_WINDOW(0x0800_0000) 标志抑制窗口创建,后台静默执行。
// tokio::process::Command 在 Windows 自带 creation_flags 方法(无需 std CommandExt trait)。
#[cfg(windows)]
+5 -5
View File
@@ -7,7 +7,7 @@
//! 评估来源由 [`EvaluatedBy`] 三态标记:`Llm`(LLM 深度评估)/ `Heuristic`(主动选启发式,
//! 无 provider)/ `HeuristicFallback`(LLM 调用失败降级)。前端可据此显示评估深度标签。
//!
//! LLM prompt 构造与 JSON 解析在 F-260614-03 接入:[`AdversarialEngine::evaluate_with_llm`]
//! LLM prompt 构造与 JSON 解析在后续接入:[`AdversarialEngine::evaluate_with_llm`]
//! 构造三角色辩论 prompt(正方/反方/分析师),调一次 `complete()` 要求返回对齐结构的 JSON,
//! 解析失败/字段缺失/枚举非法 → `bail` 触发降级([`AdversarialEngine::evaluate`] 已兜底)。
//!
@@ -42,7 +42,7 @@ pub struct AdversarialEngine {
/// 可选 LLM provider。Some → 优先 LLM 评估(失败降级启发式);None → 纯启发式。
/// 构造注入(与 IdeaPromoter::new(policy) 同一模式),批量评估复用同一 provider。
provider: Option<Arc<dyn LlmProvider>>,
/// F-01 阶段5: 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 候选模型池。非空时 evaluate_with_llm 经 select_model_id 路由选模型;
/// 空(None provider 或未注入池)→ model 留空由 provider impl 回填自身 default_model
/// (与接入前行为一致,平稳过渡)。
model_pool: Vec<ModelConfig>,
@@ -54,7 +54,7 @@ impl AdversarialEngine {
Self { provider: Some(provider), model_pool: Vec::new() }
}
/// F-01 阶段5: 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
/// 注入 provider + 候选模型池构造。池非空时 evaluate_with_llm 走路由。
pub fn with_pool(provider: Arc<dyn LlmProvider>, model_pool: Vec<ModelConfig>) -> Self {
Self { provider: Some(provider), model_pool }
}
@@ -101,7 +101,7 @@ impl AdversarialEngine {
/// analyst.final_assessment 自洽性等),待产品决策,当前逻辑原样保留不调整。
async fn evaluate_with_llm(&self, idea: &Idea, provider: &Arc<dyn LlmProvider>) -> Result<AdversarialEval> {
let prompt = build_adversarial_prompt(idea);
// F-01 阶段5: 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 智能路由 — 对抗评估 TaskRequirements(Standard,无工具)。
// 池非空 → select_model_id 选最优 model_id;池空/无匹配 → 留空由 provider impl
// 回填自身 default_model(与接入前行为一致,平稳过渡)。
let eval_req = df_ai::router::TaskRequirements {
@@ -421,7 +421,7 @@ mod tests {
}
// ────────────────────────────────────────────────────────────
// LLM 路径测试(F-260614-03)
// LLM 路径测试
// ────────────────────────────────────────────────────────────
/// mock LlmProvider:按构造时给定的响应文本回放,仅供 adversarial 单测。
+1 -1
View File
@@ -84,7 +84,7 @@ pub enum Recommendation {
}
// ============================================================
// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03)
// LLM 对抗评估 — prompt 构造 / JSON 解析
// ============================================================
/// LLM 角色 / 输出契约的系统级约束。
+4 -4
View File
@@ -45,7 +45,7 @@ impl Node for AiNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
let provider: Box<dyn LlmProvider> = provider_from_params(&p);
@@ -117,12 +117,12 @@ impl Node for AiNode {
"properties": {
"prompt": { "type": "string", "description": "用户提示词(若无则取上游 prompt 输出)" },
"system_prompt": { "type": "string", "description": "系统提示词(可选)" },
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 df_storage::secret 解析,不进 config;留空走默认 provider)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"temperature": { "type": "number", "description": "温度 0.0~2.0(可选)" },
"max_tokens": { "type": "integer", "description": "最大生成 token(可选,anthropic 协议无值时默认 4096)" },
"base_url": { "type": "string", "description": "(已废弃过渡)明文 API 地址,改用 provider_id" },
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;FR-S1 下经 secret 解析" }
"api_key": { "type": "string", "description": "(已废弃过渡)明文 API 密钥,改用 provider_id;密钥经 secret 解析" }
},
// SW-260618-15: prompt/provider_id 均"留空走兜底"(prompt 取上游、provider_id 走默认 provider),与 required 矛盾。改 required=[] 对齐 execute 运行时,防前端按 schema 误拒合法配置。
"required": []
@@ -231,7 +231,7 @@ mod tests {
}
// ============================================================
// resolve_provider 双路径测试(FR-S1 注入链核心)
// resolve_provider 双路径测试(注入链核心)
// ============================================================
/// 内存 DB 插 provider(可选 is_default),返回 (db, provider_id)。
+12 -13
View File
@@ -10,7 +10,7 @@ use std::sync::Arc;
use df_ai::df_ai_core::model::{Modality, ModelConfig};
use df_ai::provider::LlmProvider;
// F-01 阶段5: AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// AiNode 路由 — 节点 config.model_id 优先;否则按 TaskRequirements 路由
// (默认 Standard + needs_tool_use=true)。池空/无匹配兜底 record.default_model。
use df_ai::router::{select_model_id, TaskRequirements};
use df_storage::crud::AiProviderRepo;
@@ -22,7 +22,7 @@ use df_workflow::node::{NodeOutput};
/// AI 节点解析后的参数(execute 与参数解析解耦,便于单测覆盖取值/默认/校验逻辑)
///
/// provider 配置(base_url/api_key/protocol/default_model)经 `resolve_provider` 从 DB
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config(FR-S1)**。
/// ai_providers 表查 record + 经 df_storage::secret 解析密钥得到,**不进 config**。
/// api_key 仅存于本结构体内存(AiNode 进程内存),不落 NodeContext.config / NodeOutput.data。
#[derive(Debug)]
pub(crate) struct AiNodeParams {
@@ -37,7 +37,7 @@ pub(crate) struct AiNodeParams {
/// 经 `resolve_provider` 从 ai_providers 表 + df_storage::secret 解析后的 provider 构造要素。
///
/// api_key 字段:明文,FR-S1 下全程不出 AiNode 进程内存(不进 config/output/schema)。
/// api_key 字段:明文,全程不出 AiNode 进程内存(不进 config/output/schema)。
#[derive(Debug, Clone)]
pub(crate) struct ResolvedProvider {
/// 协议类型:openai_compat(默认)/ anthropic(GLM 订阅 / Claude 官方)— 从 record.provider_type 映射
@@ -47,12 +47,12 @@ pub(crate) struct ResolvedProvider {
pub api_key: String,
/// model 为空时的占位(record.default_model 或 "gpt-4o-mini"),避免 provider 构造 panic
pub default_model: String,
/// F-01 阶段5: 候选模型池(来自 record.model_configs)。parse_params 路由用:
/// 候选模型池(来自 record.model_configs)。parse_params 路由用:
/// config.model 留空时经 select_model_id 选最优;池空兜底 default_model。
pub model_pool: Vec<ModelConfig>,
}
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(FR-S1 注入链核心)。
/// 经 ai_providers 表 + df_storage::secret 解析 provider 构造要素(注入链核心)。
///
/// 三路径(优先级从高到低):
/// 1. **provider_id 优先**:config["provider_id"] 存在 → `AiProviderRepo::get_by_id` 查 record →
@@ -114,12 +114,11 @@ pub(crate) async fn resolve_provider(
let plain_key = config.get("api_key").and_then(|v| v.as_str());
if let (Some(base_url_str), Some(api_key_str)) = (plain_base, plain_key) {
tracing::warn!(
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id (FR-S1). \
"AiNode 明文 api_key/base_url 经 config 注入已废弃, 改用 provider_id. \
老路径将在后续版本移除"
);
let base_url = base_url_str.to_string();
let api_key = api_key_str.to_string();
ensure_resolved_key("(明文注入)", &api_key)
let api_key = ensure_resolved_key("(明文注入)", api_key_str)
.map_err(anyhow::Error::msg)?;
let protocol = config
.get("protocol")
@@ -162,7 +161,7 @@ pub(crate) fn resolve_from_record(
config_model: &str,
) -> anyhow::Result<ResolvedProvider> {
let api_key = resolve_provider_secret(record);
ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
let api_key = ensure_resolved_key(&record.name, &api_key).map_err(anyhow::Error::msg)?;
let default_model = if !config_model.is_empty() {
config_model.to_string()
} else if !record.default_model.is_empty() {
@@ -201,7 +200,7 @@ pub(crate) fn parse_params(
.ok_or_else(|| anyhow::anyhow!("AiNode 缺少必填参数: prompt(config 或上游输入均无)"))?;
// ── 可选参数 ──
// model 解析优先级(F-01 阶段5):config.model 显式指定 > 路由选优(provider.model_pool 非空时)
// model 解析优先级:config.model 显式指定 > 路由选优(provider.model_pool 非空时)
// > 空(CompletionRequest.model 留空由 provider impl 回填 default_model,行为不变)。
// 注:provider.model_pool 在 provider move 进 AiNodeParams 前先借引用路由,选中的 model_id
// 填入 CompletionRequest.model;provider.default_model 仍是 build_provider 兜底用。
@@ -213,7 +212,7 @@ pub(crate) fn parse_params(
let model = if !config_model.is_empty() {
config_model
} else {
// F-01 阶段5: AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// AiNode 默认路由 — needs_tool_use=true(工作流无人值守 AI 步骤
// 常含工具调用,如检索/生成;无需工具的节点应在 config 显式指定 model)。
// select_model_id None(池空/无匹配)→ 空串(由 provider impl 回填 default_model)。
let node_req = TaskRequirements {
@@ -289,10 +288,10 @@ pub(crate) fn truncate_for_summary(s: &str) -> String {
format!("{truncated}…")
}
/// 阶段3 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
/// 自审闸门决策(纯函数,便于单测覆盖各 verdict/gate 组合)。
///
/// 仅当 `gate==true` 且 `verdict=="fail"` 时阻断。verdict="unknown"(LLM 输出不可靠)
/// 与 "pass" 均不阻断 —— unknown 保持人定权(阶段2 保守语义不变)。
/// 与 "pass" 均不阻断 —— unknown 保持人定权(保守语义不变)。
pub(crate) fn gate_should_block(gate: bool, verdict: &str) -> bool {
gate && verdict == "fail"
}
+8 -8
View File
@@ -74,7 +74,7 @@ impl Node for AiSelfReviewNode {
async fn execute(&self, ctx: NodeContext) -> NodeResult {
tracing::info!("AiSelfReviewNode 执行: node_id={}", ctx.node_id);
// FR-S1 注入链:provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
// provider 经 df_storage::secret 在 AiNode 内存解析,api_key 不进 config。
let p = resolve_and_parse(&self.db, &ctx.config, &ctx.inputs).await?;
// ── 读任务(需求 + 产出) ──
@@ -195,7 +195,7 @@ impl Node for AiSelfReviewNode {
"model": response.model,
}));
// ── 阶段3: 自审闸门(F-260616-07 决策 a 步骤③) ──
// ── 自审闸门 ──
// config["gate"]==true 时,AiSelfReviewNode 从「自审辅助」升级为「DAG 节点闸门」:
// verdict="fail" → 返回 Err → executor first_err 中止后续层(下游 human_review 不跑)
// → 工作流 failed → ②-4 回调退回 in_review(对齐工作流失败语义)
@@ -214,7 +214,7 @@ impl Node for AiSelfReviewNode {
// C) executor 闸门检查钩子(节点 execute 后 executor 读 output.verdict):改 DagExecutor
// 核心循环,牵动所有节点,风险/范围不符「最简不破坏」。
// 选 A:语义最贴近「自审结果作为闸门」(自审节点自行决定放行/阻断),且 gate 可按节点
// config 开关(默认 false = 阶段2 行为不变,模板/前端零强制改动,向后兼容)。
// config 开关(默认 false = 辅助模式行为不变,模板/前端零强制改动,向后兼容)。
let gate_enabled = ctx
.config
.get("gate")
@@ -241,10 +241,10 @@ impl Node for AiSelfReviewNode {
"type": "object",
"properties": {
"task_id": { "type": "string", "description": "自审目标任务 ID(必填)" },
"provider_id": { "type": "string", "description": "AI Provider ID(FR-S1:密钥经 secret 解析不进 config;留空走默认 provider)" },
"provider_id": { "type": "string", "description": "AI Provider ID(密钥经 secret 解析不进 config;留空走默认 provider)" },
"model": { "type": "string", "description": "模型名(可选,留空用 record.default_model)" },
"max_tokens": { "type": "integer" },
"gate": { "type": "boolean", "description": "阶段3 闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
"gate": { "type": "boolean", "description": "闸门开关:false(默认)=自审辅助,verdict 仅透传展示;true=自审结果作 DAG 闸门,verdict=fail 返回 Err 阻断下游(工作流 failed → ②-4 退回),verdict=unknown/pass 放行" }
},
"required": ["task_id", "provider_id"]
}),
@@ -411,18 +411,18 @@ mod tests {
}
// ============================================================
// 阶段3 自审闸门(gate_should_block)单测
// 自审闸门(gate_should_block)单测
// ============================================================
//
// gate 决策矩阵:
// gate=false(默认,阶段2 行为) → 任何 verdict 都放行(辅助模式)
// gate=false(默认,辅助模式行为) → 任何 verdict 都放行(辅助模式)
// gate=true + verdict=pass → 放行
// gate=true + verdict=unknown → 放行(LLM 不可靠时不阻断,人定权)
// gate=true + verdict=fail → 阻断(返回 Err,工作流 failed)
#[test]
fn gate_disabled_never_blocks_any_verdict() {
// gate 默认 false(阶段2 兼容):无论 verdict 如何都不阻断,自审仅辅助展示
// gate 默认 false:无论 verdict 如何都不阻断,自审仅辅助展示
assert!(!gate_should_block(false, "fail"), "gate 关闭时 fail 也不阻断");
assert!(!gate_should_block(false, "pass"), "gate 关闭时 pass 放行");
assert!(!gate_should_block(false, "unknown"), "gate 关闭时 unknown 放行");
+27 -25
View File
@@ -40,7 +40,7 @@ impl Node for HumanNode {
.and_then(|v| v.as_u64())
.unwrap_or(3600);
// F-260615-01: 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
// 解析 select_type(缺省 Single,向后兼容)。非 "multiple" 一律按 Single 处理。
let select_type = match config.get("select_type").and_then(|v| v.as_str()) {
Some("multiple") => SelectType::Multiple,
_ => SelectType::Single,
@@ -74,7 +74,7 @@ impl Node for HumanNode {
Ok(WorkflowEvent::HumanApprovalResponse {
execution_id, node_id, decision, decisions, comment,
}) if execution_id == ctx.execution_id && node_id == ctx.node_id => {
// F-260615-01: 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// 归一化决策集合(优先用 decisions 数组,空则回退兼容 decision 单值)
// select_type=Single → 决策数必须 =1
// select_type=Multiple → 决策数必须 ≥1
// options 空 → 允许自由文本(仅受数量约束);
@@ -91,24 +91,26 @@ impl Node for HumanNode {
let each_valid = picked.iter().all(|d| !d.is_empty())
&& (options.is_empty() || picked.iter().all(|d| options.contains(d)));
if count_ok && each_valid {
// F-260616-06 阶段2: 拒绝语义化。
// 拒绝语义化。
// 审批拒绝此前与同意一样返 Ok —— 语义反转(审批被拒却报"成功"),
// 下游无法据 failed 触发退回/重做。
// 现:decision 命中拒绝关键字(见 REJECT_KEYWORDS)→ 返 Err
// "人工审批被拒绝(用户选择: <decision>)",executor Err 分支 set_failed
// → 工作流 failed 状态 → 阶段2 推进链可据 failed 触发退回。
// → 工作流 failed 状态 → 推进链可据 failed 触发退回。
// 行为变更:审批拒绝从 Ok → Err,标注(同步通知主代理)。
if contains_reject(&picked) {
let primary = picked.first().cloned().unwrap_or_default();
let comment_str = comment.unwrap_or_default();
// 意见后缀:空 comment 不拼接,非空才追加(避免空括号)
let suffix = if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
};
return Err(anyhow::anyhow!(
"人工审批被拒绝(用户选择: {}){}",
primary,
if comment_str.is_empty() {
String::new()
} else {
format!(";意见: {}", comment_str)
}
suffix,
));
}
// 输出统一含 decisions 数组;保留 decision 取首项(向后兼容下游消费者)
@@ -225,7 +227,7 @@ mod tests {
}
/// 发一条审批响应到事件总线(模拟前端 approve_human_approval IPC 走完后的链路)。
/// F-260615-01: 单选调用方仅填 decision;多选调用方填 decisions。
/// 单选调用方仅填 decision;多选调用方填 decisions。
async fn send_response(
event_bus: &EventBus,
execution_id: &str,
@@ -244,7 +246,7 @@ mod tests {
.await;
}
/// F-260615-01: 多选响应发送助手(填 decisions 数组,decision 留空)
/// 多选响应发送助手(填 decisions 数组,decision 留空)
async fn send_response_multi(
event_bus: &EventBus,
execution_id: &str,
@@ -568,9 +570,9 @@ mod tests {
assert_eq!(sm.get(&"h".to_string()), NodeStatus::Cancelled);
}
// ===== F-260615-01: 多选审批覆盖 =====
// ===== 多选审批覆盖 =====
/// F-260615-01: select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
/// select_type=multiple + 多 decisions(均∈options) → 返回 decisions 数组
#[tokio::test]
async fn multiple_select_returns_decisions_array() {
let bus = EventBus::new();
@@ -596,7 +598,7 @@ mod tests {
assert_eq!(out.data["comment"], json!("多选"));
}
/// F-260615-01: select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
/// select_type=single 缺省 + decisions 多个 → 校验失败(count!=1)忽略后超时
#[tokio::test]
async fn single_select_rejects_multiple_decisions_then_timeout() {
let bus = EventBus::new();
@@ -618,7 +620,7 @@ mod tests {
assert!(err.contains("超时"), "single 下多 decisions 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
/// select_type=multiple 但 decisions 含 ∉ options 的项 → 非法忽略后超时
#[tokio::test]
async fn multiple_select_invalid_option_ignored_then_timeout() {
let bus = EventBus::new();
@@ -644,7 +646,7 @@ mod tests {
assert!(err.contains("超时"), "含非法 option 应被忽略后超时, 实际: {}", err);
}
/// F-260615-01: 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// 兼容旧调用方 —— 不填 select_type(缺省 single) + 只填 decision 单值,应正常通过
/// (即所有未改造的现有 Request 均按 single 解析,零改动)
#[tokio::test]
async fn default_single_with_legacy_decision_single_value() {
@@ -664,10 +666,10 @@ mod tests {
assert_eq!(out.data["decisions"], json!(["同意"]), "兼容回退后 decisions 应含单值");
}
// ===== F-260616-06 阶段2: 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
// ===== 审批拒绝语义化(行为变更: 拒绝从 Ok → Err) =====
/// F-260616-06: 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
/// 阶段2 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
/// 默认 options `["同意","拒绝"]` 下选「拒绝」→ Err(不再 Ok)。
/// 推进链依赖工作流 failed 触发退回,故拒绝必须让节点返 Err → executor set_failed。
#[tokio::test]
async fn reject_decision_returns_error() {
let bus = EventBus::new();
@@ -697,7 +699,7 @@ mod tests {
);
}
/// F-260616-06: 同一 options 下选「同意」→ Ok(通过路径不回归)。
/// 同一 options 下选「同意」→ Ok(通过路径不回归)。
#[tokio::test]
async fn approve_decision_still_ok() {
let bus = EventBus::new();
@@ -718,7 +720,7 @@ mod tests {
assert_eq!(out.data["decision"], json!("同意"));
}
/// F-260616-06: 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
/// 英文 reject 关键字同样识别为拒绝 → Err(归一化大小写/空白)。
/// 多关键字覆盖走 reject_keyword_detection_normalized 纯单元测试,此处仅验证端到端一条。
#[tokio::test]
async fn english_reject_keyword_returns_error() {
@@ -744,7 +746,7 @@ mod tests {
);
}
/// F-260616-06: 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// 多选场景,picked 含一项拒绝 → 整单拒绝 → Err
/// (选了「驳回」即驳回,即便同时选了「同意」)。
#[tokio::test]
async fn multiple_select_with_one_reject_returns_error() {
@@ -776,7 +778,7 @@ mod tests {
assert!(err.contains("拒绝"), "多选含拒绝项应返 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// options 空的自由文本场景 —— 明确拒绝词("拒绝")仍返 Err,
/// 其余自由文本(非拒绝词)仍按通过处理(向后兼容,不阻断自由反馈)。
#[tokio::test]
async fn empty_options_free_text_reject_keyword_still_errors() {
@@ -793,7 +795,7 @@ mod tests {
assert!(err.contains("拒绝"), "自由文本明确为拒绝词仍应 Err, 实际: {}", err);
}
/// F-260616-06: options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
/// options 空的自由文本场景 —— 非拒绝词自由文本仍返 Ok(不误伤自由反馈)。
/// (empty_options_allows_free_text 已覆盖 "改成先做B方案" → Ok,此处补一条非拒绝中文短句。)
#[tokio::test]
async fn empty_options_non_reject_free_text_still_ok() {
@@ -810,7 +812,7 @@ mod tests {
assert_eq!(out.data["decision"], json!("再讨论一下"));
}
/// F-260616-06 单元: 关键字判定函数归一化(去空白+小写)与边界。
/// 单元: 关键字判定函数归一化(去空白+小写)与边界。
#[test]
fn reject_keyword_detection_normalized() {
assert!(is_reject_decision("拒绝"));
+2 -2
View File
@@ -3,9 +3,9 @@
//! 从 human_node.rs 抽离的纯函数/常量(execute 与关键字判定解耦,便于单测覆盖)。
//! HumanNode 的 struct + impl 仍保留在 human_node.rs(impl 块约束)。
//!
//! B-260615-05 / CR-260618-15: 拒绝语义化保留(executor set_failed → 推进链退回)。
//! 拒绝语义化保留(executor set_failed → 推进链退回)。
/// F-260616-06 阶段2: 拒绝语义化关键字。
/// 拒绝语义化关键字。
/// decision 归一化(去空白 + 小写)后命中此集合 → 审批拒绝 → 节点返 Err(触发工作流 failed)。
///
/// 识别范围(避免误伤):
+1 -1
View File
@@ -1,4 +1,4 @@
//! 任务推进节点 — advance_task 推进链触发器(F-260616-02)
//! 任务推进节点 — advance_task 推进链触发器。
//!
//! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node):
//! 1. 读当前 TaskRecord(取 from status)
+3 -3
View File
@@ -1,8 +1,8 @@
//! 任务推进状态机 — 7 态合法转换定义(F-260616-01)
//! 任务推进状态机 — 7 态合法转换定义。
//!
//! 独立模块,非挂在 df-types::TaskStatus enum 上(对齐 D-260616-03「推进链业务逻辑落
//! df-nodes」)。本模块只做「给定 from/to 是否合法」的纯函数判定,不触碰存储层
//! (原子写 SQL 在 task_advance_node.rs 完成,见 F-260616-02)。
//! (原子写 SQL 在 task_advance_node.rs 完成)。
//!
//! 7 态(与 df-types::TaskStatus / 前端对齐,D-260616-01):
//! todo / in_progress / in_review / testing / done / blocked / cancelled
@@ -124,7 +124,7 @@ pub fn is_regression(from: &str, to: &str) -> bool {
matches!((from, to), (IN_REVIEW, IN_PROGRESS) | (TESTING, IN_REVIEW))
}
/// 工作流联动任务「失败退一步」的目标态映射(F-260616-06 ②-4)。
/// 工作流联动任务「失败退一步」的目标态映射。
///
/// 工作流失败时,任务不应停留在失败前向目标态,需回退到上一闸门重做。映射表:
/// - testing → in_review(测试失败退回重审)
@@ -1,4 +1,4 @@
//! 任务推进链 DAG 模板(F-260616-06 阶段2 / D-260616-03)
//! 任务推进链 DAG 模板。
//!
//! 推进链前向转换的工作流拓扑描述(声明式,纯数据)。DagDef 只描述节点与边,
//! 执行逻辑靠 DagExecutor 驱动各 Node trait 的 execute —— 模板本身不跑逻辑。
@@ -51,7 +51,7 @@ fn in_progress_template() -> DagDef {
/// in_review → testing:AiNode 自审 → HumanNode 核对。
///
/// 拓扑:ai → human 串行。阶段3(本批)起 ai_self_review 启用 gate:true:
/// 拓扑:ai → human 串行。ai_self_review 启用 gate:true:
/// - verdict=fail → AiSelfReviewNode 返回 Err → 工作流 failed(不经 human_review)
/// → ②-4 失败回调退回 in_review(review_rounds+=1)。
/// - verdict=unknown/pass → 放行 human_review,人定最终是否推进。
@@ -59,9 +59,9 @@ fn in_progress_template() -> DagDef {
/// 通过则完成回调(②-3)推进 status 到 testing。
fn testing_template() -> DagDef {
let mut dag = DagDef::new();
// 决策 a 步骤③:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// 决策:ai_self_review 节点类型对齐 state.rs 注册的独立自审节点
// (四维度 prompt + JSON 解析兜底 + 写回 output_json 加 review 子字段)。
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
// gate=true 启用自审闸门(verdict=fail 阻断下游,工作流 failed)。
dag.add_node(
"ai_self_review",
"ai_self_review",
@@ -135,13 +135,13 @@ mod tests {
// 节点类型(决策 a 步骤③:ai_self_review 独立节点类型)
let ai = dag.nodes.get("ai_self_review").expect("ai_self_review 存在");
assert_eq!(ai.node_type, "ai_self_review");
// 阶段3:gate=true 启用自审闸门(verdict=fail 阻断下游)
// gate=true 启用自审闸门(verdict=fail 阻断下游)
let gate = ai
.config
.get("gate")
.and_then(|v| v.as_bool())
.expect("ai_self_review config 应含 gate");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(阶段3 闸门)");
assert!(gate, "testing 模板 ai_self_review 应启用 gate:true(闸门)");
let human = dag.nodes.get("human_review").expect("human_review 存在");
assert_eq!(human.node_type, "human");
// 边方向:ai → human
+1 -1
View File
@@ -7,7 +7,7 @@
/// 内容图引用(README 内的架构图/截图等,喂 vision 用)。
/// 采样层只收集 alt+src,Phase 2 上线后由 commands 层读 base64 喂 vision。
/// 当前 ChatMessage.content:String(F-260614-05 未做)走纯文本降级,
/// 当前 ChatMessage.content:String(多模态未做)走纯文本降级,
/// 此结构仅为采样层留接口,不读 base64。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImageRef {
@@ -53,7 +53,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result<AiProviderRecord,
config: row.get("config")?,
created_at: row.get("created_at")?,
updated_at: row.get("updated_at")?,
// F-260614-04: enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// enabled/weight 列老库经 v19 迁移补建,DEFAULT 1 / DEFAULT 50。
// from_row 按 i32 取列值兼容(SQLite 无真 BOOLEAN),0→false/非0→true。
enabled: row.get::<_, i32>("enabled").unwrap_or(1) != 0,
// weight 读侧 clamp [0,100]:与 insert/update_full 落库的 `.min(100)` 对齐,
@@ -95,7 +95,7 @@ fn ai_tool_execution_from_row(row: &Row<'_>) -> std::result::Result<AiToolExecut
Ok(AiToolExecutionRecord {
id: row.get("id")?,
conversation_id: row.get("conversation_id")?,
// F-260619-04:message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// message_id 列老库经 v21 迁移补建。unwrap_or(None) 兜底:
// 新库空表直接有列;老库行 ALTER 后 NULL;极端情况(迁移未跑/手工删列)防御。
message_id: row.get("message_id").unwrap_or(None),
tool_call_id: row.get("tool_call_id")?,
@@ -124,7 +124,7 @@ impl_repo!(
let is_default = if rec.is_default { 1i32 } else { 0i32 };
// model_configs:Vec<ModelConfig> → JSON 字符串落 TEXT 列
let model_configs_json = serde_json::to_string(&rec.model_configs).unwrap_or_else(|_| "[]".into());
// F-260614-04: enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
// enabled/weight 落库(SQLite 无 BOOLEAN,i32 承载)。
let enabled_i = if rec.enabled { 1i32 } else { 0i32 };
let weight_i = rec.weight.min(100) as i32;
conn.execute(
@@ -323,6 +323,40 @@ impl AiToolExecutionRepo {
// AiConversationRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl AiConversationRepo {
/// 写入对话版本化快照 checkpoint(INSERT OR IGNORE,同 id 已存在则跳过)。
///
/// 参数化绑定(替代原调用方的 format! 拼 SQL + execute_batch),防 snapshot 含引号/
/// 特殊字符致注入或损坏。列对齐 conversation_checkpoints(id, conv_id, snapshot,
/// token_total, created_at)。
pub async fn insert_checkpoint(
&self,
id: &str,
conv_id: &str,
snapshot: &str,
token_total: i64,
created_at: &str,
) -> Result<()> {
let conn = self.conn.clone();
let id = id.to_string();
let conv_id = conv_id.to_string();
let snapshot = snapshot.to_string();
let created_at = created_at.to_string();
let _ = tokio::task::spawn_blocking(move || {
let guard = conn.blocking_lock();
guard
.execute(
"INSERT OR IGNORE INTO conversation_checkpoints \
(id, conv_id, snapshot, token_total, created_at) \
VALUES (?1, ?2, ?3, ?4, ?5)",
params![id, conv_id, snapshot, token_total, created_at],
)
.map_err(storage_err)
})
.await
.map_err(storage_err)??;
Ok(())
}
/// 清空对话消息内容(保留 conversation 记录本身,只清 messages JSON + 清零 token 计数)
///
/// "清空对话"语义:对话壳保留(侧栏仍可见,可继续在该对话内聊),仅清空历史消息。
@@ -420,7 +454,7 @@ mod tests {
use crate::models::AiProviderRecord;
use df_ai_core::model::{Capability, IntelligenceTier, Modality, ModelConfig};
/// model_configs DB roundtrip + 老库空兼容(F-01 阶段1)
/// model_configs DB roundtrip + 老库空兼容
#[tokio::test]
async fn ai_provider_model_configs_roundtrip_and_old_db_compat() {
let db = Database::open_in_memory().await.expect("open_in_memory");
+5 -5
View File
@@ -20,7 +20,7 @@ use super::{now_millis_str, storage_err, validate_column_name};
/// `knowledges` 表对应 `KnowledgeRecord` 15 个字段的列名(顺序与结构体一致)。
///
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口(CR-260615-03):集中一处定义,
/// 多处 `search`/`search_vector` 内联 COLS 串的 DRY 收口:集中一处定义,
/// 配合下方 `KNOWLEDGE_COL_COUNT` 断言,任一处加列漏改会被测试 `test_knowledge_cols_matches_record`
/// 立即捕获(`knowledge_from_row` 按 name 取列,SELECT 漏列会运行时 rusqlite 报错,故提前断言)。
///
@@ -38,7 +38,7 @@ const KNOWLEDGE_COLS_WITH_EMBEDDING: &str = concat!(
/// `ideas` 表对应 `IdeaRecord` 14 个字段的列名(顺序与结构体一致)。
///
/// 同 KNOWLEDGE_COLS 的列漂移防护(CR-260615-03):idea 表 INSERT/UPDATE/from_row 三处
/// 同 KNOWLEDGE_COLS 的列漂移防护:idea 表 INSERT/UPDATE/from_row 三处
/// 各写一份列名串,加列须三处同步(如 V24 加 related_ids 即三处齐改),漏一处
/// 只在运行时 rusqlite 报错(INSERT 列数与参数数不匹配 / from_row 取不到列)。集中一处
/// 定义 + 配合 `IDEA_COL_COUNT` 断言 + 测试 `test_idea_cols_matches_record`,加列漏改即捕获。
@@ -147,7 +147,7 @@ fn knowledge_event_from_row(row: &Row<'_>) -> std::result::Result<KnowledgeEvent
// ============================================================
// ============================================================
// IdeaQuery — 多条件查询入参(F-260621-02 status 下沉 + 关键词 + 排序 + 分页)
// IdeaQuery — 多条件查询入参(status 下沉 + 关键词 + 排序 + 分页)
// ============================================================
/// 灵感多条件查询入参。
@@ -256,7 +256,7 @@ impl_repo!(
// KnowledgeRepo 的整体更新已由 impl_repo! 宏统一生成的 update_full 提供。
impl IdeaRepo {
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页(F-260621-02)。
/// 多条件查询:动态 WHERE 拼接(status / keyword) + 白名单排序 + 分页。
///
/// 复用 `KnowledgeRepo::search` 的动态 WHERE 模式:if-let 分支按可选条件拼 SQL 片段,
/// 各分支化参数绑定到 `?N` 占位符。`order_by` 经 `validate_idea_order_by` 白名单校验后
@@ -925,7 +925,7 @@ mod tests {
use super::*;
use crate::db::Database;
// ---------- COLS 漂移防护(CR-260615-03) ----------
// ---------- COLS 漂移防护 ----------
/// KNOWLEDGE_COLS 列数须等于 KNOWLEDGE_COL_COUNT(任一处漂移:加列漏改 / 串错位 → 立即失败)。
/// `knowledge_from_row` 按 name 取列,SELECT 漏列会在运行时被 rusqlite 报错;此断言提前到测试期捕获。
+2 -3
View File
@@ -1,4 +1,4 @@
//! AI 消息 Repo — ai_messages 表(F-260619-03 消息拆分存储)
//! AI 消息 Repo — ai_messages 表(消息拆分存储)
//!
//! 每条 ChatMessage 一行的独立表,替代 `ai_conversations.messages` 整对话 JSON 列存。
//! 全专用方法(insert_batch / list_by_conversation / delete_range / update_status /
@@ -239,7 +239,6 @@ impl AiMessageRepo {
/// 全量重写对话的消息(单事务 DELETE + INSERT OR IGNORE,原子)。
///
/// F-260619-03 批次 B(save_conversation 写路径切 ai_messages)的核心方法:
/// 全量重写语义——以入参 records 为该对话的**唯一真相**,先删该 conv 全部旧行再批量插。
/// 单事务保证「删 + 插」原子,无中间空窗(reload 不会读到半删半插的中间态)。
///
@@ -472,7 +471,7 @@ mod tests {
assert_eq!(got[0].content, "替换后的结果", "其他消息不应被改");
}
// ---------- replace_conversation(F-260619-03 批次 B)----------
// ---------- replace_conversation ----------
/// replace_conversation 全量重写:删旧 + 插新原子,list 一致
#[tokio::test]
+1 -1
View File
@@ -12,7 +12,7 @@
//! - [`mod@conversation_repo`]:AiProviderRepo/AiConversationRepo/AiToolExecutionRepo
//! - [`mod@idea_repo`]:IdeaRepo/KnowledgeRepo/KnowledgeEventsRepo + 向量工具
//! - [`mod@idea_eval_repo`]:IdeaEvalRepo(灵感评估历史追加型审计表 idea_evaluations,V22)
//! - [`mod@message_repo`]:AiMessageRepo(F-260619-03 消息拆分存储,全专用方法不走宏)
//! - [`mod@message_repo`]:AiMessageRepo(消息拆分存储,全专用方法不走宏)
//!
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
+2 -2
View File
@@ -17,7 +17,7 @@ use super::impl_repo;
use super::{normalize_stored_path, now_millis_str, storage_err, validate_column_name};
// ============================================================
// 项目查询入参(F-260621-02 P2/P3 查询维度补全)
// 项目查询入参(P2/P3 查询维度补全)
// ============================================================
/// 项目列表查询条件(可选字段,全 None = 当前 list_active 全量行为)。
@@ -204,7 +204,7 @@ impl ProjectRepo {
.map_err(storage_err)?
}
/// 按条件查询未删除项目(F-260621-02 P2/P3:关键词搜索 + 排序 + 分页)。
/// 按条件查询未删除项目(P2/P3:关键词搜索 + 排序 + 分页)。
///
/// 复用 `KnowledgeRepo::search` 动态 WHERE 拼接模式:按可选字段 if-let 拼 SQL 子句 +
/// 分支化参数绑定。始终排除 `deleted_at IS NOT NULL`(软删),与 `list_active` 行为对齐。
+7 -7
View File
@@ -119,8 +119,8 @@ impl SettingsRepo {
pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
Some(match table {
"ideas" => &[
// IDEA-FIX-02: id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (对标 tasks 白名单 B-260616-16 同款防护,防篡改主键/伪造创建时间)
// id\created_at 不列入 — 主键与创建时间不可通过通用 update_field 改写
// (对标 tasks 白名单同款防护,防篡改主键/伪造创建时间)
"title", "description", "status", "priority", "score", "tags", "source",
"promoted_to", "ai_analysis", "scores", "related_ids", "updated_at",
],
@@ -141,15 +141,15 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// 否则破坏「review_rounds 唯一写入路径」收口、引入旁路写导致计数错乱。
"project_id", "title", "description", "priority", "branch_name",
"assignee",
// workflow_def_id / base_branch: 预留字段,阶段4 Git/workflow_defs 集成前无写入路径。
// workflow_def_id / base_branch: 预留字段,Git/workflow_defs 集成前无写入路径。
// 当前推进链用硬编码三模板(task_workflow_templates.rs,不建 workflow_defs 表,
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为阶段4 预留 +
// tasks.workflow_def_id 留 None),无任何代码写这两列。白名单列入仅为预留 +
// 允许手动/未来填充,勿判死代码删除。base_branch 同理(code kind 闸门接 git 前预留)。
"workflow_def_id", "base_branch",
// output_json:ai_execute 写产出 / ai_self_review 读产出自审 / human_review 展示对象
// (决策 a:task 中心,产出跟 task 走)。非状态机收口字段,合法可写。
"output_json",
// idea_id(F-260619-01 任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// idea_id(任务关联灵感,1对1 单向):任务可关联/解关联一条灵感,
// 非状态机收口字段,合法可写(idea_id 存在性由外键约束 + 上层校验兜底)。
"idea_id",
// 知识图谱 Phase 1 V29 三列(对标设计 §2.1):非状态机收口字段,合法可写。
@@ -160,7 +160,7 @@ pub fn allowed_columns_for(table: &str) -> Option<&'static [&'static str]> {
// 走专用方法 set_status_for_aggregation,不经通用 update_field。
"queue", "parent_id", "content_json",
"updated_at",
// TODO(B-260616-16): project_id 跨表存在性校验待 commands/task.rs 层补。
// TODO: project_id 跨表存在性校验待 commands/task.rs 层补。
// 通用 CRUD 层(db repo)只懂表/列语义,不持有跨表业务约束(查 projects 表存在性)。
// project_id 当前可在白名单内改写,合法目标存在性由上层命令层校验。
],
@@ -218,7 +218,7 @@ pub(crate) fn validate_column_name(field: &str, table: &str) -> Result<()> {
match allowed_columns_for(table) {
Some(cols) if cols.contains(&field) => Ok(()),
Some(_) => Err(Error::Storage(format!("表 {} 不允许的字段名: {}", table, field))),
// 未登记表保守拒绝(FR-S6: 原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐)
// 未登记表保守拒绝:原放行 Ok,若未来未登记表走通用查询路径,列名直进字符串拼接即 SQL 注入;与 is_allowed_column 的 None=>false 对齐
None => Err(Error::Storage(format!("表 {} 未登记列白名单,拒绝防注入", table))),
}
}
+4 -4
View File
@@ -47,7 +47,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result<TaskRecord, rusqlite::Err
}
// ============================================================
// 任务列表查询入参(F-260621-02 查询维度补全)
// 任务列表查询入参(查询维度补全)
// ============================================================
/// 任务列表动态查询入参。全可选,空 query = 等价当前全量行为(向后兼容)。
@@ -213,7 +213,7 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 原子推进任务状态(任务推进链 F-260616-02 唯一 status 写入路径)
/// 原子推进任务状态(任务推进链唯一 status 写入路径)
///
/// 下沉 SQL `WHERE id=? AND status=?expected` 做 CAS(Compare-And-Swap)防 TOCTOU:
/// 并发推进/旁路修改若已改 status,affected_rows==0,本方法返回 None,调用方
@@ -296,7 +296,7 @@ impl TaskRepo {
.map_err(storage_err)?
}
/// 动态条件列出未删除任务(F-260621-02 查询维度补全)。
/// 动态条件列出未删除任务(查询维度补全)。
///
/// 复用 KnowledgeRepo::search 的「动态 WHERE + 参数绑定」模式,但用累积式条件收集
/// (Vec<String> WHERE 子句 + Vec<rusqlite::Value> 参数)替代 if-let 二分支——
@@ -528,7 +528,7 @@ impl TaskRepo {
///
/// 本方法返回 `Vec<(status, count)>`(SQL GROUP BY 一次查询,数据量小 ~50 无压力),
/// 聚合规则的具体判定由调用方(commands 层)实现 —— 本层只提供原始计数,不持有
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标 B-260616-16 跨表校验下沉思路)。
/// 业务聚合逻辑(CRUD 层只懂表/列语义,对标跨表校验下沉思路)。
pub async fn count_children_by_status(
&self,
parent_id: &str,
+129 -18
View File
@@ -23,12 +23,12 @@ pub fn run(conn: &Connection) -> Result<()> {
// 迁移步骤链: 顺序执行,跳过已应用的版本(current_version < N 才跑)。
// 新增版本时,在此数组追加一项 (N, migrate_vN) 即可,无需改逻辑。
// V20 = F-260619-01(任务关联灵感 idea_id);V21 = 消息拆分存储 + audit message_id;
// V20 = 任务关联灵感 idea_id;V21 = 消息拆分存储 + audit message_id;
// V22 = 灵感评估历史持久化(idea_evaluations 追加型审计表);
// V23 = knowledges.embedding_status 列(嵌入失败可补偿重试);
// V24 = ideas.related_ids 列(灵感间关联关系持久化打底);
// V25 = idea_evaluations (idea_id, version) 唯一约束(评估版本并发重复兜底);
// V26 = F-260621-02 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
// V26 = 任务索引缺口补全(priority/assignee,对齐 idx_tasks_status 同类索引)。
// V27 = TD-260621-05 审批状态统一(ai_tool_executions.status executed→completed,
// 对齐 DTO 契约 audit/mod.rs:53 只列 completed + 前端 i18n auditLog.status 无 executed 键 +
// 治 AuditLog executed 记录显示错位蓝pending标签+raw"executed")。
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
let steps: [(i32, fn(&Connection) -> Result<()>); 37] = [
(1, migrate_v1),
(2, migrate_v2),
(3, migrate_v3),
@@ -82,6 +82,7 @@ pub fn run(conn: &Connection) -> Result<()> {
(34, migrate_v34),
(35, migrate_v35),
(36, migrate_v36),
(37, migrate_v37),
];
for (version, migrate_fn) in steps {
@@ -289,7 +290,7 @@ fn migrate_v14(conn: &Connection) -> Result<()> {
Ok(())
}
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数,F-260616-04)
/// V15: 幂等补 tasks.review_rounds 列(review 退回累计轮数)
///
/// 任务推进链状态机退回时累加:in_review→in_progress / testing→in_review 各 +1,
/// 由 advance_task(df-nodes::task_advance_node)原子写入。默认 0(从未退回过的任务)。
@@ -342,7 +343,7 @@ fn migrate_v17(conn: &Connection) -> Result<()> {
Ok(())
}
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置,F-01 阶段1)
/// V18: 幂等补 ai_providers.model_configs 列(模型能力配置)
///
/// 模型 4 维度(模态/能力/价格/智力)+ 路由控制配置 JSON 字符串。TEXT NULL 向后兼容:
/// 老库行默认 NULL,from_row 经 deserialize_model_configs 解析为空 Vec(配合 default_model 过渡)。
@@ -357,7 +358,7 @@ fn migrate_v18(conn: &Connection) -> Result<()> {
Ok(())
}
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(F-260614-04 多 Provider 负载均衡池)
/// V19: 幂等补 ai_providers.enabled + ai_providers.weight 列(多 Provider 负载均衡池)
///
/// - `enabled INTEGER NOT NULL DEFAULT 1`:provider 是否进入负载均衡池。
/// 老库行迁移后默认 1(所有现存 provider 默认启用,单 provider 路径零变化)。
@@ -373,21 +374,21 @@ fn migrate_v19(conn: &Connection) -> Result<()> {
"ALTER TABLE ai_providers ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1",
[],
)?;
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池,F-260614-04)");
tracing::info!("v19: 补建 ai_providers.enabled 列(多 Provider 负载均衡池)");
}
if !column_exists(conn, "ai_providers", "weight") {
conn.execute(
"ALTER TABLE ai_providers ADD COLUMN weight INTEGER NOT NULL DEFAULT 50",
[],
)?;
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池,F-260614-04)");
tracing::info!("v19: 补建 ai_providers.weight 列(多 Provider 负载均衡池)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [19])?;
tracing::info!("迁移 v19 完成");
Ok(())
}
/// V20:幂等补 tasks.idea_id 列(F-260619-01 任务关联灵感,1对1 单向)
/// V20:幂等补 tasks.idea_id 列(任务关联灵感,1对1 单向)
///
/// 任务可关联到一条灵感(任务→灵感单向),复用 projects.idea_id 模式
/// (REFERENCES ideas(id) 外键)。TEXT NULL 向后兼容:老库行默认 NULL,TaskRecord
@@ -400,7 +401,7 @@ fn migrate_v20(conn: &Connection) -> Result<()> {
"ALTER TABLE tasks ADD COLUMN idea_id TEXT REFERENCES ideas(id)",
[],
)?;
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感,F-260619-01)");
tracing::info!("v20: 补建 tasks.idea_id 列(任务关联灵感)");
}
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [20])?;
tracing::info!("迁移 v20 完成");
@@ -432,7 +433,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
// 1. 建 ai_messages 表(IF NOT EXISTS 幂等)
conn.execute_batch(V21_SQL)?;
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit,F-260619-04)
// 2. 幂等补 ai_tool_executions.message_id 列(消息级溯源 audit)
// 表存在性兜底:run() 正常流程下 V9 已先建该表,但测试/手动调用可能跳过 V9。
// 表不存在时跳过 ALTER(新库会由 V9_SQL 建表带 message_id 列;此处只补老库已有表)。
let tool_exec_table_exists: bool = conn
@@ -563,7 +564,7 @@ fn migrate_v21(conn: &Connection) -> Result<()> {
///
/// 不登记通用列白名单(allowed_columns_for):本表走专用 list_by_idea,
/// 宏生成的 query/update_field 未登记表会被 validate_column_name 保守拒绝
/// (FR-S6),与追加型审计语义一致(历史不改),不开放通用写路径。
/// 与追加型审计语义一致(历史不改),不开放通用写路径。
fn migrate_v22(conn: &Connection) -> Result<()> {
conn.execute_batch(V22_SQL)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [22])?;
@@ -660,7 +661,7 @@ fn migrate_v25(conn: &Connection) -> Result<()> {
Ok(())
}
/// V26:补建 tasks 表 priority/assignee 索引(F-260621-02 索引缺口)
/// V26:补建 tasks 表 priority/assignee 索引(索引缺口)
///
/// list_by_query 已支持 priority/assignee 过滤下推(TaskQuery.priority/assignee),
/// 但缺索引 → 数据量增长后全表扫描。补建索引对齐已有的 idx_tasks_status /
@@ -1115,6 +1116,25 @@ fn migrate_v36(conn: &Connection) -> Result<()> {
Ok(())
}
/// V37: conversation_checkpoints 表(对话版本化快照)
fn migrate_v37(conn: &Connection) -> Result<()> {
conn.execute_batch(
"CREATE TABLE IF NOT EXISTS conversation_checkpoints (\
id TEXT PRIMARY KEY,\
conv_id TEXT NOT NULL,\
snapshot TEXT NOT NULL,\
token_total INTEGER NOT NULL,\
label TEXT,\
created_at TEXT NOT NULL\
);\
CREATE INDEX IF NOT EXISTS idx_ck_conv_id \
ON conversation_checkpoints(conv_id, created_at DESC);",
)?;
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [37])?;
tracing::info!("迁移 v37 完成: 建 conversation_checkpoints 表");
Ok(())
}
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
///
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
@@ -1199,7 +1219,7 @@ CREATE TABLE IF NOT EXISTS tasks (
priority INTEGER NOT NULL DEFAULT 2,
branch_name TEXT,
assignee TEXT,
-- F-260619-01 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。
-- 任务关联灵感(1对1 单向,复用 projects.idea_id 模式)。老库由 V20 迁移补列。
idea_id TEXT REFERENCES ideas(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
@@ -1245,7 +1265,7 @@ CREATE TABLE IF NOT EXISTS node_executions (
-- 索引
CREATE INDEX IF NOT EXISTS idx_tasks_project_id ON tasks(project_id);
CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
-- V26 补建(F-260621-02 索引缺口):priority/assignee 过滤下推索引,新库一次性建;
-- V26 补建(索引缺口):priority/assignee 过滤下推索引,新库一次性建;
-- 老库由 migrate_v26 CREATE INDEX IF NOT EXISTS 补建,两边索引定义须一致。
CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority);
CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee);
@@ -1375,7 +1395,7 @@ CREATE TABLE IF NOT EXISTS ai_tool_executions (
decided_by TEXT
);
-- F-260619-03 消息拆分存储:每条 ChatMessage 一行的独立表。
-- 消息拆分存储:每条 ChatMessage 一行的独立表。
-- 与 V21 迁移建表 SQL 镜像(V21 用于老库 ALTER,此处给新库直接建最终态)。
-- 改动须两边同步(V21_SQL 见下方)。
CREATE TABLE IF NOT EXISTS ai_messages (
@@ -1431,7 +1451,7 @@ CREATE TABLE IF NOT EXISTS app_settings (
";
// ============================================================
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03)
// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态)
// ============================================================
#[cfg(test)]
@@ -1653,7 +1673,7 @@ mod tests {
}
// ============================================================
// V20 迁移幂等安全(F-260619-01 任务关联灵感)
// V20 迁移幂等安全(任务关联灵感)
// ============================================================
/// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。
@@ -1800,4 +1820,95 @@ mod tests {
.unwrap();
assert_eq!(v, 27);
}
// ============================================================
// 全量迁移测试 — 新库从零跑完整 V1-V37 路径
// ------------------------------------------------------------
// 目的:某 migrate_vN 的 SQL 手滑写错(列名/类型/缺索引/缺表)只能等运行时暴露,
// 此测试一次性覆盖全部迁移路径。任何一条迁移 SQL 写错、列名拼错、缺建表
// 都会被这里捕获,不必等到应用启动真机才报错。
// ============================================================
/// 辅助:断言表存在且 PRAGMA table_info 返回的列数 > 0(即表非空、有列定义)。
fn assert_table_has_columns(conn: &Connection, table: &str) {
let count: i64 = conn
.query_row(
&format!("SELECT COUNT(*) FROM pragma_table_info('{}')", table),
[],
|r| r.get(0),
)
.unwrap_or_else(|e| panic!("查 {} 列信息失败: {}", table, e));
assert!(
count > 0,
"表 {} 应存在且至少 1 列(实际 {} 列)—— 可能 migrate_vN 建表 SQL 写错或被遗漏",
table,
count
);
}
/// 全量迁移:新库从零跑完 V1-V37,验证关键表齐全 + 列数 > 0 + 关键列存在。
///
/// 覆盖至少:task / ai_conversations / ai_messages / ai_tool_executions /
/// conversation_checkpoints / ai_providers / projects / ideas。
/// 抽查关键列:ai_providers.enabled/weight、conversation_checkpoints.snapshot、
/// tasks.idea_id(这些列由不同 vN 加,任一漏加此处失败)。
#[tokio::test]
async fn test_full_migration_on_fresh_db() {
// 用 Database::open_in_memory 打开新库,内部自动跑 migrations::run() 全量迁移
let db = crate::db::Database::open_in_memory()
.await
.expect("新库应能跑完全量迁移");
let conn = db.conn();
let conn = conn.lock().await;
// 1. 关键表存在且列数 > 0
for table in [
"tasks",
"ai_conversations",
"ai_messages",
"ai_tool_executions",
"conversation_checkpoints",
"ai_providers",
"projects",
"ideas",
] {
assert_table_has_columns(&conn, table);
}
// 2. 抽查关键列存在(跨多个 vN 加的列,任一漏加此处失败)
// ai_providers.enabled / weight(V19 ALTER)
assert!(
column_exists(&conn, "ai_providers", "enabled"),
"ai_providers.enabled 列缺失(V19 加)"
);
assert!(
column_exists(&conn, "ai_providers", "weight"),
"ai_providers.weight 列缺失(V19 加)"
);
// conversation_checkpoints.snapshot(V37 建表)
assert!(
column_exists(&conn, "conversation_checkpoints", "snapshot"),
"conversation_checkpoints.snapshot 列缺失(V37 建表)"
);
// tasks.idea_id(V1 建表已带,V20 老库兜底——新库应有)
assert!(
column_exists(&conn, "tasks", "idea_id"),
"tasks.idea_id 列缺失(V1 建表已带)"
);
// 3. schema_version 应推进到 37(全量迁移成功落版本号)
let max_version: i64 = conn
.query_row(
"SELECT COALESCE(MAX(version), 0) FROM schema_version",
[],
|r| r.get(0),
)
.expect("查 schema_version 应成功");
assert_eq!(
max_version, 37,
"全量迁移后 schema_version 应为 37(实际 {}),说明某条 migrate_vN 链路断在中间",
max_version
);
}
}
+7 -7
View File
@@ -94,7 +94,7 @@ pub struct TaskRecord {
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default)]
pub output_json: Option<String>,
/// 关联灵感 ID(F-260619-01,1对1 单向,任务→灵感)。
/// 关联灵感 ID(1对1 单向,任务→灵感)。
/// 复用 projects.idea_id 模式(REFERENCES ideas(id) 外键),可空(任务可不关联灵感)。
/// 老任务无 idea_id → None。#[serde(default)] 兼容旧前端 JSON(无该字段时为 None)。
#[serde(default)]
@@ -291,7 +291,7 @@ pub struct AiProviderRecord {
pub base_url: String,
pub default_model: String,
pub models: Option<String>, // JSON array of model names
/// 模型能力配置数组(F-01 阶段1,4 维度 + 路由控制)。
/// 模型能力配置数组(4 维度 + 路由控制)。
/// DB TEXT 列存 JSON 字符串,from_row 经 deserialize_model_configs 解析。
/// 向后兼容:老库 NULL/空/老字符串数组 → 空 Vec 或转默认 ModelConfig。default_model 保留过渡。
#[serde(default, deserialize_with = "deserialize_model_configs")]
@@ -300,11 +300,11 @@ pub struct AiProviderRecord {
pub config: Option<String>, // JSON extra config
pub created_at: String,
pub updated_at: String,
/// provider 是否进入负载均衡池(F-260614-04)。false = 仅作为配置存在,不参与主链路由/选池。
/// provider 是否进入负载均衡池。false = 仅作为配置存在,不参与主链路由/选池。
/// 老库迁移默认 1(单 provider 路径零变化)。
#[serde(default = "default_enabled")]
pub enabled: bool,
/// provider 在负载均衡池中的选择权重(0-100,F-260614-04)。高权重优先被选为主;
/// provider 在负载均衡池中的选择权重(0-100)。高权重优先被选为主;
/// 同权重时退化近似轮询。老库迁移默认 50。
#[serde(default = "default_weight")]
pub weight: u32,
@@ -371,9 +371,9 @@ pub struct AiConversationRecord {
pub struct AiToolExecutionRecord {
pub id: String,
pub conversation_id: Option<String>,
/// 消息级溯源:工具调用所属的 ChatMessage.id(F-260619-04)。
/// 消息级溯源:工具调用所属的 ChatMessage.id。
/// NULL = 老库行 / 消息级溯源未启用期的记录 / 无法关联的调用。
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入(P1 接入,P0 只建列)。
/// 升级后,audit 写入从 ContextManager 取当前 assistant 消息 id 填入。
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message_id: Option<String>,
pub tool_call_id: String,
@@ -387,7 +387,7 @@ pub struct AiToolExecutionRecord {
pub decided_by: Option<String>, // human/auto
}
/// 消息记录(ai_messages 表,F-260619-03 消息拆分存储)。
/// 消息记录(ai_messages 表,消息拆分存储)。
///
/// 每条 ChatMessage 一行,替代 `ai_conversations.messages` 的整对话 JSON 列存。
/// 主键 `id` = ChatMessage.id(构造时 ULID 风格生成),(conversation_id, seq) UNIQUE
+94 -16
View File
@@ -1,4 +1,4 @@
//! FR-S1 api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
//! api_key 密钥管理 — 真实密钥存 OS keyring,DB `api_key` 列迁移后存空串。
//!
//! **下沉层(方案 B,2026-06-16)**:原位于 `src-tauri/src/commands/ai/secret.rs`,
//! 下沉纯密钥逻辑(get/set/delete/resolve/ensure/migrate + failcount sidecar)到 df-storage,
@@ -67,7 +67,7 @@ fn write_failcounts(map: &HashMap<String, u32>) {
text.push('\n');
}
if let Err(e) = fs::write(failcount_path(), text) {
tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e);
tracing::debug!("[密钥迁移] 迁移失败计数文件写入失败(忽略): {}", e);
}
}
@@ -114,22 +114,55 @@ pub async fn get_provider_secret_async(id: String) -> Option<String> {
}
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
///
/// **不静默压空**:keyring 无记录/读取故障(None 已合并 keyring Err)时,这里**不**用 `unwrap_or_default()`
/// 静默吞成空串 —— 那会让调用方拿空 api_key 发请求吃 401,错误伪装成「密钥无效」且无线索。改为
/// warn 留痕(区分「真正未配置密钥」与「keyring 后端故障」),仍返空串交由下游 `ensure_resolved_key`
/// 早失败给出用户可读错误 —— 签名不变,调用方零改动。
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key.clone();
}
get_provider_secret(&record.id).unwrap_or_default()
match get_provider_secret(&record.id) {
Some(k) => k,
None => {
tracing::warn!(
"[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
record.name, record.id
);
String::new()
}
}
}
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
///
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
///
/// **不静默压空**:同同步版,keyring 无记录/读取故障时 warn 留痕(不吞成空串致 401 难定位),
/// 仍返空串交由下游 `ensure_resolved_key` 早失败给用户可读错误 —— 签名不变,调用方零改动。
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
if !record.api_key.is_empty() {
return record.api_key;
}
get_provider_secret_async(record.id).await.unwrap_or_default()
// 先取 id/name 再 await,避免 record 部分移动后无法在 warn 中引用。
let id = record.id.clone();
let name = record.name.clone();
match get_provider_secret_async(id.clone()).await {
Some(k) => k,
None => {
tracing::warn!(
"[密钥解析] provider {} (id={}) 系统钥匙串无密钥或读取故障 —— \
下游 ensure_resolved_key 将报「密钥缺失」。排查:1) 设置中是否保存过密钥;\
2) OS 钥匙串后端是否可用(Win Credential Manager / macOS Keychain / Linux Secret Service)",
name, id
);
String::new()
}
}
}
/// 写入密钥到 keyring(覆盖)
@@ -172,14 +205,14 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
let n = record_migration_fail(&p.id);
if n >= MIGRATION_FAIL_THRESHOLD {
tracing::warn!(
"[FR-S1] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
"[密钥迁移] provider {} keyring 迁移已连续失败 {} 次,明文 api_key 长期滞留 SQLite 文件(无加密)。\
建议:1) 确认 OS 钥匙串可用(Win Credential Manager / macOS Keychain);\
2) keyring 后端异常时排查对应平台后端;3) 必要时手动在设置中重新保存密钥触发写入",
p.id, n
);
} else {
tracing::warn!(
"[FR-S1] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
"[密钥迁移] keyring 迁移失败 {} (累计 {}/{},保留明文下次重试): {}",
p.id, n, MIGRATION_FAIL_THRESHOLD, e
);
}
@@ -188,29 +221,52 @@ pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result
let pid = p.id.clone();
p.api_key.clear();
if let Err(e) = repo.insert(p).await {
tracing::warn!("[FR-S1] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
tracing::warn!("[密钥迁移] 迁移后清空 DB api_key 失败 {}: {}", pid, e);
}
// 迁移成功 → 清零该 provider 的失败计数(下次若再出现失败从 1 重新累计)
clear_migration_failcount(&pid);
migrated += 1;
}
if migrated > 0 {
tracing::info!("[FR-S1] {} 条 provider 密钥迁移至 OS keyring", migrated);
tracing::info!("[密钥迁移] {} 条 provider 密钥迁移至 OS keyring", migrated);
}
Ok(migrated)
}
/// 校验已解析的密钥是否可用:空(含纯空白)→明确错误信息,非空→Ok。
/// 校验已解析的密钥是否可用并归一化:空(含纯空白)→明确错误信息;非空→返回归一化后的 String。
///
/// 归一化 = trim → 剥首尾配对引号(`"`/`'`)→ 再 trim。覆盖用户粘贴脏 key 的常见场景:
/// 复制带前后引号/换行/全角空格/尾部空白,校验能过但原样发 provider 致 401 误报"API Key 无效"。
/// 调用方应用返回值(归一化后的 key)替代原 resolved 传给 provider。
///
/// 用于消费点(build_provider 前)早失败,避免空 key 发请求吃 401,错误伪装成"API Key 无效"。
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<(), String> {
pub fn ensure_resolved_key(provider_name: &str, resolved: &str) -> Result<String, String> {
// 先 trim 判空(纯空白视为无密钥,防粘贴时只有空格)。
if resolved.trim().is_empty() {
Err(format!(
return Err(format!(
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
provider_name
))
} else {
Ok(())
));
}
// 归一化:trim → 剥首尾引号(成对,支持 ""xxx"" / ''xxx'' 多层)→ 再 trim。
let mut cleaned = resolved.trim().to_string();
while cleaned.len() >= 2 {
let first = cleaned.chars().next().unwrap();
let last = cleaned.chars().last().unwrap();
if (first == '"' || first == '\'') && first == last {
cleaned = cleaned[first.len_utf8()..cleaned.len() - last.len_utf8()].trim().to_string();
} else {
break;
}
}
// 剥引号后可能变空(如粘贴仅一对引号)→ 视为无密钥。
if cleaned.is_empty() {
return Err(format!(
"未读取到「{}」的 API 密钥(系统钥匙串无记录或已损坏),请在设置中重新填写并保存",
provider_name
));
}
Ok(cleaned)
}
#[cfg(test)]
@@ -230,7 +286,7 @@ mod tests {
#[test]
fn ensure_resolved_key_accepts_nonempty() {
assert!(ensure_resolved_key("GLM", "sk-abc").is_ok());
assert_eq!(ensure_resolved_key("GLM", "sk-abc").unwrap(), "sk-abc");
}
#[test]
@@ -239,9 +295,31 @@ mod tests {
assert!(err.contains("我的提供商"), "错误信息应含 provider 名便于定位");
}
#[test]
fn ensure_resolved_key_trims_surrounding_whitespace() {
// 粘贴带前后空白/换行 → 归一化为干净 key
assert_eq!(ensure_resolved_key("GLM", " sk-abc \n").unwrap(), "sk-abc");
}
#[test]
fn ensure_resolved_key_strips_surrounding_quotes() {
// 粘贴带前后引号(双引号/单引号,多层嵌套)→ 剥引号
assert_eq!(ensure_resolved_key("GLM", "\"sk-abc\"").unwrap(), "sk-abc");
assert_eq!(ensure_resolved_key("GLM", "'sk-abc'").unwrap(), "sk-abc");
assert_eq!(ensure_resolved_key("GLM", "\"\"sk-abc\"\"").unwrap(), "sk-abc");
assert_eq!(ensure_resolved_key("GLM", " \"sk-abc\"\n").unwrap(), "sk-abc");
}
#[test]
fn ensure_resolved_key_rejects_only_quotes() {
// 仅一对引号(剥后为空)→ 视为无密钥
assert!(ensure_resolved_key("GLM", "\"\"").is_err());
assert!(ensure_resolved_key("GLM", " '' ").is_err());
}
#[test]
fn resolve_prefers_db_when_non_empty() {
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(FR-S1 兼容未迁移老库)
// DB api_key 非空 → 直接返回 DB 值,不触发 keyring(兼容未迁移老库)
// 纯逻辑路径,不碰 OS keyring,CI 任意 OS 安全。
let rec = AiProviderRecord {
id: "t1".into(), name: "t".into(), provider_type: "openai_compat".into(),
+1 -1
View File
@@ -84,7 +84,7 @@ pub trait TunnelClient: Send + Sync {
/// 发送原始事件(桌面端 → 云后端 → 小程序),payload 为 AiChatEvent 序列化 JSON Value
///
/// Phase3 阶段2(D2 全 19 变体透传):EventBus subscriber 把 AiChatEvent Value 经此方法
/// D2 全 19 变体透传:EventBus subscriber 把 AiChatEvent Value 经此方法
/// 透传(不经 TunnelEvent 强类型子集)。与 [`send_event`](TunnelClient::send_event) 伴行,
/// 后者保留作高频子集快捷方式(D5 强类型保留)。非阻塞入队,连接断开返 NotConnected。
async fn send_raw_event(&self, payload: serde_json::Value) -> Result<()>;
+3 -3
View File
@@ -4,7 +4,7 @@ use serde::{Deserialize, Serialize};
use crate::types::{ExecutionId, NodeId};
/// 人工审批选择类型(F-260615-01)
/// 人工审批选择类型
/// - Single: 单选(decision 单值),缺省值,向后兼容现有调用方
/// - Multiple: 多选(decisions 数组)
///
@@ -100,7 +100,7 @@ pub enum WorkflowEvent {
title: String,
description: String,
options: Vec<String>,
/// F-260615-01: 选择类型,缺省 Single(向后兼容)
/// 选择类型,缺省 Single(向后兼容)
#[serde(default)]
select_type: SelectType,
},
@@ -109,7 +109,7 @@ pub enum WorkflowEvent {
execution_id: ExecutionId,
node_id: NodeId,
decision: String,
/// F-260615-01: 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
/// 多选结果(Single 模式长度=1,Multiple 模式长度≥1)
#[serde(default)]
decisions: Vec<String>,
comment: Option<String>,
+2 -2
View File
@@ -6,7 +6,7 @@ use std::collections::HashMap;
/// DAG 定义 — 可序列化/反序列化,用于持久化和模板
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct DagDef {
// BUG-260621-01: nodes/edges 加 #[serde(default)]。
// nodes/edges 加 #[serde(default)]。
// 前端 run_workflow 推进链传空 dag `{}`(设计意图:空 dag + target_status →
// workflow.rs:91 dag.nodes.is_empty() 进 template_for 自动选模板)。原无 default
// 时,`{}` 在 Tauri IPC 入口反序列化 DagDef 即报 `missing field nodes`,挡在选模板
@@ -91,7 +91,7 @@ impl Default for DagDef {
mod tests {
use super::*;
// BUG-260621-01: 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
// 前端 run_workflow 推进链传空 dag `{}`,serde 必须能反序列化
// 成空 DagDef(非报 missing field),后续 workflow.rs:91 dag.nodes.is_empty()
// 进 template_for 选模板。nodes/edges 加 #[serde(default)] 前此用例会失败。
#[test]
+20 -20
View File
@@ -98,27 +98,27 @@ impl DagExecutor {
let mut any_edge_passed = false; // 任一入边放行(含无条件边)
if let Some(preds) = adjacency_in.get(node_id) {
for (pred_id, cond) in preds {
if let Some(out) = outputs.get(pred_id) {
if eval_conditions {
if let Some(cond) = cond {
has_any_cond_edge = true;
// 以 source output.data 为 context 求值;失败保守 false
let passed = ConditionEngine::evaluate(cond, &out.data)
.unwrap_or(false);
if passed {
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
} else {
// 无 condition 的入边:无条件放行(必收集)
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
} else {
// flag 关:condition 完全忽略,旧行为全收集
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
let Some(out) = outputs.get(pred_id) else {
continue;
};
// flag 关:condition 完全忽略,旧行为全收集;
// flag 开 + 无 condition 入边:无条件放行(必收集);
// flag 开 + 有 condition 入边:以 source output.data 为 context 求值,
// 失败保守 false(对齐引擎语义)。
let passed = match (eval_conditions, cond) {
(false, _) => true,
(true, None) => true,
(true, Some(c)) => {
ConditionEngine::evaluate(c, &out.data).unwrap_or(false)
}
};
if eval_conditions && cond.is_some() {
has_any_cond_edge = true;
}
if passed {
inputs.insert(pred_id.clone(), out.clone());
any_edge_passed = true;
}
}
} else {
+55 -12
View File
@@ -41,13 +41,21 @@ impl StateMachine {
}
/// 获取节点状态
///
/// 锁中毒时降级返回 `NodeStatus::Pending`(保守默认:视为未启动,
/// 执行器不会误判为已完成/失败),并 `tracing::error!` 记录,不 panic。
pub fn get(&self, node_id: &NodeId) -> NodeStatus {
self.states
.lock()
.expect("状态机锁中毒")
.get(node_id)
.cloned()
.unwrap_or(NodeStatus::Pending)
match self.states.lock() {
Ok(states) => states.get(node_id).cloned().unwrap_or(NodeStatus::Pending),
Err(poisoned) => {
tracing::error!(
"状态机锁中毒,get({}) 降级返回 Pending:{}",
node_id,
poisoned
);
NodeStatus::Pending
}
}
}
/// 判断状态转换是否合法
@@ -61,8 +69,23 @@ impl StateMachine {
}
/// 状态转换 — 校验合法性后更新,非法转换返回错误
///
/// 锁中毒时降级返回 `Err`(携带「状态机锁中毒」上下文)并 `tracing::error!` 记录,
/// 由调用方决定如何处理(通常是 `set_running`/`set_completed`/`set_failed`
/// 把 Err 上抛 → 节点执行流捕获后置 Failed),不 panic 拖垮 runtime。
pub fn transition(&self, node_id: NodeId, target: NodeStatus) -> anyhow::Result<()> {
let mut states = self.states.lock().expect("状态机锁中毒");
let mut states = match self.states.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!(
"状态机锁中毒,transition({}, {}) 降级返 Err:{}",
node_id,
target.as_str(),
poisoned
);
anyhow::bail!("状态机锁中毒,节点 {} 状态转换失败", node_id);
}
};
let current = states
.get(&node_id)
.cloned()
@@ -104,15 +127,35 @@ impl StateMachine {
///
/// 注:原 set_waiting/set_skipped 同为旁路置位但全仓零调用,已删除。
pub fn set_cancelled(&self, node_id: NodeId) {
self.states
.lock()
.expect("状态机锁中毒")
.insert(node_id, NodeStatus::Cancelled);
match self.states.lock() {
Ok(mut states) => {
states.insert(node_id, NodeStatus::Cancelled);
}
Err(poisoned) => {
tracing::error!(
"状态机锁中毒,set_cancelled({}) 降级丢弃取消信号:{}",
node_id,
poisoned
);
}
}
}
/// 获取所有状态快照(clone 返回,调用方持独立副本)
///
/// 锁中毒时降级返回空 HashMap(调用方遍历视为无已完成节点,保守安全),
/// 并 `tracing::error!` 记录,不 panic。
pub fn snapshot(&self) -> HashMap<NodeId, NodeStatus> {
self.states.lock().expect("状态机锁中毒").clone()
match self.states.lock() {
Ok(states) => states.clone(),
Err(poisoned) => {
tracing::error!(
"状态机锁中毒,snapshot() 降级返回空 HashMap:{}",
poisoned
);
HashMap::new()
}
}
}
/// 检查节点是否被取消
@@ -0,0 +1,732 @@
# 上下文管理演进 — 任务技术设计
> 创建: 2026-07-20 | 状态: 设计阶段 | 关联文档: `上下文管理演进与发散思考-2026-07-20.md`
---
## 目录
- [T2 工具命名空间(详细设计)](#t2-工具命名空间详细设计)
- [T4 工作流 DAG 注入(详细设计)](#t4-工作流-dag-注入详细设计)
- [T3 L3 结构化摘要(设计概要)](#t3-l3-结构化摘要设计概要)
---
## T2 工具命名空间(详细设计)
### 现有流程 vs 新流程
```
现有流程:
AiToolRegistry.execute(name, args)
→ 工具执行 → 全量结果 String
→ push(ChatMessage::tool_result(全量内容)) ← 不管大小,全塞进消息队列
→ build_for_request 时,大 tool_result 撑爆预算
→ 压缩/裁剪 → 丢失细节
新流程:
AiToolRegistry.execute(name, args)
→ 工具执行 → 全量结果 String
→ if should_use_namespace(全量内容)
→ store_to_namespace(key, 全量内容) ← 存入独立存储
→ push(ChatMessage::tool_result(引用路径)) ← 主队列只插引用
else
→ push(ChatMessage::tool_result(全量内容)) ← 小结果不动
→ build_for_request 时,引用只占 ~20 tokens
→ 压缩不影响 namespace 内容
→ 模型通过引用路径按需读取(或系统自动补全)
```
### 数据结构
```rust
// crates/df-ai/src/namespace_store.rs
/// namespace 引用路径格式: "namespace://tool_name/args_hash"
/// 例: "namespace://read_file/a1b2c3d4"
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
/// 落入 namespace 的字节阈值(> 2048 bytes)
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
/// 落入 namespace 的行数阈值(> 50 行)
pub const NAMESPACE_LINE_THRESHOLD: usize = 50;
/// namespace 存储
///
/// 内存中为 HashMap,提供常规 CRUD;
/// 外部可注入持久化实现(接 SQLite / 内存 fallback)。
/// 设计为独立于 ContextManager 的结构——不被压缩/裁剪影响。
pub struct NamespaceStore {
/// key = 引用路径 hash, value = 原始工具结果
entries: HashMap<String, NamespaceEntry>,
/// 总字节数上限(防内存爆炸,超限淘汰最旧条目)
max_bytes: usize,
current_bytes: usize,
}
pub struct NamespaceEntry {
pub key: String, // 完整引用路径
pub tool_name: String,
pub args: serde_json::Value,
pub content: String, // 原始工具执行结果
pub content_summary: String, // extract_key_info 后的摘要(供 LLM 预览)
pub created_at: Instant,
pub access_count: u64,
}
```
### 关键决策点
#### 决策 1:何时进 namespace
```rust
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool {
// 1. 明确标记为"大结果"的工具(read_file / list_directory 几乎总是大)
if is_always_large_tool(tool_name) {
return true;
}
// 2. 按大小阈值判定
content.len() > NAMESPACE_BYTE_THRESHOLD
|| content.lines().count() > NAMESPACE_LINE_THRESHOLD
}
fn is_always_large_tool(tool_name: &str) -> bool {
matches!(tool_name, "read_file" | "list_directory" | "grep" | "diff_files")
}
```
#### 决策 2:模型看到什么
LLM 在 tool_result 消息中看到的不是全量内容,而是:
```
当前(小结果原样,大结果原样):
[tool_result] 342 行文件内容...(占 2000 tokens)
新流程(引用路径 + 摘要预览):
[tool_result] <namespace://read_file/a1b2c3>
[tool_result]
文件: config.rs
行数: 342
关键节点: timeout:15 (行15), pool_size:10 (行42)
```
**LLM 的理解能力验证**:如果模型需要读取完整内容,system prompt 需说明:
```
[上下文说明]
当工具结果以 <namespace://path> 格式返回时,表示完整内容已存储。
如果你需要查看完整内容,回复 /read_namespace path
系统将自动拉取完整内容替换当前引用。
```
或自动更激进——当 LLM 的回复引用某 namespace 路径时,系统在下一轮自动拉取:
```
LLM: "config.rs 第 15 行的 timeout 需要改"
系统检测: /read_namespace 触发 → 从 namespace 拉取 config.rs 完整内容
→ 下轮 tool_result 消息中替换为完整内容
→ LLM 看到原文,精确引用行号
```
#### 决策 3:持久化与生命周期
```rust
impl NamespaceStore {
/// 存入 namespace
pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String {
let summary = extract_key_info(content); // 复用现有 extract_key_info
let entry = NamespaceEntry {
key: key.to_string(),
tool_name: tool_name.to_string(),
args: args.clone(),
content: content.to_string(),
content_summary: summary,
created_at: Instant::now(),
access_count: 0,
};
self.current_bytes += content.len();
// 超限淘汰:从最旧开始删
while self.current_bytes > self.max_bytes {
// ... LRU 淘汰
}
format!("{}{}/{}", NAMESPACE_REF_PREFIX, tool_name, key)
}
/// 读取完整内容
pub fn read(&mut self, full_path: &str) -> Option<&str> {
let key = self.parse_key(full_path)?;
let entry = self.entries.get_mut(&key)?;
entry.access_count += 1;
Some(entry.content.as_str())
}
/// 读取摘要(用于自动补全判断)
pub fn read_summary(&self, full_path: &str) -> Option<&str> {
let key = self.parse_key(full_path)?;
self.entries.get(&key).map(|e| e.content_summary.as_str())
}
}
```
### 接线点:process_tool_calls
改动点位于 `audit/mod.rs::process_tool_calls`,工具执行完成后的 push 路径:
```rust
// current (audit/mod.rs ~380行附近)
pub(crate) async fn process_tool_calls(..., conv_id: &str) -> usize {
// ... 现有审批/执行逻辑 ...
for (index, draft) in tc_list {
// ... 执行工具 ...
let result = tools_arc.execute(&draft.name, &draft.args).await;
// ★ 新增:namespace 判断
let content = if should_use_namespace(&result, &draft.name) {
let session = session_arc.lock().await;
let ns = &mut session.namespace_store; // AiSession 新增字段
let ref_path = ns.store(&draft.name, &draft.args, &result);
ref_path // ← 主队列只推引用路径
} else {
result // ← 小结果原样推
};
// 后续 push 不变
let msg = ChatMessage::tool_result(draft.tool_call_id.clone(), &content);
session.conv(conv_id).messages.push(msg);
}
}
```
### AiSession 新增字段
```rust
// src-tauri/src/commands/ai/mod.rs 或 state.rs
pub struct AiSession {
pub conversations: HashMap<String, AiConversation>,
pub current_conv: Option<String>,
pub generating: HashSet<String>,
// ★ 新增
pub namespace_store: NamespaceStore,
}
```
### 边界情况推演
#### 情况 1:模型不理解为 namespace 引用
```
LLM 收到 [tool_result] <namespace://read_file/a1b2c3>
LLM 回复: "读了 config.rs,但我不知道内容是什么"
→ 用户体验差
```
**对策**:引用路径不是结束。在 `process_tool_calls` push 引用时,额外 push 一条 assistant 消息或修改 tool_result 内容格式:
```
[tool_result] 文件 config.rs (342 行)
| 关键行: timeout:15 (行15), pool_size:10 (行42)
| 完整内容: <namespace://read_file/a1b2c3>
```
即:`extract_key_info(已有)+ 引用路径` 混合格式。LLM 可以靠摘要感知文件内容,只有需要精确行号时才触发 `/read_namespace`。
#### 情况 2:同一文件在 2 轮内被多次引用
```
第 3 轮: read_file("config.rs") → namespace 存 342 行 → 引用
第 5 轮: 模型需要第 15 行 → /read_namespace → 系统拉取
```
第 5 轮拉取后,第 6 轮应该做什么?
**方案 A(推荐)**:拉取后仅在**当轮** `tool_result` 替换为完整内容。下一轮重新压缩时,如果内容大再次进入 namespace。
```
第 5 轮: [tool_result] config.rs 342 行全文(/read_namespace 触发的)
第 6 轮: 压缩 → config.rs 内容被压缩掉 → 正常
第 7 轮: 模型再次需要 → 再次 /read_namespace → namespace 还在
```
**方案 B**:拉取后一直保留在消息队列中。
→ 不推荐,回到了 tool_result 膨胀的老路。
#### 情况 3:namespace 内存爆炸
```
最大对话: 500 轮,平均每轮存 2 个 namespace 条目,每条约 3k bytes
→ 500 × 2 × 3k = 3MB
```
设 `max_bytes = 10MB`(宽松上限),最旧条目自动淘汰。淘汰后如果有模型再次引用:
```
LLM: /read_namespace namespace://read_file/a1b2c3
系统: 条目已淘汰 → 重新执行工具 → 重新 namespace 存储 → 返回内容
```
等价于「cache miss」,对用户透明。
#### 情况 4:DB 持久化需不需要存 namespace
**不需要**。namespace 是运行时缓存优化,不是持久化真相源。全量消息已在 `save_conversation` 写入 `ai_messages` 表(uncompressed),压缩后的摘要也在 system 消息中。namespace 淘汰后,可通过 DB 重新构建,但需要保证:
```rust
// save_conversation 中
if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
// 写 DB 前将引用替换回原始内容(从 namespace 中取)
// 替代方案:DB 也存引用路径,恢复时从 namespace 重建
}
```
**建议**:DB 存引用路径。恢复时 namespace 可能已淘汰,此时:
1. 重新执行工具(不可行——工具可能有副作用)
2. DB 保留原文(`save_conversation` 时展开引用)
**结论**:DB 存原文。save 时 namespace 条目肯定存在(刚执行完),展开引用写入 DB。恢复时直接读 DB 原文,namespace 只服务于运行时。
```rust
// save_conversation 中展开引用
let content = if msg.content.starts_with(NAMESPACE_REF_PREFIX) {
namespace_store.read(&msg.content)
.unwrap_or(&msg.content) // 兜底:用引用路径自身(可能性低)
} else {
&msg.content
};
record.content = content.to_string();
```
### 推演:T2 在 T-heavy 场景中的行为
```
第 3 轮: read_file("config.rs") → 342 行
应进 namespace ✓
主队列: [tool_result] 文件 config.rs (342行), 行15:timeout, 行42:pool
主队列 token: ~80(→原来 2000)
节省: 1920 tokens
第 4-7 轮: 另有 3 次大工具结果 → 每次省 ~1500-2000 tokens
第 8 轮: 压缩触发
当前: 主队列总 history_tokens ≈ 5000(含 4 条引用)
→ 未达压缩阈值(原应为 20000+)
压缩不触发 ✓ 用户无感知
第 15 轮: 用户回来 "config.rs 的 timeout 在第几行?"
LLM 在压缩摘要中仍能找到"行15:timeout" ← 摘要来自 extract_key_info
→ 精确回答 ✓ 不需要 /read_namespace
第 16 轮: "删掉那行,改成 30"
LLM 需要完整文件结构来做 diff
→ /read_namespace namespace://read_file/xxx
→ 系统拉取完整 342 行
→ 第 16 轮 tool_result 出现完整内容
→ 第 17 轮压缩 → 完整内容又被 namespace 吸收 → 回到摘要+引用
```
**总节省**:15 轮对话中,原本平均每轮 4000 tokens 的大 tool_result 占用 → 现在每轮 ~100 tokens 引用 + 摘要。**累积节省 ~60k tokens,约 $0.18(Sonnet)。**
---
## T4 工作流 DAG 注入(详细设计)
### 现有流程 vs 新流程
```
现有:
run_agentic_loop system_prompt 拼接:
[系统指令] + [工具定义] + [pinned_goals] + [知识注入]
→ build_for_request
→ LLM 只能从消息历史中推断"当前在做什么"
新流程:
run_agentic_loop system_prompt 拼接前:
if conv.workflow_id != None:
dag = df_workflow::Dag::load(conv.workflow_id)
dag_block = render_dag_to_system_block(dag)
system_prompt = dag_block + system_prompt_原有内容
→ build_for_request(system_prompt + ...)
→ LLM 看到结构化 DAG,"任务进展"一目了然
```
### 数据结构
```rust
// src-tauri/src/commands/ai/workflow_context.rs
/// 工作流上下文块 — 注入 system prompt 的 DAG 摘要
pub struct WorkflowContextBlock {
pub workflow_id: String,
pub workflow_name: String,
pub total_nodes: usize,
pub completed_nodes: usize,
pub current_node: Option<WorkflowNodeSummary>,
pub next_nodes: Vec<WorkflowNodeSummary>,
}
/// DAG 节点摘要 — 不进完整 DAG,只进关键上下文
pub struct WorkflowNodeSummary {
pub node_id: String,
pub node_type: String, // "AINode" | "ScriptNode" | "HumanNode" | ...
pub label: String, // 用户或 LLM 设定的节点名称
pub status: String, // "completed" | "running" | "pending" | "blocked"
pub output_summary: Option<String>, // 节点输出的摘要(关键产出)
}
```
### 关键决策点
#### 决策 1:DAG 全量注入还是摘要注入
```
DAG 可能很大(50+ 节点)。全量注入 ≈ 2000+ tokens,不可接受。
```
**方案**:只注入「当前活跃路径」。从 DAG 的起始节点到当前节点 + 后续 2 层子节点。其余节点不注入。
```mermaid
graph LR
subgraph "DAG 全量(50 节点)"
N1["N1 ✅"] --> N2["N2 ✅"]
N1 --> N3["N3 ✅"]
N2 --> N4["N4 🚧 当前"]
N2 --> N5["N5 pending"]
N3 --> N6["N6 pending"]
N4 --> N7["N7 pending"]
N5 --> N8["N8 pending"]
N6 --> N9["N9 pending"]
N3 --> N10["N10 pending"]
N4 --> N11["N11 pending"]
end
subgraph "注入内容(当前活跃路径,5 节点)"
I1["N1: 读源码 ✅"]
I2["N2: 分析依赖 ✅"]
I3["N4: 改配置 🚧"]
I4["N5: 验证配置 pending"]
I5["N7: 提交变更 pending"]
end
```
```rust
pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock {
let path = dag.path_to_root(current_node_id); // 到根的全路径
let next = dag.children(current_node_id, 2); // 后续 2 层
WorkflowContextBlock {
workflow_id: dag.id.clone(),
workflow_name: dag.name.clone(),
total_nodes: dag.nodes.len(),
completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(),
current_node: summarize_node(dag.get_node(current_node_id)),
next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(),
}
}
```
#### 决策 2:注入位置
注入 system prompt 的最前方(优先级高于工具定义):
```
[system]
[工作流] 审批模块改造 (ID: wf-abc)
✅ 1/3 读 config.rs 源码 — 产出: timeout:15 位于行15
🚧 2/3 改 timeout 配置 — 当前步骤
⬜ 3/3 验证配置生效
──────────────────────────────────────
[工具定义] 30 个工具的 JSON schema...
[目标提示] ...
[system prompt 原有内容]
```
**理由**:工作流状态是会话的最上层语境。模型先看到"我们在做什么",再看到"有什么工具可用"。
#### 决策 3:无工作流时的行为
```rust
if let Some(wf_id) = &conv.workflow_id {
if let Ok(dag) = df_workflow::Dag::load(db, wf_id) {
let block = build_active_path(&dag, &conv.current_node_id);
system_prompt = format!("{}\n{}", block.to_system_text(), system_prompt);
}
// Dag::load 失败 → 静默跳过(workflow 可能被删了)
}
// workflow_id == None → 行为完全不变
```
### 推演:T4 在 T-resume 场景中的行为
```
上午:
用户在工作流 wf-abc 中:
N1: 读源码 ✅
N2: 分析依赖 ✅
N3: 改配置 🚧(当前)
最后操作: 改了一半配置,离开
下午回来:
现有: 消息历史被压缩 → 摘要"用户讨论过配置修改"
→ LLM 答:"你讨论了配置,要继续吗?"
→ 用户需要重新说明
T4: system prompt 注入:
[工作流] 审批模块改造
✅ 1/3 读 config.rs — timeout:15
✅ 2/3 分析 db.rs 依赖 — 连接池:10
🚧 3/3 改 timeout 配置 — 已设为30,未验证
→ LLM: "你上午在改 timeout 配置,已经改了还没验证,
要继续验证还是改其他?"
→ 用户直接继续,不需要重新说明
```
**精度依赖**:`WorkflowNodeSummary.output_summary` 是关键。如果工作流节点在完成时已有结构化的产出记录(`read_file → 行号:timeout:15`),LLM 就能精确回溯。这需要 df-workflow 的节点在完成时主动记录产出摘要——目前不一定有。
**缺口**:如果工作流节点跑完但没有产出摘要(`output_summary: None`),LLM 只能看到"节点已完成",不知道完成了什么。`output_summary` 需要在 `AiNode::execute` 完成时自动产生:
```rust
// df-nodes/src/ai_node.rs: 执行完成后
if let Some(workflow_node_id) = current_workflow_node {
let summary = extract_key_info(&result); // 复用
workflow::update_node_output(db, workflow_node_id, summary);
}
```
这个联动在 T4 之前需要确保。
### T4 + T2 组合推演
```
T2 提供了 namespace 精确行号回溯
T4 提供了工作流节点结构化映射
组合:
LLM: "config.rs 的 timeout 在哪一行?"
T4 DAG 节点: "N1: 读 config.rs"
T2 namespace: 关联 namespace://read_file/xxx 到 N1 的产出
→ LLM 从 DAG 节点取到 "timeout:15 位于行15"
→ 不需要调用 /read_namespace
→ 精确行号回答,零额外 token
如果 T2 没有、T4 没有:
LLM: 从压缩摘要猜 → 可能错
如果 T2 有、T4 没有:
LLM: 从 DAG 节点取出 "读 config.rs",但需要/read_namespace 拿行号
→ 多一次 tool round
如果 T2 没有、T4 有:
LLM: DAG 节点只有 "读源码 ✅",没有行号细节
→ 重新 read_file
组合后: 最大精度,最小 token 开销。
```
---
## T3 L3 结构化摘要(设计概要)
### 改 compress_via_llm 返回类型
```rust
// current
pub(crate) async fn compress_via_llm(...) -> Result<String, String>;
// new
pub struct CompressedSummary {
/// JSON 卡片序列化字符串
pub json_card: String,
/// 自然语言摘要(向前兼容)
pub nl_summary: String,
}
pub(crate) async fn compress_via_llm(...) -> Result<CompressedSummary, String>;
```
### 改 compress_prompt
```rust
pub(crate) fn compress_prompt(lang: &str) -> &'static str {
match lang {
"en" => "You are a conversation summarizer. Compress the following conversation \
into a structured summary that preserves the essential context for \
continuing the work. \
\n\
**You MUST output TWO parts separated by a delimiter:**\n\
\n\
Part 1 — JSON (between <<<JSON>>> and <<<END_JSON>>>):\n\
{\n\
\"topics\": [\"topic1\", \"topic2\"],\n\
\"decisions\": [{\"what\": \"...\", \"why\": \"...\"}],\n\
\"unresolved\": [\"question1\"],\n\
\"key_files\": [{\"path\": \"...\", \"change\": \"...\"}],\n\
\"token_saved\": <estimated_tokens>\n\
}\n\
\n\
Part 2 — Natural language summary (between <<<NL>>> and <<<END_NL>>>):\n\
A concise paragraph summarizing the conversation.\n\
\n\
Rules:\n\
- JSON must be valid.\n\
- Keep the natural language summary concise; prefer bullet points.\n\
- Preserve file paths, identifiers, and error messages verbatim in both parts.\n\
- Drop small talk; keep only technically load-bearing facts.\n\
- Do NOT invent facts.\n",
_ => {
// 中文版本同上,翻译为中文
}
}
}
```
### insert_at 处的消费
```rust
// context_lifecycle.rs: 压缩成功后
match compress_outcome {
Ok(Some(summary)) => {
// ★ 新格式:JSON + NL 双格式嵌入 system 消息
let system_text = format!(
"{}<<<STRUCTURED_SUMMARY>>>\n{}\n<<<END_STRUCTURED_SUMMARY>>>\n{}",
SUMMARY_MARKER,
summary.json_card,
summary.nl_summary,
);
conv.messages.insert_at(0, ChatMessage::system(&system_text));
// ...
}
Ok(None) => { /* noop */ }
Err(e) => {
// LLM 压缩失败 → 关键词摘要兜底(同前)
// 关键词摘要是纯文本格式,不需要 JSON 结构
// → insert_at(0, system(keyword_fallback)) ← 与旧行为一致
}
}
```
### 退化检测适配
当前 `clean_summary` 函数只处理纯文本退化。新格式引入后,退化检测需增加 JSON 解析验证:
```rust
fn clean_summary(raw: &str) -> Result<CompressedSummary, String> {
// 提取 JSON 段
let json = extract_between(raw, "<<<JSON>>>", "<<<END_JSON>>>")?;
let nl = extract_between(raw, "<<<NL>>>", "<<<END_NL>>>")?;
// 验证 JSON 合法性
let card: SummaryCard = serde_json::from_str(&json)
.map_err(|e| format!("JSON 解析失败: {}", e))?;
// 退化检测 NL 段
if is_degenerated_repetition(&nl) {
return Err("NL 段退化重复".to_string());
}
Ok(CompressedSummary {
json_card: json,
nl_summary: nl,
})
}
```
### 推演:T3 在检索场景中的行为
```
无 T3(当前):
压缩摘要: "用户讨论了审批配置,把 timeout 改成了 30"
LLM 读取后知道"改过",但具体决策原因不清晰
→ 需要推测或 ask user
有 T3:
system 消息中嵌入:
<<<STRUCTURED_SUMMARY>>>
{"decisions": [{"what": "timeout: 15→30", "why": "用户反馈15min太短"}]}
<<<END_STRUCTURED_SUMMARY>>>
自然语言: 用户讨论审批配置,决定超时改为 30 分钟
LLM 直接从 JSON 读取决策原因:
→ "15→30,原因是用户反馈太短"
→ 精确回答 ✓
→ 不需要 ask user
```
**收益量化**:每次压缩后,JSON 卡片占 ~300 tokens,自然语言占 ~200 tokens。比纯自然语言的 ~400 tokens 多了 ~100 tokens。但 JSON 的结构化让 LLM 的检索精度从「需要推理」变为「可以直接读」,减少了后续追问的轮次。
---
## T1 动态压缩阈值(设计概要)
代码改动精确到行:
```rust
// src-tauri/src/commands/ai/agentic/mod.rs: 调用处(line ~1117)
if maybe_auto_compress(
&session_arc,
&conv_id,
&app_handle,
&provider,
&provider_config,
&llm_concurrency,
iteration,
sys_tokens, // ★ 新增参数
).await {
```
```rust
// src-tauri/src/commands/ai/agentic/context_lifecycle.rs: 函数签名(line ~72)
pub(super) async fn maybe_auto_compress(
session_arc: &Arc<Mutex<AiSession>>,
conv_id: &str,
app_handle: &AppHandle,
provider: &Box<dyn LlmProvider>,
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
iteration: usize,
sys_tokens: u32, // ★ 新增
) -> bool {
```
```rust
// 同上,line ~88-92 触发条件
let budget = mgr.budget_limit();
let available = budget.saturating_sub(sys_tokens); // ★ 新增
let should = protect_start > 0
&& (available as u64) * 6 / 10 < history_tokens as u64 // ★ 改 budget→available
&& mgr.has_compressible_messages(protect_start);
```
---
## 实施顺序验证(依赖关系)
```mermaid
graph LR
T1["T1: 动态阈值<br>~4 行改动"] --> T2["T2: 命名空间<br>~300 行"]
T1 -.->|可选前置| T3["T3: 结构化摘要<br>~100 行"]
T2 --> T4["T4: 工作流DAG<br>~500 行"]
T3 --> T5["T5: WorkingContext<br>~500 行"]
T2 -.-> T4
T3 -.-> T4
style T1 fill:#c8e6c9
style T2 fill:#c8e6c9
style T3 fill:#fff9c4
style T4 fill:#fff9c4
style T5 fill:#ffccbc
```
**并发策略(AI coding 多 agent 并行)**:
| 并行流 | 任务 | 前置 | 可独立启动? |
|--------|------|------|------------|
| 流 A | T1 + T2 | T1 无前置,T2 无前置 | ✅ 立即,T1 与 T2 可并行编码 |
| 流 B | T3 | 无(与 T2 独立) | ✅ 可同时启动 |
| 流 C | T4 | 依赖 T2 的 namespace 接口签名(非实现) | ⚠️ 接口定义后即可启动 |
| 流 D | T5 | 依赖 T3 的 CompressedSummary 结构 | ⚠️ T3 验收后 |
@@ -0,0 +1,352 @@
# 上下文管理演进 — 执行计划
> 关联: `上下文管理演进-任务技术设计-2026-07-20.md` | 状态: 待执行
---
## Wave 划分
3 个 Wave,每个 Wave 内 agent 可并行,Wave 间串行 gate。
```mermaid
graph LR
subgraph "Wave 1 (4 agent 并行)"
A1["Agent 1: T1 动态阈值"]
A2["Agent 2: T2 命名空间存储"]
A3["Agent 3: 接口契约定义"]
end
A3 -.->|"定义 downstream 接口"| A2
A1 --> G1["Gate 1: cargo check + 单测"]
A2 --> G1
G1 --> subgraph "Wave 2 (3 agent 并行)"
B1["Agent 4: T3 结构化摘要"]
B2["Agent 5: T4 工作流 DAG"]
end
B1 --> G2["Gate 2: cargo check + 集成验证"]
B2 --> G2
G2 --> subgraph "Wave 3 (2 agent 串行)"
C1["Agent 6: T5 WorkingContext"]
end
```
---
## Wave 1:基础改造(4 agent 并行)
### Agent 1:T1 动态压缩阈值
**改动清单**:精确到行
| 文件 | 行 | 操作 |
|------|----|------|
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | 64 签名 | `maybe_auto_compress` 加 `sys_tokens: u32` 参数 |
| 同上 | 88-92 触发条件 | `let budget` → `let available = budget.saturating_sub(sys_tokens)`;比较用 `available` 替代 `budget` |
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~1117 调用处 | `maybe_auto_compress(...)` 加 `sys_tokens` 实参(同作用域 line 964 已定义) |
**接口契约**:`maybe_auto_compress` 签名变更——调用方需传入 `sys_tokens`。不影响其他模块。
**验证**:`cargo check -p devflow`;观察日志 `available` 值在 system prompt 大时显著小于 `budget`。
**回退**:删参数 + 恢复 `budget` 变量,2 行。
---
### Agent 2:T2 工具命名空间存储
**接口契约**(Wave 1 必须先执行):
```rust
// crates/df-ai/src/namespace_store.rs — 新增,其他 agent 依赖此接口
pub struct NamespaceStore { .. }
impl NamespaceStore {
pub fn new(max_bytes: usize) -> Self;
pub fn store(&mut self, key: &str, tool_name: &str, args: &Value, content: &str) -> String;
pub fn read(&mut self, full_path: &str) -> Option<&str>;
pub fn read_summary(&self, full_path: &str) -> Option<&str>;
}
pub fn should_use_namespace(content: &str, tool_name: &str) -> bool;
pub const NAMESPACE_REF_PREFIX: &str = "namespace://";
pub const NAMESPACE_BYTE_THRESHOLD: usize = 2048;
```
**接线改动**:
| 文件 | 行 | 操作 |
|------|----|------|
| `crates/df-ai/src/namespace_store.rs` | 新增 | 完整实现 + 单元测试 |
| `crates/df-ai/src/lib.rs` | — | `pub mod namespace_store;` + `pub use` 重导出 |
| `crates/df-ai/src/context_helpers.rs` | — | 新增 `NAMESPACE_REF_PREFIX`、`should_use_namespace`、`is_namespace_ref` |
| `src-tauri/src/commands/ai/audit/mod.rs` | `process_tool_calls` ~380 行 | tool 执行完成后:`if should_use_namespace` → `ns.store(...)` → push 引用;else → push 原文 |
| `src-tauri/src/commands/ai/mod.rs` 或 `state.rs` | `AiSession` struct | 新增 `namespace_store: NamespaceStore` 字段 |
| `src-tauri/src/commands/ai/conversation.rs` | `save_conversation` | namespace 引用展开为原文再写 DB |
**验证**:
1. `cargo test -p df-ai` — `namespace_store` 单元测试(store → read → LRU 淘汰)
2. 手动:`read_file("large.txt")` → DB 中 `tool_result` 内容为原文(引用已展开)
3. `history_tokens` 统计应显著下降(大工具结果只计引用 token)
**与 Agent 3 的边界**:`namespace_store.rs` 实现不依赖 AiSession,纯数据结构的 `HashMap` 操作,零耦合,可独立开发和测试。接线到 AiSession 和 process_tool_calls 由本 agent 完成。
---
### Agent 3:接口契约定义(跨 Wave 共享)
定义 Wave 2/Wave 3 依赖的数据结构,**仅定义不实现**,确保下游 agent 可独立编码。
```rust
// ── Wave 2 共享契约 ──
/// 压缩摘要双格式(T3 输出,T5 消费)
pub struct CompressedSummary {
pub json_card: String, // JSON 卡片(主题/决策/待办)
pub nl_summary: String, // 自然语言摘要(向前兼容)
}
// ── Wave 3 共享契约 ──
/// 工作流 DAG 上下文块(T4 输出 → 注入 system prompt)
pub struct WorkflowContextBlock {
pub workflow_id: String,
pub workflow_name: String,
pub current_node: Option<String>,
pub completed: usize,
pub total: usize,
pub next_steps: Vec<String>,
}
/// WorkingContext 常驻上下文(T5 实现)
pub struct WorkingContext {
pub goals: Vec<GoalItem>,
pub decisions: Vec<DecisionItem>,
pub unresolved: Vec<String>,
pub current_step: Option<StepInfo>,
pub version: WorkingContextVersion,
}
pub enum GoalStatus { Active, Completed, Cancelled }
pub struct GoalItem { pub id: String, pub text: String, pub status: GoalStatus }
pub struct DecisionItem { pub question: String, pub decision: String, pub reason: String }
pub struct StepInfo { pub step_name: String, pub status: StepStatus }
pub enum StepStatus { Pending, InProgress, Done, Blocked }
pub struct WorkingContextVersion {
pub goals_changed_at: u32,
pub decisions_changed_at: u32,
pub unresolved_changed_at: u32,
pub step_changed_at: u32,
pub current_turn: u32,
}
```
**验证**:`cargo check` — 编译器验证所有 use 路径和类型引用正确,无需运行时测试。
**简化**:Agent 3 先把上述结构体定义放进 `crates/df-ai/src/context_helpers.rs`,`pub use` 重导出。Wave 2/3 的 agent 直接引用,不用等。
---
## Gate 1(Wave 1 → Wave 2)
```
条件:
□ cargo check --workspace 全部通过
□ cargo test -p df-ai 全部通过
□ cargo test -p devflow 全部通过(已有测试不受影响)
验证项:
□ 日志: compress 触发时 available 减去 sys_tokens(T1)
□ 手动: 大工具结果 DB 存原文(T2)
□ 手动: namespace 引用格式正确(T2)
回滚:
单文件回退: T1 删参数,T2 删 `namespace_store.rs` + 恢复 process_tool_calls 原文 push
```
---
## Wave 2:结构化改进(3 agent 并行)
### Agent 4:T3 L3 结构化摘要
**前置依赖**:Agent 3 定义了 `CompressedSummary` 结构
**改动清单**:
| 文件 | 行 | 操作 |
|------|----|------|
| `src-tauri/src/commands/ai/compress.rs` | 45-52 签名 | `compress_via_llm` 返回类型 `Result<String>` → `Result<CompressedSummary>` |
| 同上 | 82-86 prompt 构造 | 改 `compress_prompt` 模板要求输出 JSON+NL 双格式 |
| 同上 | 114-119 clean + 返回 | `clean_summary` 适配双格式:提取 JSON 段 + NL 段,分别验证退化 |
| 同上 | 新增 | `extract_between` 辅助函数(提取 `<<<JSON>>>...<<<END_JSON>>>` 之间的内容) |
| `src-tauri/src/commands/ai/prompt.rs` | 240-290 | `compress_prompt` 模板新增 JSON+NL 双格式输出要求 |
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | 145-155 消费处 | `Ok(Some(summary))` 分支中 `insert_at` 内容改为双格式嵌入 |
| 同上 178-210 兜底 | 关键词摘要降级 | 关键词降级不改,仍是纯文本(`keyword_fallback` 无 JSON) |
**验证**:
1. `cargo test -p devflow` — compress 单测通过
2. 手动:触发压缩 → DB 中 system 消息含 `<<<STRUCTURED_SUMMARY>>>` 标记
3. 手动:关键词摘要降级路径仍为纯文本(不受影响)
**与 Agent 5 无依赖**:T3 只改 compress 路径,T4 只改 system prompt 注入,零耦合。
---
### Agent 5:T4 工作流 DAG 注入
**前置依赖**:Agent 3 定义了 `WorkflowContextBlock`,Agent 2 的 namespace 接口已就绪(弱依赖——仅用于产出关联)
**改动清单**:
| 文件 | 行 | 操作 |
|------|----|------|
| `src-tauri/src/commands/ai/workflow_context.rs` | 新增 | `load_dag()`, `build_active_path()`, `summarize_node()` |
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~1110-1128 | `build_for_request` 之前:检测 `conv.workflow_id` → 加载 DAG → 构建 context block → 注入 system prompt |
| `crates/df-ai/src/context_helpers.rs` | 新增常量 | `WORKFLOW_DAG_MARKER` |
**核心逻辑**:
```rust
// workflow_context.rs
pub fn build_active_path(dag: &Dag, current_node_id: &str) -> WorkflowContextBlock {
let path = dag.path_to_root(current_node_id); // 到根路径
let next = dag.children(current_node_id, 2); // 后续 2 层
WorkflowContextBlock {
workflow_id: dag.id.clone(),
workflow_name: dag.name.clone(),
total_nodes: dag.nodes.len(),
completed_nodes: dag.nodes.iter().filter(|n| n.status == "completed").count(),
current_node: summarize_node(dag.get_node(current_node_id)),
next_nodes: next.into_iter().map(|n| summarize_node(n)).collect(),
}
}
```
**关键安全**:
```rust
// mod.rs: system_prompt 注入处
if let Some(wf_id) = &conv.workflow_id {
if let Ok(dag) = df_workflow::Dag::load(&db, wf_id) {
let block = build_active_path(&dag, &conv.current_node_id);
system_prompt = format!("[工作流] {}\n{}", block.to_system_text(), system_prompt);
}
// Dag::load 失败 → 静默跳过
}
// workflow_id == None → 行为完全不变
```
**验证**:
1. `cargo check -p devflow` — 依赖 `df-workflow` crate 的 `Dag::load` 接口
2. 手动:创建带工作流的对话 → system prompt 含 `[工作流]` 块
3. 手动:自由对话(无 workflow)→ system prompt 不变
---
## Gate 2(Wave 2 → Wave 3)
```
条件:
□ cargo check --workspace 全部通过
□ cargo test --workspace 全部通过
□ 前端 vue-tsc --noEmit 通过
验证项:
□ 手动: 压缩后 DB system 消息含 JSON 标记(T3)
□ 手动: 工作流对话注入 DAG 块(T4)
□ 手动: 自由对话不受影响(T4 安全)
□ 全量: history_tokens 在 T2 基础上再次下降(T3 摘要更精简)
回滚:
T3: 回退 compress.rs 签名 + prompt.rs 模板
T4: 删 workflow_context.rs + 恢复 mod.rs system_prompt 拼接
```
---
## Wave 3:常驻上下文(1 agent 串行)
### Agent 6:T5 WorkingContext 分层注入
**前置依赖**:
- Agent 3 的 `WorkingContext` 结构定义需在 `context_helpers.rs` 中(已在 Wave 1 Gate 前到位)
- Agent 4 的 `CompressedSummary` 用于 `reset_all_with_summary`(弱依赖——无 NL 摘要时可跳过)
**改动清单**:
| 文件 | 行 | 操作 |
|------|----|------|
| `crates/df-ai/src/context_helpers.rs` | 已有定义 | `WorkingContext` / `WorkingContextVersion` 等已由 Agent 3 定义 |
| `crates/df-ai/src/context/mod.rs` | `ContextManager` struct | 新增字段 `pub working_context: Option<WorkingContext>` |
| `src-tauri/src/commands/ai/agentic/mod.rs` | ~960 system_prompt 拼接处 | 在 `build_for_request` 之前调 `WorkingContext::build_injection()` 注入 L1a+L1b |
| 同上 | tool_registry 注册 | 注册 `read_context` 工具(L1c) |
| `src-tauri/src/commands/ai/agentic/context_lifecycle.rs` | ~145-155 压缩成功后 | 调 `ctx.version.reset_all_with_summary(&summary)` |
| `src-tauri/src/commands/ai/commands/chat.rs` | `ai_chat_clear_context` | 归档后 reset WorkingContext(归档 = 上下文状态变更) |
**脏标记核心逻辑**:
```rust
impl WorkingContext {
pub fn build_injection(&self, recent_turns: u32) -> String {
let mut parts = vec![];
// L1a: 当前步骤(每轮必带,~200 tokens)
if let Some(step) = &self.current_step {
parts.push(format!("当前步骤: {}", step.step_name));
}
// L1b: 过去 recent_turns 轮内有变更才注入
let threshold = self.version.current_turn.saturating_sub(recent_turns);
let has_active = self.goals.iter().any(|g| g.status == GoalStatus::Active);
if has_active && self.version.goals_changed_at >= threshold {
let active: Vec<_> = self.goals.iter()
.filter(|g| g.status == GoalStatus::Active)
.collect();
parts.push(format!("目标({}): {}", active.len(),
active.iter().map(|g| g.text.as_str()).collect::<Vec<_>>().join("; ")));
}
if self.version.unresolved_changed_at >= threshold && !self.unresolved.is_empty() {
parts.push(format!("待解: {}", self.unresolved.join("; ")));
}
parts.join("\n")
}
}
```
**验证**:
1. `cargo test -p df-ai` — `WorkingContext::build_injection` 单元测试(脏标记正确跳过/注入)
2. 手动:观察 system prompt 含 L1a(当前步骤)和条件性的 L1b(有变更时)
3. 手动:压缩触发后,下轮 L1b 正常注入(`reset_all` 生效)
4. 开关:`GOAL_PIN_ENABLED=false`(或等价的 workincontext 开关)→ 行为恢复到改动前
---
## 汇总:执行序列
```
Week 1:
Day 1: Agent 3 定义契约(30 分钟) → 其余 3 agent 拿到接口开始编码
Day 1-2: Agent 1 T1 + Agent 2 T2 并行编码
Day 3: Gate 1 验证 + 修复
Week 2:
Day 4-5: Agent 4 T3 + Agent 5 T4 并行编码
Day 6: Gate 2 验证 + 修复
Week 3:
Day 7-9: Agent 6 T5 编码(由于串行依赖,此阶段无并行)
Day 10: 最终验证 + 全量回归测试
```
## 回滚总策略
| 场景 | 操作 |
|------|------|
| T1 有问题 | 删 `sys_tokens` 参数 + 恢复 `budget` 变量 → 2 行回退 |
| T2 有问题 | 删 `namespace_store.rs` + 恢复 `process_tool_calls` push 原文 → 1 文件回退 |
| T3 有问题 | 回退 `compress.rs` + `prompt.rs` 到 `Result<String>` → 2 文件回退 |
| T4 有问题 | 删 `workflow_context.rs` + 恢复 mod.rs system_prompt 拼接 → 1 文件回退 |
| T5 有问题 | `GOAL_PIN_ENABLED=false` 等效开关 → 配置回退,不改代码 |
| 全量回退 | `git revert <commit-hash>` |
@@ -0,0 +1,882 @@
# 上下文管理:现状评估、业界对标与发散演进
> 创建: 2026-07-20 | 状态: 构想审查 | 关联: F-15 上下文管理增强设计
---
## 目录
- [一、当前 ContextManager 评估](#一当前-contextmanager-评估)
- [二、业界方案对标](#二业界方案对标)
- [三、融合设计:结构化分层上下文引擎](#三融合设计结构化分层上下文引擎)
- [四、3 个近期改进的推演验证](#四3-个近期改进的推演验证)
- [五、7 个发散方向](#五7-个发散方向)
- [六、发散组合分析与冲突矩阵](#六发散组合分析与冲突矩阵)
- [七、推荐路线](#七推荐路线)
---
## 一、当前 ContextManager 评估
### 核心架构
```
build_for_request → 返回裁剪后视图(影响 LLM 上下文)
all_messages_clone → 返回全量(影响 DB 存储, save_conversation)
```
| 层级 | 组件 | 文件 |
|------|------|------|
| Token 估算 | `TokenEstimator` — chars×0.35 + 消息开销 | `context_helpers.rs:28` |
| 窗口配置 | `ContextConfig` — max_tokens/output_reserve/safety_ratio | `context_helpers.rs:105` |
| 消息管理 | `ContextManager` — 消息队列 + token 缓存 | `context/mod.rs:44` |
| 自愈 | `sanitize模块` — 畸形配对过滤 + 序列合法性修复 | `context/sanitize.rs` |
| 自动压缩 | `maybe_auto_compress` — F-15 自动触发 | `context_lifecycle.rs:64` |
### 三维评分
| 维度 | 评分 | 核心论据 |
|------|------|---------|
| **效率** | ⭐⭐⭐⭐ (8/10) | 核心路径零拷贝裁剪,O(n) 反向扫描查找,幂等压缩。瓶颈在 TokenEstimator 精度和压缩同步等待 |
| **合理性** | ⭐⭐⭐⭐⭐ (9/10) | 三元组原子性、保护区、视图/持久化分离、畸形自愈——近乎工业级完整。略缺跨会话上下文 |
| **成本** | ⭐⭐⭐⭐ (8/10) | 预算池 + 流式记录 + 关键词兜底形成三级防线。缺乏自动模型降级链和长时间会话自动分段 |
### 已知问题
1. **TokenEstimator 对所有字符一视同仁** — 中文(~1.8-2.5 tok/char)被低估,英文(~0.2-0.3)被高估。当前靠 `safety_ratio: 0.85` 和 `output_reserve: 8192` 补误差
2. **压缩阈值硬编码 0.6** — 不考 system prompt 大小,大量工具结果时可能误判
3. **无自动会话分段** — `archived_segment` 状态和 IPC 已就绪,但只能手动触发
---
## 二、业界方案对标
### 2.1 与 7 条行业公认合理标准对标
| # | 行业标准 | DevFlow 现状 | 差距 |
|---|---------|-------------|------|
| 1 | **运行时/LLM 上下文双层隔离** | 有部分隔离但不彻底。无类型层面的强制边界 | ⚠️ 概念上有但无强制 |
| 2 | **分层记忆(瞬时→短期→长期)** | 只有「内存窗口 ↔ DB」两层,缺短期缓存层和向量检索层 | ⚠️ 有分层但缺中间层 |
| 3 | **Token 预算管控** | `TokenEstimator` + `budget_limit` + 0.6 水位 + 降级关键词兜底 | ✅ 业界中上 |
| 4 | **信息加权裁剪** | `PROTECT_COUNT=6` 保近期,但裁剪是纯位置滑动,不是按价值加权 | ⚠️ 机制粗糙 |
| 5 | **命名空间隔离** | `conv_id` 做 Session 级隔离,无 User/Agent 维度 | ⚠️ 缺维度 |
| 6 | **结构化沉淀** | 压缩摘要为纯文本,非结构化数据 | ❌ 文本摘要非结构化 |
| 7 | **可持久快照** | DB 保留全量,但无版本概念,不可回滚 | ❌ 有持久无快照 |
### 2.2 具体方案比较
| 方案 | 核心优势 | 对 DevFlow 的参考价值 |
|------|---------|---------------------|
| **Claude Code 三层加载** | 常驻索引 + 按需主题 + 离线归档 | 当前 `pinned_goals` → 扩展为更丰富的 WorkingContext |
| **OpenAI Agents SDK RunContext** | 运行时状态不进 prompt | 当前 AiSession 概念上有但无强制边界 |
| **Mem0** | User/Session/Agent 三维命名空间 | 未来多租户/跨会话复用的架构参考 |
| **Letta (MemGPT)** | 虚拟内存分页,模型自主调度 | 哲学不同:Letta 模型自主换页,DevFlow 系统强制压缩 |
| **TencentDB Agent Memory** | 结构化任务画布替代文本历史 | **最有参考价值** — 与 df-workflow + df-nodes 天然契合 |
| **LangGraph** | 可持久化 State + Checkpoint | 版本化快照模式参考 |
| **AutoContextMemory** | 6 档阶梯式上下文管控 | 信息加权裁剪参考 |
### 2.3 核心启发
```
TencentDB 任务画布思路 → 结构化卡片替代纯文本摘要
↓
WorkingContext 常驻 → 结构化目标/决策/步骤替代纯文本 pinned_goals
↓
MemoryAdapter trait → 插件化记忆层,不绑定 Vec<TrackedMessage>
↓
版本化快照 → 从覆盖写改为追加 checkpoint
```
---
## 三、融合设计:结构化分层上下文引擎
### 3.1 架构总览
```mermaid
graph TB
subgraph LLM 上下文窗口 (build_for_request 输出)
L1["L1 常驻上下文<br>(永不裁剪,结构化 system 消息)"]
L2["L2 活跃历史<br>(窗口滑动淘汰,user/assistant/tool)"]
L3["L3 结构化摘要<br>(压缩/归档后锚点,JSON 卡片)"]
end
subgraph 本地运行时状态 (不进 LLM)
R1["原始消息全量 (SQLite DB)"]
R2["工具执行日志 (分级缓存)"]
R3["向量记忆索引 (df-knowledge)"]
R4["工作流 DAG (df-workflow)"]
end
L1 -->|注入 system| LLM["LLM 请求"]
L2 -->|阈值超限| L3
L3 -->|检索回溯| R1
L3 -->|语义检索| R3
style L1 fill:#c8e6c9
style L2 fill:#fff9c4
style L3 fill:#bbdefb
style R1 fill:#f5f5f5
style R2 fill:#f5f5f5
style R3 fill:#f5f5f5
style R4 fill:#f5f5f5
```
### 3.2 L1 常驻上下文:WorkingContext
融合 Claude Code 常驻索引 + DevFlow 现有 `pinned_goals` + Decision Journal:
```rust
pub struct WorkingContext {
pub goals: Vec<GoalItem>,
pub decisions: Vec<DecisionItem>,
pub unresolved: Vec<String>,
pub current_step: Option<StepInfo>,
pub key_artifacts: Vec<ArtifactRef>,
}
```
### 3.3 L1 注入策略:分层注入(非全量常驻)
`dirty_window_turns` 控制脏标记的可见窗口——只有最近 N 轮内有变更的字段才注入 system prompt。
```mermaid
graph LR
subgraph "L1a 高频常驻 (~200 tokens)"
A1["当前步骤 current_step"]
end
subgraph "L1b 条件注入 (~200-800 tokens)"
B1["活跃目标 — dirty_window 内变更才注入"]
B2["未决项 — dirty_window 内变更才注入"]
end
subgraph "L1c 工具按需"
C1["完整决策历史 → read_context 工具"]
end
A1 -->|每轮必带| LLM
B1 -->|变更在窗口内| LLM
B2 -->|变更在窗口内| LLM
C1 -->|模型主动调用| LLM
```
### 3.4 压缩联动(关键补丁)
压缩事件发生后,`reset_all_with_summary` 将所有脏标记重置到当前轮次,保证压缩后下轮 L1b 全量注入,补偿被压缩丢失的历史信息:
```rust
// context_lifecycle.rs: 压缩成功后
conv.messages.compress_old_messages(protect_start);
conv.messages.insert_at(0, ChatMessage::system(&summary));
// 压缩后重置脏窗口
conv.working_context.version.reset_all_with_summary(&summary, current_turn);
```
### 3.5 L3 结构化摘要卡片
将压缩输出的纯文本摘要改为 JSON + NL 双格式:
```
当前: system: "用户讨论了审批流程,确认了超时参数改为 30 分钟"
改进后: system: <<<STRUCTURED_SUMMARY>>>
{
"type": "conversation_segment",
"topics": ["审批超时设置"],
"decisions": [
{"what": "超时改为 30 分钟", "why": "用户反馈 15 分钟太短"}
],
"unresolved": ["推送方式待定"],
"token_saved": 45200
}
<<<END_STRUCTURED_SUMMARY>>>
自然语言摘要: 用户讨论了审批流程,确认了超时参数改为 30 分钟
```
### 3.6 信息加权裁剪
当前 `build_eviction_units` 按消息位置淘汰,改为按消息类型赋予保留权重:
| 消息角色 | 保留权重 | 理由 |
|---------|---------|------|
| User(短指令) | 100 | 高价值,优先保留 |
| User(长消息) | 70 | 中等 |
| Assistant(含 tool_calls) | 50 | 工具调用链需要保留 |
| Assistant(纯文本) | 40 | 一般 |
| Tool(工具结果) | 20 | 低价值,优先淘汰 |
---
## 四、3 个近期改进的推演验证
### 4.1 改进 1:TokenEstimator 按字符类型加权
**初始提议**:中文×1.8、英文×0.25、数字×0.20 等混合加权。
**推演结论**:**不做。** 理由:
1. 当前 `safety_ratio: 0.85` + `output_reserve: 8192` 提供了约 27k tokens 的余量,中英混合对话要超界需要极端场景
2. 真正该修的入口不在 TokenEstimator:tool_result 膨胀应由 `extract_key_info` 在工具执行阶段削减,不是靠 TokenEstimator 精度来兜底
3. 改加权逻辑有回归风险(content / parts / tool_calls / tool_call_id 多处调用了 `chars_ratio`)
### 4.2 改进 2:动态压缩阈值
**初始提议**:将 `budget * 6 / 10` 改为 `available * 6 / 10`(available = budget - sys_tokens)。
**推演结论**:**做。** 3 行改动,效果明确:
```rust
// 当前
let should = (budget as u64) * 6 / 10 < history_tokens as u64;
// 改为
let available = mgr.budget_limit().saturating_sub(sys_tokens);
let should = (available as u64) * 6 / 10 < history_tokens as u64;
```
`sys_tokens` 在 `maybe_auto_compress` 调用处已由调用方算好(loop 顶部传给 `build_for_request`),透传即可。
### 4.3 改进 3:长时间会话自动存档
**初始提议**:达到压缩次数或消息总量阈值后自动触发 `archived_segment`。
**推演结论**:**暂缓。** 基础设施已就绪(`archived_segment` 状态、IPC `ai_chat_clear_context`、前端 `AiContextCleared` 事件),但:
1. 极长对话(>300 条)才需要,日常场景用不着
2. 归档后压缩摘要与归档摘要的关系需要先理清(归档时会把之前的压缩摘要也一并标记 archived_segment,确保不产生孤立 system 消息)
3. 建议 Phase 4/5 再做
### 4.4 优先级
| 排序 | 项 | 改动量 | 收益 | 建议 |
|------|----|--------|------|------|
| 🥇 | **2a 动态阈值** | 3 行 | 减少误压缩和漏压缩 | **立即做** |
| 🥈 | 1 TokenEstimator | 多文件 | 被 safety_ratio 兜住 | **不做** |
| 🥉 | 3 自动存档 | ~50 行 | 场景不迫切 | **Phase 4/5** |
---
## 五、7 个发散方向
### 发散①:画布导航 — 模型自主控制视野
**核心思想**:不再把上下文拼成平铺消息队列丢给模型,而是组织成多维画布,模型通过 `look_at` 工具自主导航:
```json
{
"tool": "look_at",
"args": { "zone": "L1/decisions", "expand": true }
}
```
**差异**:窗口永远不会满(不需要装全部),压缩不存在(没有"全量传输"的概念)。模型控制视野范围。
**代价**:需要模型主动使用 `look_at`,模型不用时需降级回传统模式。新协议,高实现成本。
### 发散②:上下文版本分支 — 决策回溯可导航
**核心思想**:支持分支与合并上下文。`ContextManager` 加 `parent: Option<String>` 和 `fork_point: usize`:
```mermaid
graph LR
A[方案 A] --> B[继续 A]
A --> C[方案 B]
C --> D[发现 B 不可行]
D --> E[合并回到 A]
B --> E
```
**差异**:放弃的方案上下文不会被新会话污染,可随时回溯精确内容。
**代价**:存储复杂度——N 个分支 = N× 内存。
### 发散③:主动参与 — 系统在对话中插话
**核心思想**:上下文管理器在 agentic loop 中拥有系统消息通道,可以在检测到模式时主动"说话":
```
触发条件示例:
连续 N 轮使用同一工具 → 建议创建快捷方式
修改 A 后 5 轮内又修改 B → 建议关联工作流
目标活跃但 N 轮无推进 → 提醒调整目标
```
**差异**:从"你问它答"到"AI 主动观察并建议"——人机交互范式变化。
**代价**:冷却期管理和负面反馈机制是必需的,否则必然变成骚扰。
### 发散④:时间轴衰减 — 不裁不压,让信息连续"模糊"
**核心思想**:每条消息的精度随轮次连续衰减,而非在某一轮突然被压缩:
```rust
pub struct TrackedMessage {
pub message: ChatMessage,
pub precision: f32, // 1.0=精确 → 0.0=完全模糊
pub last_accessed: u32, // 最后被 LLM 读取的轮次
}
```
**差异**:压缩不是在某一轮突然发生的,而是每轮都在发生——精度衰减是连续的。
**代价**:需要新的衰减算法和精度阈值判断。与当前离散三态模型不兼容。
### 发散⑤:元控制器 — 上下文管理策略自感知
**核心思想**:把 `safety_ratio`、压缩阈值、`PROTECT_COUNT` 等硬编码常量变为动态自适应参数:
```rust
pub struct MetaContextController {
features: VecDeque<WindowFeature>, // 会话特征滑动窗口
recommended: ContextConfig, // 当前推荐配置
}
```
**差异**:无需手调参数,系统在 5-10 轮后自动收敛到适合当前会话的策略。
**代价**:元控制器自己的参数(特征窗口大小、步长)也需要调——递归问题。
### 发散⑥:工作流即上下文 — DAG 作为上下文主干
**核心思想**:上下文就是当前工作流的 DAG 视图,消息按工作流节点组织,而非按时间排列:
```
当前: [user][asst][tool][user][asst]... → 时间线
改进: [节点1(完成)] [节点2(当前)] [节点3(待办)] → DAG
```
**差异**:DevFlow 是唯一同时拥有工作流引擎和 AI 对话的产品,这种融合是**差异化最大的方向**。
**代价**:需要工作流与对话关联,自由对话不适用。
### 发散⑦:工具命名空间 — 工具结果不进主队列
**核心思想**:工具的执行结果不返回主消息队列,而是存入专属的 namespace:
```
主队列:
[asst] 正在读取 config.rs...
[tool_result(namespace://read/config.rs)] ← 仅 20 tokens 的引用
工具工作区:
namespace://read/config.rs → 342 行代码(按需读取,不占窗口)
```
**差异**:大工具结果膨胀是当前压缩触发的主因,这直接消除膨胀源。
**代价**:需要 namespace 存储引擎 + 模型引用协议。工具执行路径不变,路由从 push messages 改为 push reference。
---
## 六、发散组合分析与冲突矩阵
### 6.1 内部不可调和冲突
#### 冲突 1:画布(①) vs 批传输(当前 Design + ②③④⑤⑥⑦)
```
画布模式要求: 模型自主注视 → 增量读取 → 窗口永不装满
批传输模式要求: 系统全量拼装 → 一次发给模型 → 压缩/裁剪管理窗口
当①启用时,压缩、裁剪、budget_limit 的假设前提不再成立——
窗口装的不是全量消息,而是画布坐标系。
```
#### 冲突 2:主动参与(③) vs 画布导航(①)
```
①: 模型是自主的,系统是服务者
③: 系统有更高优先级的信息需要模型注意
模型正在注视 L2,系统突然插话——注意力被打破。
```
#### 冲突 3:时间轴衰减(④) vs 分支(②) vs 工作流(⑥)
三者对"什么是上下文的主要信息载体"的回答不同:
```
④: 消息是主要载体 → 精度衰减作用于消息
⑥: 工作流节点是主要载体 → 消息附着在节点上
②: 分支是主要载体 → 消息在分支的线性时间线上
一条消息同时有精度值(④)+ 分支ID(②)+ 工作流节点ID(⑥)→
三个矛盾的淘汰决策信号同时作用 → 需要额外仲裁逻辑
```
### 6.2 组合爆炸
7 个发散引入至少 26 个新参数;状态空间约 3^7 ≈ 2187 种(无法穷举测试)。
### 6.3 三个互斥派系
```mermaid
graph TB
subgraph "派系 A: 工作流结构化 (推荐首做)"
A6["⑥工作流即上下文"]
A7["⑦工具命名空间"]
A5["⑤元控制器 (精简版)"]
A_RESULT["输出: 工作流DAG + 命名空间引用 替代平铺消息"]
end
subgraph "派系 B: 记忆系统"
B2["②上下文分支"]
B4["④时间轴衰减"]
B5["⑤元控制器"]
B_RESULT["输出: 分支记忆 + 连续遗忘 替代离散压缩"]
end
subgraph "派系 C: 交互范式"
C1["①画布导航"]
C3["③主动参与"]
C5["⑤元控制器"]
C_RESULT["输出: 自主模型 + 系统辅助 替代命令式对话"]
end
A6 ---|不兼容| B2
B4 ---|兼容| C1
C3 ---|不兼容| C1
```
| 派系 | 核心哲学 | 适合场景 | 与当前 Design 衔接成本 |
|------|---------|---------|----------------------|
| **A 工作流结构化** | 把上下文组织成 DAG | 有明确工作流的任务 | **低**(复用 df-workflow) |
| **B 记忆系统** | 让上下文更像人脑记忆 | 超长对话、研究型任务 | 中(新算法 + 新状态) |
| **C 交互范式** | 重新定义人机交互 | 高级用户、复杂决策 | 高(改变协议) |
### 6.4 双发散的乘数效应
| 组合 | 收益等级 | 价值 |
|------|---------|------|
| **⑥+⑦** | 🔥🔥🔥 | 工作流节点引用 namespace 路径,精确回溯无需重查 |
| **①+②** | 🔥🔥 | 画布让 N 个分支的内存成本从 O(N) 降到 O(1) |
| **③+⑤** | 🔥🔥🔥 | 没有⑤,③是骚扰;没有③,⑤的优化用户感觉不到 |
| **④+⑦** | 🔥 | namespace 引用增加置信度信号 |
| **①+②+⑥+⑦** | 🏆 | 最完整的中断恢复路径 |
| **③+⑤+⑥** | 🔥🔥 | 工作流阻塞时智能主动提示 |
### 6.5 对「全组合」的结论
> **技术上可以,但架构上存在 3 对不可调和冲突、26+ 新参数、2187+ 状态空间。不可控。** 不推荐全组合。
| 维度 | 答案 |
|------|------|
| **技术上可行吗** | 可以,但需要全新架构,复杂度膨胀约 10 倍 |
| **测试覆盖可行吗** | 不可行。2187 种组合状态 → 实际覆盖不到 5% |
| **维护成本可接受吗** | 不可接受。团队需同时理解 7 个系统的交互 |
| **价值可叠加吗** | 边际递减。②+④ 已覆盖 T-fork+T-resume 主要痛点 |
| **更优路径** | **选派系 A 做深,接口预留 B 和 C** |
---
## 七、推荐路线
### 选派系 A(工作流结构化)为主干,预留接口
```mermaid
graph TB
subgraph "Phase 4-5"
A6["⑥ 工作流即上下文"]
A7["⑦ 工具命名空间"]
A5["⑤ 元控制器 (精简版: 仅调 3-5 个参数)"]
COMBINED["A 组构成新 ContextManager"]
end
subgraph "预留接口 (不实现)"
IF1["Trait: ContextNavigation<br>(供①画布)"]
IF2["Trait: ContextFork<br>(供②分支)"]
IF3["Trait: ProactiveHint<br>(供③主动参与)"]
IF4["Trait: PrecisionDecay<br>(供④衰减)"]
end
A6 --> COMBINED
A7 --> COMBINED
A5 --> COMBINED
COMBINED --> IF1
COMBINED --> IF2
COMBINED --> IF3
COMBINED --> IF4
```
### 实施步骤
| 步 | 内容 | 改造成本 | 用户可见收益 | 阶段 |
|----|------|---------|------------|------|
| 0 | **动态压缩阈值** — 将 sys_tokens 纳入 | 3 行 | 减少误/漏压缩 | 立即 |
| 1 | **L3 结构化摘要** — JSON 卡片替代纯文本 | ~100 行 | 压缩后上下文质量可测提升 | Phase 4 |
| 2 | **⑦ 工具命名空间** — 大结果不进主队列 | ~300 行 | tool_result 膨胀消除 | Phase 4 |
| 3 | **⑥ 工作流 DAG 注入** — 消息按节点组织 | ~500 行 | 工作流感知的精确回溯 | Phase 4 |
| 4 | **L1 WorkingContext + 分层注入** — 结构化常驻 | ~400 行 | 长对话目标保持 | Phase 5 |
| 5 | **MemoryAdapter trait 提取** — 重构 | ~200 行 | 零(纯重构) | Phase 5 |
| 6 | **版本化快照** — checkpoint 追加写 | ~150 行 | 断点恢复 | Phase 5 |
**接口预留**(不实现,仅定义 trait):
```rust
#[async_trait]
pub trait ContextNavigation { /* 供①画布实现 */ }
#[async_trait]
pub trait ContextFork { /* 供②分支实现 */ }
#[async_trait]
pub trait ProactiveHint { /* 供③主动参与实现 */ }
#[async_trait]
pub trait PrecisionDecay { /* 供④衰减实现 */ }
```
### 一句话总结
> **做 ⑥+⑦ 结构化工作流上下文,留接口给记忆和交互范式,不做全组合。**
---
## 八、多方法交叉论证
> 本章用 7 种独立方法对同一组选项交叉验证。结论收敛到同一方向则信心高,出现分歧则标注并分析原因。
### 8.1 方法概览
| # | 方法 | 分析对象 | 输出 | 与已有分析的关系 |
|---|------|---------|------|-----------------|
| A | **决策矩阵** | 3 个派系 | 加权总分排序 | 把第 6 章定性结论量化 |
| B | **成本收益分析** | 7 个发散 | 成本/收益散点图 | 补充第 5 章缺失的量化维度 |
| C | **ROI 测算** | 派系 A 实施步 | 美元/月节省 vs 开发改动单元 | 把第 7 章路线图加上财务回报 |
| D | **影响范围分析** | 7 个发散 | 波及文件 / 子系统热力图 | 补充实现成本的具体依据 |
| E | **依赖图 & 拓扑排序** | 派系 A 实施步 | 前置条件图 | 验证第 7 章步骤顺序的合理性 |
| F | **风险矩阵** | 7 个发散 | 概率×影响热力图 | 补充第 6 章冲突分析的失败模式 |
| G | **根本原因回溯** | 7 个发散 → 原始痛点 | 覆盖度矩阵 | 从问题端验证发散是否对症 |
| H | **帕累托分析** | 派系 A 实施步 | 累积收益曲线 | 找出 20% 努力得 80% 收益的关键步 |
| I | **Kano 模型** | 7 个发散 | 用户满意度分类 | 从用户感知角度验证优先级 |
---
### 8.2 方法 A:决策矩阵
对 3 个派系在 6 个加权维度上打分(1-5)。权重表示该维度对 DevFlow 当前阶段的重要性。
| 维度 | 权重 | 派系 A(工作流结构化) | 派系 B(记忆系统) | 派系 C(交互范式) | 权重理由 |
|------|------|---------------------|------------------|------------------|---------|
| 贴合现有资产 | **5** | 5 — 复用 df-workflow + ToolRegistry | 2 — 全新记忆层 | 1 — 需改协议 | Phase 4 优先利用已有代码 |
| 用户可见收益 | **4** | 5 — 压缩后仍可回溯精确行号 | 4 — 长对话不失忆 | 3 — 高级用户才感知 | 需要让用户感受到变化 |
| 实现成本 | **4** | 4 — 增量改造(~900 行) | 2 — 新状态+新算法 | 1 — 新协议+新交互 | 资源有限,成本敏感 |
| 测试覆盖度 | **3** | 4 — 可在现有单测基础上加 | 2 — 新算法需要全新测试 | 2 — 新协议需要集成测试 | 质量保障成本 |
| 长期扩展性 | **3** | 5 — 为 B/C 预留接口 | 3 — 自成体系 | 3 — 自成体系 | 不堵死未来路径 |
| 风险可控度 | **4** | 5 — 增量上线,可回退 | 3 — 新状态机,迁移风险 | 2 — 模型配合度不确定 | 线上稳定性 |
**加权总分**:
```
派系 A = 5×5 + 4×5 + 4×4 + 3×4 + 3×5 + 4×5 = 25 + 20 + 16 + 12 + 15 + 20 = 108
派系 B = 5×2 + 4×4 + 4×2 + 3×2 + 3×3 + 4×3 = 10 + 16 + 8 + 6 + 9 + 12 = 61
派系 C = 5×1 + 4×3 + 4×1 + 3×2 + 3×3 + 4×2 = 5 + 12 + 4 + 6 + 9 + 8 = 44
```
**结论**:派系 A 以 108 分大幅领先。差距主要在「贴合现有资产」(权重最高)和「实现成本」两个维度——这正是 Phase 4 阶段的核心约束。
---
### 8.3 方法 B:成本收益散点图
对 7 个发散方向估算实施成本(改动单元)和预期收益(用户可见改善度 1-10):
| 发散 | 实施成本(改动单元) | 收益(1-10) | 性价比 | 说明 |
|------|----------------|-------------|--------|------|
| ①画布导航 | 25-35 | 6 | 低 | 新传输协议 + 模型配合训练 |
| ②分支 | 15-20 | 7 | 中 | 新数据结构 + 状态机 |
| ③主动参与 | 10-15 | 5 | 中 | 需要用户行为研究 |
| ④时间轴衰减 | 12-18 | 4 | 低 | 与现有三态模型不兼容 |
| ⑤元控制器 | 8-12 | 6 | **高** | 改配置参数即可,收益面广 |
| ⑥工作流即上下文 | 10-15 | 9 | **最高** | 复用 df-workflow,DAG 注入 |
| ⑦工具命名空间 | 5-8 | 8 | **最高** | 直接消除 tool_result 膨胀根因 |
```
收益
10│ 🚩⑥
9│ 🚩⑦
8│
7│ ②
6│ ① ⑤
5│ ③
4│ ④
3│
2│
1│
└──────────────────────────▶ 成本(改动单元)
5 10 15 20 25 30
```
**结论**:⑥+⑦ 落在「低成本、高收益」象限(左上),⑤ 居中,②③ 在中成本区,①④ 在右下象限(不推荐)。
---
### 8.4 方法 C:ROI 测算
以典型高频用户(日均 30 会话,每会话 20 轮,使用 Claude Sonnet $3/M input tokens)为基准,测算派系 A 实施后的成本节省:
| 实施步 | 节省机制 | 单会话节省 | 月节省(30 日) | 实施改动单元 | ROI(月节省/改动) |
|--------|---------|-----------|---------------|---------|-------------------|
| **步 0:动态压缩阈值** | 减少误压缩 ≈ 10% 不必要的压缩 LLM 调用 | ~$0.01 | **~$9** | 0.5 | **$18/改动单元** |
| **步 1:L3 结构化摘要** | LLM 压缩时产出 JSON 摘要,后续检索少一轮 tool call | ~$0.02 | **~$18** | 2 | **$9/改动单元** |
| **步 2:工具命名空间** | 大 tool_result 不进主队列,输入减少 30-50% | ~$0.08 | **~$72** | 5 | **$14.4/改动单元** |
| **步 3:工作流 DAG 注入** | 结构化信息替代重复消息,压缩效率提升 | ~$0.03 | **~$27** | 8 | **$3.4/改动单元** |
| **步 4:WorkingContext 分层注入** | 条件注入比全量常驻省 90% L1 开销 | ~$0.01 | **~$9** | 6 | **$1.5/改动单元** |
**累计 ROI 曲线**:
```
月节省
$140│ 🟢 步2后爆发
$120│ 🟢
$100│ 🟢
$80│ 🟢
$60│ 🟢
$40│ 🟢
$20│ 🟢
0└──────────────────────────────▶ 累计改动单元
0.5 2.5 7.5 15.5 21.5
```
**关键发现**:步 2(工具命名空间)贡献了 ~50% 的总节省。如果资源只能做一件事,做步 2。
---
### 8.5 方法 D:影响范围分析
对 7 个发散方向统计影响的 Rust 文件数(含新增和修改):
| 发散 | 新增文件 | 修改文件 | 涉及 crate | 核心改动点 |
|------|---------|---------|-----------|----------|
| ①画布 | 5-7 | 8-12 | df-ai, src-tauri, df-storage | `look_at` 工具注册、画布坐标系构建、导航状态机 |
| ②分支 | 3-4 | 6-8 | df-ai, df-storage | `ContextFork` 分支存储、`parent_id`/`fork_point`、分支索引 |
| ③主动参与 | 2-3 | 4-6 | df-ai, src-tauri | `ScheduledMessage` 队列、冷却期管理器、模式识别 |
| ④衰减 | 3-4 | 5-7 | df-ai, df-ai-core | `precision` 字段、衰减函数、刷新策略 |
| ⑤元控制器 | 2-3 | 3-5 | df-ai, src-tauri | `MetaContextController` 结构体、特征窗口、参数调节器 |
| ⑥工作流 | 3-5 | 5-8 | df-ai, df-workflow, src-tauri | 工作流 DAG 序列化、节点↔消息关联、`workflow_id` |
| ⑦命名空间 | 2-3 | 4-6 | df-ai, src-tauri, df-storage | `NamespaceStore`、引用路径编码、`extract_key_info` 接线 |
**子系统热力图**:
```
①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑥工作流 ⑦命名空间
df-ai-core · · · ███ · · ·
df-ai ███ ███ ██ ██ ██ ███ ███
df-workflow · · · · · ███ ·
df-storage ██ ██ · · · · ██
src-tauri ███ ██ ███ · ███ ███ ██
前端 ██ · ██ · · ██ ·
███=大量改动 ██=中等 █=少量 ·=无
```
**结论**:
- ⑤(元控)的影响面最小却收益面广——这是高性价比的信号
- ⑥+⑦ 共同覆盖 df-ai + src-tauri 重叠区——可以合并实施,共享改造成本
- ① 涉及前端 + df-storage + df-ai + src-tauri——全栈改动,风险最大
---
### 8.6 方法 E:依赖图 & 拓扑排序
对派系 A 的实施步进行前置依赖分析:
```mermaid
graph LR
S0[步0: 动态压缩阈值] --> S1[步1: L3结构化摘要]
S1 --> S2[步2: 工具命名空间]
S2 --> S3[步3: 工作流DAG注入]
S3 --> S4[步4: WorkingContext分层注入]
S4 --> S5[步5: MemoryAdapter trait]
S5 --> S6[步6: 版本化快照]
```
**拓扑排序分析**:
| 步 | 前置依赖 | 可并行? | 关键路径? |
|----|---------|---------|-----------|
| 步 0 | 无 | ✅ 可独立上线 | 否 |
| 步 1 | 步 0(需要压缩触发确定) | ❌ 依赖 | 否(压缩已存在,只改输出格式) |
| 步 2 | 无 | ✅ 可与任何步并行 | **是**(收益最高) |
| 步 3 | 步 2(命名空间为 DAG 节点提供数据源) | ⚠️ 弱依赖 | **是**(差异化核心) |
| 步 4 | 步 1(需要结构化摘要格式经验) | ⚠️ 推荐步 1 后 | 否 |
| 步 5 | 步 0-4(重构现有逻辑) | ❌ 必须最后 | 否 |
| 步 6 | 步 2(命名空间需要持久化) | ⚠️ 弱依赖 | 否 |
**关键路径**:步 2 → 步 3 → 步 5,这是派系 A 的核心价值链。步 0 和步 1 可以在任何时间插入。
**并行窗口**:步 2 与步 1 完全并行(互不依赖),可分配给两个独立 AI agent 同时编码。步 3 和步 4 可以部分重叠(共享 WorkingContext 的数据结构定义,但注入逻辑不同)。
---
### 8.7 方法 F:风险矩阵
对 7 个发散方向评估失败概率(1-5)和失败影响(1-5),风险值 = 概率 × 影响:
| 发散 | 失败模式 | 概率 | 影响 | 风险值 | 缓解措施 |
|------|---------|------|------|--------|---------|
| ①画布 | 模型不主动使用 `look_at`,降级回传统模式 | 4 | 3 | **12** 🔴 | 需 prompt 工程 + 用户教育,不可强制 |
| ②分支 | 分支数量失控,内存爆炸 | 3 | 4 | **12** 🔴 | 设分支上限(如 5 个),超限自动合并 |
| ③主动参与 | 用户觉得烦,关掉整个功能 | 4 | 3 | **12** 🔴 | 冷却期+负面反馈自愈(依赖⑤) |
| ④衰减 | 衰减函数不匹配真实 tokenizer,精度信号无意义 | 3 | 3 | **9** 🟡 | 离线对比验证衰减曲线 vs 真实 token 分布 |
| ⑤元控制器 | 参数震荡——元控自己的参数也需要调 | 3 | 2 | **6** 🟢 | 固定多数参数,只自适应 3 个核心参数 |
| ⑥工作流 | 自由对话无工作流关联,收益为零 | 2 | 3 | **6** 🟢 | 检测 `workflow_id`,没有则跳过 DAG 注入 |
| ⑦命名空间 | 模型不理解引用语法,从不读取 namespace | 2 | 2 | **4** 🟢 | 透明降级:模型读引用时自动补全内容 |
**风险热力图**:
```
影响
5│ ·
4│ ② ①
3│ ③ · ④
2│ ⑦ ⑤ ⑥
1│ ·
└────────────────────────▶ 概率
1 2 3 4 5
🟢 低风险(⑤⑥⑦)🟡 中风险(④)🔴 高风险(①②③)
```
**重要发现**:
- 派系 A 的 ⑥+⑦+⑤ 全部落在 🟢 低风险区域
- 派系 B 的 ② 落在 🔴 高风险区域(分支爆炸)
- 派系 C 的 ①③ 都落在 🔴 高风险区域(模型不配合 + 用户反感)
- 风险分布与决策矩阵的得分分布高度一致——交叉验证通过 ✅
---
### 8.8 方法 G:根本原因回溯
从 DevFlow 中已确认的上下文管理痛点出发,看每个发散方向是否直接对症:
| 痛点 | 严重度 | 根因 | 对症的发散 | 不对症的发散 |
|------|--------|------|-----------|-------------|
| **P1: tool_result 膨胀撑爆窗口** | 🔴 P0 | `read_file` 等工具返回大量文本全部挤入主队列 | **⑦**命名空间(直接消除)| ①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑥工作流 |
| **P2: 压缩后目标丢失** | 🔴 P0 | `compressed` 消息不进 LLM,目标信息消失 | **⑥**工作流DAG保留结构化产出 | ①画布 ②分支 ③主动 ④衰减 ⑤元控 ⑦命名空间 |
| **P3: 中断恢复模糊** | 🟡 P1 | `build_for_request` 只恢复窗口内消息,不恢复会话状态 | **⑥+⑦**(DAG+namespace 双重恢复) | ①画布 ②分支 ③主动 ④衰减 |
| **P4: 触发时机不当** | 🟡 P1 | 压缩阈值硬编码,不考 system prompt 大小 | **⑤**元控制器(动态调参)| ①画布 ②分支 ③主动 ④衰减 ⑥ ⑦ |
| **P5: 多方案对比困难** | 🟢 P2 | 线性历史无法表达多方案岔路,需切换分支 | **②**分支(直接解决) | ①画布 ③主动 ④衰减 ⑤元控 ⑥ ⑦ |
**覆盖度统计**:
| 发散方向 | 覆盖痛点数 | 覆盖的痛点 | 错过的痛点 |
|---------|-----------|-----------|-----------|
| ⑥工作流 | 2(P2, P3) | 目标丢失、中断恢复 | P1 tool膨胀、P4触发时机、P5多方案 |
| ⑦命名空间 | 2(P1, P3) | tool膨胀、中断恢复 | P2目标丢失、P4触发时机、P5多方案 |
| ⑤元控制器 | 1(P4) | 触发时机 | 其他 4 个 |
| ②分支 | 1(P5) | 多方案对比 | 其他 4 个 |
| ①画布 | 0 | — | 全部 5 个 |
| ③主动参与 | 0 | — | 全部 5 个 |
| ④时间轴衰减 | 0 | — | 全部 5 个 |
**结论**:⑥+⑦ 覆盖了 P0 和 P1 级的 3 个痛点(P1, P2, P3),合计覆盖 3/5 = 60% 的已知痛点。加上⑤覆盖 P4 后达到 4/5 = 80%。②覆盖 P5 后达到 100%。路径清晰:⑥+⑦ → +⑤ → +②。
---
### 8.9 方法 H:帕累托分析
对派系 A 的各实施步,计算累计收益占总收益的百分比:
| 实施步 | 累计改动单元 | 月节省 | 累计节省 | 累计节省占比 | 累计改动单元占比 |
|--------|---------|--------|---------|------------|------------|
| 步 0:动态阈值 | 0.5 | $9 | $9 | 6.7% | 2.3% |
| 步 1:结构化摘要 | 2.5 | $18 | $27 | 20.0% | 11.6% |
| **步 2:工具命名空间** | 7.5 | $72 | $99 | **73.3%** 🔥 | 34.9% |
| 步 3:工作流 DAG | 15.5 | $27 | $126 | 93.3% | 72.1% |
| 步 4:WorkingContext | 21.5 | $9 | $135 | 100% | 100% |
```
累计节省占比
100%│ ●
75%│ ● ← 步2: 20%努力→73%收益
50%│
25│ ●
0%│──●────●───────────────────────────▶ 累计改动单元占比
0% 2.3% 11.6% 34.9% 72.1%
```
**帕累托结论**:步 0-2(前 35% 努力)贡献 73% 收益。步 2(工具命名空间)是唯一的"低挂果实"——5 改动单元工作量,月节省 $72,占全部收益的 53%。**建议立即启动步 2,步 3-4 视资源情况决策。**
---
### 8.10 方法 I:Kano 模型
从用户感知角度将 7 个发散方向分类:
| 发散方向 | Kano 分类 | 判断依据 |
|---------|----------|---------|
| ⑤元控制器 | **基本型** | 用户不直接感知,但参数不当会导致对话中断(负面体验) |
| ⑦工具命名空间 | **基本型 → 性能型** | 当前 tool 膨胀可接受但痛苦(Sprint 6 P1),改善后用户不说但感觉流畅 |
| ⑥工作流即上下文 | **性能型** | 用户使用工作流时体验飞跃,自由对话时完全无感 |
| ②上下文分支 | **性能型** | 多方案探索用户明显受益,单线用户不关心 |
| ④时间轴衰减 | **无差异型** | 精度衰减在对话中无法被用户察觉(除非看日志) |
| ①画布导航 | **魅力型** | 高级用户会惊叹"AI 自己知道要看哪里",但普通用户不敏感 |
| ③主动参与 | **魅力型 → 反向型** | 做得好是惊喜,做不好是骚扰。双刃剑 |
**Kano 对实施顺序的指导**:
```mermaid
graph LR
subgraph "先做(基本型→性能型)"
A["⑤元控制器<br>(基本型,不做会出问题)"]
B["⑦工具命名空间<br>(性能型,做了用户觉得流畅)"]
end
subgraph "再做(性能型)"
C["⑥工作流即上下文<br>(差异化竞争力)"]
D["②分支<br>(特定场景加分)"]
end
subgraph "可选"
E["①画布<br>(魅力型,成本高)"]
F["③主动参与<br>(双刃剑,需谨慎)"]
G["④衰减<br>(无差异,不做)"]
end
A --> B
B --> C
C --> D
```
**结论**:Kano 模型给出的优先级顺序与决策矩阵、ROI、帕累托的分析高度一致——⑤→⑦→⑥→②→①③可选→④跳过。这是第 9 种独立方法得出同一结论,信心再度增强。
---
## 九、论证收敛总表
### 9.1 各方法结论汇合
| 方法 | 收敛结论 | 与主线是否一致 |
|------|---------|--------------|
| 推演法(第 4-6 章) | 派系 A > B > C,⑥+⑦ 为最优起点 | —(主线基准) |
| **A 决策矩阵** | 派系 A 108 > B 61 > C 44 | ✅ 一致 |
| **B 成本收益散点** | ⑥+⑦ 在低成本高收益象限 | ✅ 一致 |
| **C ROI 测算** | 步 2 月节省 $72,5 改动单元回收 | ✅ 一致 |
| **D 影响范围** | ⑥+⑦ 影响面可控,集中在 df-ai | ✅ 一致 |
| **E 拓扑排序** | 步 2 和步 1 可并行,关键路径清晰 | ✅ 一致 |
| **F 风险矩阵** | ⑥+⑦+⑤ 全 🟢,①②③ 全 🔴 | ✅ 一致 |
| **G 根本原因回溯** | ⑥+⑦ 覆盖 3/5 痛点,② 补 1 个 | ✅ 一致 |
| **H 帕累托分析** | 35% 努力得 73% 收益,步 2 为低挂果实 | ✅ 一致 |
| **I Kano 模型** | ⑤基本→⑦性能→⑥性能→②性能 | ✅ 一致 |
**9 种独立方法结论全部收敛,无分歧。** 这是强信号。
### 9.2 最终建议
```
立即启动(步 0 + 步 2):
动态压缩阈值(3 行) + 工具命名空间(5 改动单元)
→ 消除 tool 膨胀主因,月省 $81
Phase 4 追加(步 1 + 步 3):
L3 结构化摘要(2 改动单元) + 工作流 DAG 注入(8 改动单元)
→ 差异化竞争力,累计月省 $135
Phase 5 完善(步 4 + 步 5 + 步 6):
WorkingContext 分层注入 + MemoryAdapter trait + 版本化快照
→ 架构完善,累计月省 $135+
不做:
画布导航(①)、时间轴衰减(④)—— 收益成本比低
主动参与(③)、分支(②)—— Kano 双刃剑,待用户需求信号出现后再评估
预留接口:
ContextNavigation / ContextFork / ProactiveHint / PrecisionDecay
→ 4 个 trait,仅定义不实现(实现零行)
```
+3
View File
@@ -60,6 +60,9 @@ docs/
│ ├── secret下沉与provider注入方案-2026-06-16.md # secret 纯密钥下沉 df-storage 方案
│ ├── 推进链阶段2实施路径-2026-06-16.md # advance_task 走 df-nodes 实施路径
│ ├── 意图识别层论证-2026-06-19.md # 通用前置意图识别层 8 维度论证
│ ├── 上下文管理演进与发散思考-2026-07-20.md # 上下文管理现状评估、业界对标、7个发散方向与组合分析
│ ├── 上下文管理演进-任务技术设计-2026-07-20.md # T2命名空间/T4工作流DAG/T3结构化摘要技术设计与推演
│ ├── 上下文管理演进-执行计划-2026-07-20.md # Wave 划分、agent 并行策略、接口契约、验证 gate、回滚策略
│ ├── 多主题上下文管理愿景-2026-06-19.md # 多主题多摘要愿景(远期方向,关联 F-15/意图识别)
│ └── 查询效率优化方案-2026-06-19.md # 查询链路优化(SQL 下推/精确拉取/缓存/字段投影)
├── 03-模块文档/
@@ -4,7 +4,7 @@
//!
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
//!
//! 流程(对齐阶段2 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! 流程(对齐 ai_chat_compress_context IPC 的 read-but-don't-mutate 模式):
//! ① 读 active 克隆(不改 status / 不扣 token)→ 喂 LLM 出摘要;
//! ② LLM 成功 → compress_old_messages(标 compressed + 扣 token)+ insert_at(摘要 system);
//! ③ LLM 失败 → 消息状态完全不变(未改 status / 未扣 token),降级走原 build_for_request 裁剪。
@@ -17,18 +17,18 @@
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
//!
//! 安全(FR-S1):复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
//! 安全:复用 loop 顶部已 build+验证 的 provider(不再 build_provider_for 重复 resolve
//! keyring),api_key 经 df_storage::secret 闭环;summary/error payload/日志均不含 api_key。
//! is_compressing 防重入:set_compressing(true/false) 成对(LLM 调用前后均复位)。
//! 单轮问答(history_tokens 未超 0.6*budget)不触发,零行为变化。
//! F-260616-09 B 批2:messages 操作改 per_conv(设计 §4.2)。
//! messages 操作改 per_conv。
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
use tokio::sync::Mutex;
use df_ai::context_helpers::extract_keyword_summary;
use df_ai::context_helpers::{extract_keyword_summary, CompressedSummary};
use df_ai::provider::{ChatMessage, LlmProvider};
use df_storage::models::AiProviderRecord;
@@ -51,7 +51,7 @@ const PROTECT_COUNT: usize = 6;
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
const KEYWORD_FALLBACK_ENABLED: bool = true;
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
/// 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
///
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
@@ -69,6 +69,7 @@ pub(super) async fn maybe_auto_compress(
provider_config: &AiProviderRecord,
llm_concurrency: &LlmConcurrency,
iteration: usize,
sys_tokens: u32,
) -> bool {
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
if !prev_compressing {
@@ -85,10 +86,10 @@ pub(super) async fn maybe_auto_compress(
let mgr = &mgr.messages;
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
let history_tokens = mgr.history_tokens();
let budget = mgr.budget_limit();
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
let available = mgr.budget_limit().saturating_sub(sys_tokens);
// 触发阈值 0.6*available(可用预算=budget-sys_tokens):available*6/10 < history_tokens
let should = protect_start > 0
&& (budget as u64) * 6 / 10 < history_tokens as u64
&& (available as u64) * 6 / 10 < history_tokens as u64
&& mgr.has_compressible_messages(protect_start);
(should, protect_start, history_tokens)
};
@@ -99,9 +100,9 @@ pub(super) async fn maybe_auto_compress(
conversation_id: Some(conv_id.to_string()),
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
let (active_msgs, lang) = {
let (mut active_msgs, lang) = {
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
conv.messages.set_compressing(true);
@@ -117,6 +118,30 @@ pub(super) async fn maybe_auto_compress(
(active_msgs, lang)
};
// T2-修复(方案A):展开 namespace 引用为真实内容,再喂压缩 LLM/关键词摘要。
// 同 agentic/mod.rs 的 build_for_request 出口展开。未展开则压缩 LLM 只看到
// "namespace://..." 占位符,摘要基于占位符丢主题。read_only None(条目被 LRU 淘汰 /
// 重启后 namespace 清空 / 跨会话残留引用)→ 替换为 EVICTED_PLACEHOLDER 提示文案
// (非保留无意义 namespace:// URI)+ warn 日志。
{
let session = session_arc.lock().await;
for m in &mut active_msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
let path = m.content.clone();
if let Some(original) = session.namespace_store.read_only(&path) {
m.content = original.to_string();
} else {
tracing::warn!(
conv_id = %conv_id,
path = %path,
"[namespace] 引用已淘汰,替换为提示文案"
);
m.content = df_ai::namespace_store::EVICTED_PLACEHOLDER.to_string();
}
}
}
}
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
@@ -128,7 +153,7 @@ pub(super) async fn maybe_auto_compress(
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
let compress_outcome: Result<Option<CompressedSummary>, String> = if active_msgs.is_empty() {
Ok(None)
} else {
compress_via_llm(
@@ -144,15 +169,14 @@ pub(super) async fn maybe_auto_compress(
match compress_outcome {
Ok(Some(summary)) => {
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
{
let mut session = session_arc.lock().await;
let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start);
conv.messages.insert_at(0, ChatMessage::system(&summary));
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn)
}
tracing::info!(
conv_id = %conv_id,
@@ -165,10 +189,10 @@ pub(super) async fn maybe_auto_compress(
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
let ev = AiChatEvent::AiAutoCompressed {
conversation_id: Some(conv_id.to_string()),
summary,
summary: summary.nl_summary,
};
let _ = app_handle.emit("ai-chat-event", ev.clone());
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
// L3 emit 双写:tunnel subscriber透传 miniapp
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
}
Ok(None) => {
@@ -216,7 +240,7 @@ pub(super) async fn maybe_auto_compress(
);
}
}
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
@@ -15,9 +15,9 @@
//! - **读侧收敛**:停止按钮三态(可停 / 停中 / 停失败可重试)、MaxRoundsCard 是否弹等
//! 判别逻辑由 `ConvState` 变体直接表达,不再靠多变量组合反推。
//!
//! # 收口后(批3+)
//! # 当前架构
//!
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。批3 双轨收口后 `generating` bool
//! 本模块是**纯逻辑、无 IO**的 enum + 转换守卫。`generating` bool
//! 与 `CONV_STATE_ENABLED` 开关已退役,`ConvState` 成为唯一真相源:guard 接入点
//! (`GeneratingGuard` new/reset/drop 时同步迁移 `ConvState`)无条件执行迁移 + emit。
//!
@@ -52,7 +52,7 @@ use serde::{Deserialize, Serialize};
/// `ConvState` 是面向「生成生命周期」的写侧真相(写收敛)。两者正交:如 `Generating` 态
/// 同时有审批挂起时,`ConvState=Generating` 而 `SessionState=AwaitingApproval`。
///
/// 序列化(`Serialize`/`Deserialize`):批3 前端经事件总线读 enum 视图时使用(本批未接,
/// 序列化(`Serialize`/`Deserialize`):前端经事件总线读 enum 视图时使用(本批未接,
/// 预留,避免后续改动序列化兼容性)。
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
@@ -150,9 +150,7 @@ impl ConvState {
/// 审批挂起(读视图 `SessionState::AwaitingApproval`)期间 `ConvState` 仍是 `Generating`,
/// 故本方法返回 true——审批挂起不算「停止生成」。
///
/// 预留:批2+ 读侧迁移(try_continue / ai_chat_stop / 前端停止三态)消费。
/// L2 读侧迁移(2026-06-22):已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,
/// 不再标 allow(dead_code)(有真实消费方)。
/// 已接入 ai_is_generating / ai_chat_stop / try_continue_agent_loop,不再标 allow(dead_code)。
pub fn is_active(self) -> bool {
matches!(self, ConvState::Generating | ConvState::Compressed)
}
@@ -162,8 +160,8 @@ impl ConvState {
/// 读侧便利方法:用于 `ai_conversation_create` / `ai_chat_send` 等入口判断
/// 「能否接新请求」。`Error` 态视为可接(用户重试即从 Error 起步)。
///
/// 双轨收口批1(2026-06-25):chat 域入口拦截(ai_regenerate / ai_chat_send /
/// ai_chat_edit)已接入,作真实读侧方法消费。删除 `allow(dead_code)` 标注。
/// chat 域入口拦截(ai_regenerate / ai_chat_send /
/// ai_chat_edit)已接入,作真实读侧方法消费。
pub fn can_accept_request(self) -> bool {
matches!(self, ConvState::Idle | ConvState::Error)
}
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::commands::ai::knowledge_inject::inject_knowledge_into_prompt;
use crate::commands::ai::{AiChatEvent, ErrorType, SessionState, GoalEntry};
use super::conv_state::ConvState;
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
/// try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
struct ContinueSnapshot {
is_generating: bool,
has_pending: bool,
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,186 @@
//! 工作流 DAG 上下文注入 — 当会话关联工作流时,将活跃路径注入 system prompt
//!
//! T4 实现。提供两个函数:
//! - `build_dag_summary`: 从 DagDef 构建可读的 DAG 摘要文本
//! - `extract_active_path`: 从完整 DAG 中提取当前活跃路径(简化)
//!
//! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入
//! `PerConvState.workflow_dag_summary`,agentic loop 自动注入 system prompt。
use df_workflow::dag_def::DagDef;
/// 从 DagDef 构建可读的 DAG 摘要文本
///
/// 输出格式(5 节点 DAG 为例):
/// ```
/// 步骤: 读源码 → 分析依赖 → 改配置 → 验证 → 提交
/// 当前: 改配置
/// 完成: 2/5
/// ```
pub fn build_dag_summary(dag: &DagDef, current_node_id: Option<&str>) -> String {
let total = dag.nodes.len();
if total == 0 {
return String::new();
}
// 拓扑排序(简化:按 edges 计算入度,分层输出)
let layers = topological_layers(dag);
// 构建步骤链
let step_chain: Vec<&str> = layers.iter()
.flat_map(|layer| layer.iter().map(|id| id.as_str()))
.collect();
let chain_text = step_chain.join(" → ");
// 当前节点
let current_label = current_node_id
.and_then(|id| dag.nodes.get(id))
.map(|n| n.label.as_deref().unwrap_or(&n.node_type))
.unwrap_or("");
let mut result = format!("步骤: {}\n", chain_text);
if !current_label.is_empty() {
result.push_str(&format!("当前: {}\n", current_label));
}
result.push_str(&format!("完成: 待执行器上报"));
result
}
/// 从 DagDef 构建活跃路径(从根节点到当前节点的路径 + 后续 2 层)
///
/// 返回简化的步骤链文本,适合注入 system prompt。
pub fn extract_active_path(dag: &DagDef, current_node_id: Option<&str>) -> Vec<String> {
let layers = topological_layers(dag);
if let Some(current) = current_node_id {
// 找到当前节点所在的层
let current_layer_idx = layers.iter().position(|layer| layer.iter().any(|x| x == current));
match current_layer_idx {
Some(idx) => {
// 从根到当前层的路径
let path: Vec<String> = layers[..=idx].iter()
.flat_map(|layer| layer.iter().map(|id| {
dag.nodes.get(id)
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
.unwrap_or_else(|| id.clone())
}))
.collect();
// 后续 2 层
let next: Vec<String> = layers[idx + 1..]
.iter()
.take(2)
.flat_map(|layer| layer.iter().map(|id| {
dag.nodes.get(id)
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
.unwrap_or_else(|| id.clone())
}))
.collect();
let mut result = path;
result.extend(next);
result
}
None => {
// 当前节点不在 DAG 中 → 返回所有节点标签
layers.iter().flat_map(|layer| layer.iter().map(|id| {
dag.nodes.get(id)
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
.unwrap_or_else(|| id.clone())
})).collect()
}
}
} else {
// 无当前节点 → 返回所有节点标签
layers.iter().flat_map(|layer| layer.iter().map(|id| {
dag.nodes.get(id)
.map(|n| n.label.clone().unwrap_or_else(|| id.clone()))
.unwrap_or_else(|| id.clone())
})).collect()
}
}
/// 拓扑分层排序:按入度计算每层的节点
fn topological_layers(dag: &DagDef) -> Vec<Vec<String>> {
let mut in_degree: std::collections::HashMap<&str, usize> = dag.nodes.keys().map(|k| (k.as_str(), 0)).collect();
for edge in &dag.edges {
if let Some(deg) = in_degree.get_mut(edge.target.as_str()) {
*deg += 1;
}
}
let mut layers = Vec::new();
let mut remaining: std::collections::HashSet<&str> = dag.nodes.keys().map(|k| k.as_str()).collect();
while !remaining.is_empty() {
let current_layer: Vec<String> = remaining.iter()
.filter(|id| *in_degree.get(*id).unwrap_or(&0) == 0)
.map(|id| id.to_string())
.collect();
if current_layer.is_empty() {
// 有环 → 剩余节点全放同一层
layers.push(remaining.iter().map(|id| id.to_string()).collect());
break;
}
for id in &current_layer {
remaining.remove(id.as_str());
// 减少以该节点为起点的边的入度
for edge in &dag.edges {
if edge.source == *id {
if let Some(deg) = in_degree.get_mut(edge.target.as_str()) {
*deg = deg.saturating_sub(1);
}
}
}
}
layers.push(current_layer);
}
layers
}
#[cfg(test)]
mod tests {
use super::*;
use df_workflow::dag_def::{DagDef, NodeDef, EdgeDef};
fn make_dag() -> DagDef {
let mut dag = DagDef::new();
dag.add_node("n1".to_string(), "read_file".to_string(), serde_json::json!({}));
dag.add_node("n2".to_string(), "analyze".to_string(), serde_json::json!({}));
dag.add_node("n3".to_string(), "modify".to_string(), serde_json::json!({}));
dag.add_edge("n1".to_string(), "n2".to_string());
dag.add_edge("n2".to_string(), "n3".to_string());
dag
}
#[test]
fn test_topological_layers_linear() {
let dag = make_dag();
let layers = topological_layers(&dag);
assert_eq!(layers.len(), 3);
assert_eq!(layers[0], vec!["n1"]);
assert_eq!(layers[1], vec!["n2"]);
assert_eq!(layers[2], vec!["n3"]);
}
#[test]
fn test_extract_active_path_midway() {
let dag = make_dag();
let path = extract_active_path(&dag, Some("n2"));
assert_eq!(path.len(), 3); // n1, n2, n3
assert_eq!(path[1], "n2");
}
#[test]
fn test_build_dag_summary_empty() {
let dag = DagDef::new();
assert_eq!(build_dag_summary(&dag, None), "");
}
}
+32 -16
View File
@@ -1,4 +1,4 @@
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
//! 审批门控逻辑。
//!
//! 审批策略(方案 B:Persona 维度 + 扩展点):
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
@@ -19,7 +19,7 @@ use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
use super::diff::build_write_file_diff;
use super::reason::build_approval_reason;
use super::record::audit_tool_call;
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, is_destructive_command, trust_key_for, TrustKey};
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
///
@@ -88,12 +88,12 @@ pub(super) fn should_auto_for_persona(
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
}
/// AE-2025-04 检查会话信任命中。
/// 检查会话信任命中。
///
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
pub(super) async fn check_trust_hits(
draft: &ToolCallDraft,
@@ -102,6 +102,22 @@ pub(super) async fn check_trust_hits(
conv_id: &str,
) -> Option<TrustKey> {
let key = trust_key_for(&draft.name, args)?;
// 安全门控(run_command 会话信任按目录不按命令的补丁):
// run_command 的信任 key 只用 working_dir,一次批准 → 同目录任意命令 auto。
// 破坏性命令(rm/del/format/... 不可逆)绝不 auto 放行,即使同目录已批准过,
// 也必须每轮走正常审批。命中黑名单直接返 None,让流程继续走 pending 审批。
if matches!(key, TrustKey::Execute { .. }) {
if let Some(cmd) = args.get("command").and_then(|v| v.as_str()) {
if is_destructive_command(cmd) {
tracing::info!(
tool = %draft.name,
new_tool_call_id = %draft.id,
"[会话信任] 命中破坏性命令黑名单,跳过 auto 放行(走正常审批)"
);
return None;
}
}
}
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
let hit = {
let session = session_arc.lock().await;
@@ -118,7 +134,7 @@ pub(super) async fn check_trust_hits(
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
pub(super) async fn insert_pending_approval(
@@ -132,7 +148,7 @@ pub(super) async fn insert_pending_approval(
db: &Arc<Database>,
current_message_id: Option<&str>,
) {
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
// (路径 B):write_file 挂起审批前预读旧文件生成 diff。
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
@@ -142,7 +158,7 @@ pub(super) async fn insert_pending_approval(
None
};
// 阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// (容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 与 High risk 的 find_cached_high_risk_result 互补:去重按 (tool_name,args) 匹配
// (High only),本 guard 按 tc_id 匹配(覆盖 Med + High 残留场景)。
// 同 tc_id 已有审计落定记录 → retry_count≥1,跳过审批 + emit Completed,断死循环。
@@ -179,12 +195,12 @@ pub(super) async fn insert_pending_approval(
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
// 阶段3a:普通 RiskLevel 审批标 kind=Risk{diff}(下沉原 diff 字段)。
// 普通风险审批标 kind=Risk{diff}(下沉原 diff 字段)。
kind: ApprovalKind::Risk { diff: approval_diff.clone() },
retry_count,
created_at: Some(std::time::SystemTime::now()),
});
// 阶段2:占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
// 占位带 __PENDING__:tc_id 标记,供 sanitize 豁免保留 + 出口断言自愈(防 400 orphan)
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
}
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
@@ -203,7 +219,7 @@ pub(super) async fn insert_pending_approval(
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "pending", risk_level, None, None, current_message_id).await;
}
/// 阶段4(容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
/// (容错/恢复,开关 `df-ai-approval-retry`):查审计表推算同 tc_id 重试计数。
///
/// 返回语义:
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
@@ -224,7 +240,7 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
None => return 0, // 无记录 = 首次
},
Err(e) => {
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
tracing::warn!("[approval-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
return 0;
}
};
@@ -240,10 +256,10 @@ pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id:
///
/// 1. 按 `auto_exec_mode`(low/medium/all) + `risk_level` + `patch_file` 小改动特例
/// 判定是否应自动执行(`classify_risk_and_auto`)。若是 → 推入 `low_risk` 返回。
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ F-05 高危去重缓存 → 阶段4 重试 guard →
/// 2. 否则走审批分支:会话信任(`check_trust_hits`)→ 高危去重缓存 → 重试 guard →
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
///
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
/// 签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
@@ -270,9 +286,9 @@ pub(super) async fn handle_approval_tool(
}
// ── Step 2: 会话信任检查 ──
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
// 会话级信任(Session Trust):首批 write_file / run_command,
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
// 命中后走与 F-05 去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
// 命中后走与去重命中相似的「直接执行 + Completed + 审计 decided_by=auto_trust」路径,
// 但与 F-05 不同:F-05 复用缓存 tool_result 跳过执行;trust 放行**真实执行工具**
// (用户信任同目录同类操作,但仍要看每次的真实结果)。
if let Some(key) = check_trust_hits(&draft, &args, session_arc, conv_id).await {
@@ -283,7 +299,7 @@ pub(super) async fn handle_approval_tool(
tool = %draft.name,
dir = %dir_label,
new_tool_call_id = %draft.id,
"[AE-2025-04] 会话信任命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
"[会话信任] 命中: 同会话已批准同类操作,自动放行(跳过审批+二次确认)"
);
// emit 轻量 toast 事件(前端 AiChat.vue 显示"🔓 自动放行: tool(dir)")
// L3 emit 双写:会话信任自动放行 toast 双路发布(tunnel 透传 miniapp 即时反馈)。
+12 -12
View File
@@ -1,4 +1,4 @@
//! F-260616-05 高危工具去重缓存(第三批 helper 抽离,行为零变更)。
//! 高危工具去重缓存。
//!
//! 从 audit/mod.rs 搬迁:`find_cached_high_risk_result` + `canonical_args_key`
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
@@ -18,14 +18,14 @@ use super::super::AiSession;
/// (find_cached_high_risk_result) 各持一份字面量致耦合——若两者漂移,
/// 去重会把 pending 占位误判为已落定结果命中缓存,污染 LLM 上下文。
///
/// **阶段2(占位配对完整性)**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// **占位配对完整性**:实际写入 messages 时用 [`pending_placeholder_for`] 生成带
/// `__PENDING__:tc_id` 标记的完整占位文本,供 sanitize 识别"占位不可裁 + 出口断言自愈补头"。
/// 本常量保留为基础文本(向前兼容 + 去重匹配基准)。
pub(crate) const PENDING_APPROVAL_PLACEHOLDER: &str = "需要用户审批,等待确认";
/// 构造带唯一标记(`__PENDING__:tc_id`)的审批挂起占位 tool_result 内容。
///
/// 阶段2 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 解 400 orphan:占位 tool_result 内嵌 tc_id 标记,sanitize step3.5(反向 orphan 检测)
/// 据此把占位豁免保留(不丢),出口断言 `assert_placeholder_pairing` 据此补 TOOL_MISSING_PREFIX
/// 占位头自愈,使占位 result 与(可能被裁掉的)tool_call 头闭合配对,防 provider 400 orphan。
///
@@ -40,15 +40,15 @@ pub(crate) fn pending_placeholder_for(tc_id: &str) -> String {
/// 判定 tool_result content 是否为审批挂起占位(含基础文本或带 __PENDING__ 标记)。
///
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(阶段2 占位带 tc_id 标记后
/// 替代原裸 `msg.content == PENDING_APPROVAL_PLACEHOLDER` 精确匹配(占位带 tc_id 标记后
/// 不再精确等于基础文本,需用子串/标记匹配)。兼容老占位(纯文本)与新占位(带标记)。
pub(crate) fn is_pending_placeholder(content: &str) -> bool {
df_ai::context_helpers::is_pending_placeholder(content)
}
/// F-260616-05:高危工具去重(根治 run_command 超时→重试→重新审批循环)。
/// 高危工具去重(根治 run_command 超时→重试→重新审批循环)。
///
/// ⚠ 性能注记(CR-260618-11#5):本函数在 session lock 持有期间对每个 high risk 工具
/// ⚠ 性能注记:本函数在 session lock 持有期间对每个 high risk 工具
/// 串行查 AiToolExecutionRepo::find_by_tool_call_id,工具数多时锁持有线性增长。
/// 批量预取(改签名传 tool_call_ids 批量查)属架构改暂未做,后续 High risk 工具增多时优先处理。
///
@@ -70,7 +70,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
///
/// `session` 只读扫描 messages(不写),调用方据返回值决定是否跳过 insert pending。
///
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
/// 经`conv_id` 索引 per_conv.messages(顶层 messages 在拆分存储后是死字段)。
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
pub(crate) async fn find_cached_high_risk_result(
session_arc: &Arc<Mutex<AiSession>>,
@@ -84,12 +84,12 @@ pub(crate) async fn find_cached_high_risk_result(
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
let new_args_key = canonical_args_key(args);
// BUG-260624-03/P0 重构:短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
// 短 lock 段读 messages + 找旧 tool_call_id + 旧 tool_result content,
// drop 锁后再锁外 await DB 查 status(原代码持锁 await audit_repo,违反持锁 await 慢操作禁令)。
// 第一步:锁内(async block 包裹,出 block 自动 drop guard)反向扫描,定位旧 tool_call_id 与对应 tool_result content
let cached: Option<(String, String)> = (async {
let session = session_arc.lock().await;
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
// 读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
let Some(conv) = session.conv_read(conv_id) else { return None };
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
@@ -134,7 +134,7 @@ pub(crate) async fn find_cached_high_risk_result(
continue;
}
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
// 占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
@@ -167,7 +167,7 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
serde_json::to_string(&v).unwrap_or_default()
}
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
/// 只读幂等工具结果缓存(治 LLM 死循环重调)。
///
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
@@ -205,7 +205,7 @@ pub(crate) async fn find_cached_readonly_result(
let new_args_key = canonical_args_key(args);
// BUG-260624-03/P0 重构:短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
// 短 lock 段读 messages + 锁外 await DB 查 status(原代码持锁 await)
let cached: Option<(String, String)> = (async {
let session = session_arc.lock().await;
let Some(conv) = session.conv_read(conv_id) else { return None };
+3 -3
View File
@@ -1,15 +1,15 @@
//! AE-2025-03(路径 B):write_file 审批预览 diff 生成。
//! write_file 审批预览 diff 生成。
//!
//! 第五批从 audit/mod.rs 抽离,行为零变更。`build_write_file_diff` 仅 process_tool_calls
//! 在 write_file 高风险审批分支裸名调用,依赖 `super::super::tool_registry::generate_diff`
//! (F-260615-10 LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
//! (LCS 行级 diff),故 diff.rs 直接 `use super::super::tool_registry::generate_diff`。
//!
//! re-export 保持原路径透明:audit/mod.rs 通过 `use diff::build_write_file_diff` 裸名调用。
use super::super::tool_registry::generate_diff;
/// 从 write_file args 取 path(旧文件路径)+ content(新内容),
/// 预读旧文件 → 复用 `generate_diff`(F-260615-10 LCS 行级 diff)注入审批事件。
/// 预读旧文件 → 复用 `generate_diff`(LCS 行级 diff)注入审批事件。
/// 旧文件不存在(新建)/ 读失败 / args 缺字段 → None(前端回退显新 content)。
///
/// **仅读不改**:审批未通过前不动文件;读路径不校验(write_file handler 自身会校验
+1 -1
View File
@@ -14,7 +14,7 @@ use crate::state::AppState;
/// 导致审批后审计记录永久卡 pending。
pub(crate) async fn audit_finalize(state: &AppState, tool_call_id: &str, status: &str, result: Option<String>) {
// 区分 Err(DB 故障)与 Ok(None)(真无记录):原 unwrap_or_default 把 Err 压成 None,
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索(B-260617-17 同款吞错)。
// DB 故障被「未找到」日志掩盖,审批后审计记录卡 pending 无确诊线索。
let mut rec = match state.ai_tool_executions.find_by_tool_call_id(tool_call_id).await {
Ok(Some(rec)) => rec,
Ok(None) => {
@@ -1,4 +1,4 @@
//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。
//! 灵感来源(create_idea)消息级溯源补全。
//!
//! ## 背景
//! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea`
+71 -33
View File
@@ -20,16 +20,16 @@ use super::{AiChatEvent, AiSession, ApprovalKind, PathAuthRequest, PendingApprov
mod utils;
pub(crate) use utils::{risk_from_str, risk_str, truncate_chars};
// diff(audit/diff.rs):AE-2025-03 write_file 审批预览 diff 生成。
// diff(audit/diff.rs):write_file 审批预览 diff 生成。
// 第五批从本文件抽离,行为零变更。
mod diff;
// path_auth(audit/path_auth.rs):F-260619-03 Phase B/C 路径授权预校验。
// path_auth(audit/path_auth.rs):路径授权预校验。
// 第六批从本文件抽离,行为零变更。pub(super) use 供 tests 子模块引用 + process_tool_calls 裸名调用。
mod path_auth;
pub(super) use path_auth::{check_file_tool_auth, FileToolAuthOutcome};
// approval(audit/approval.rs):F-#97/AE-04/阶段4 审批门控逻辑。
// approval(audit/approval.rs):审批门控逻辑。
// 第六批从本文件抽离,行为零变更。use 保持 process_tool_calls 裸名调用。
mod approval;
use approval::{detect_retry_count, handle_approval_tool};
@@ -63,8 +63,8 @@ pub use restore::restore_pending_approvals;
mod finalize;
pub(crate) use finalize::audit_finalize;
// cache(audit/cache.rs):F-260616-05 高危工具去重缓存 + BUG-2026-07-07/P0-2 只读工具缓存。
// 第三批从本文件抽离,行为零变更(P0-2 为新增能力)。
// cache(audit/cache.rs):高危工具去重缓存 + 只读工具缓存。
// 第三批从本文件抽离,行为零变更。
mod cache;
pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
@@ -74,7 +74,7 @@ pub(super) use cache::{find_cached_readonly_result, pending_placeholder_for};
mod data_change;
pub(crate) use data_change::emit_data_changed;
// idea_source(audit/idea_source.rs):F-260619-04 P2(方案 B)灵感来源消息级溯源补全。
// idea_source(audit/idea_source.rs):灵感来源消息级溯源补全。
// create_idea 工具执行后,若 AI 未填 source 且有 message_id → 补 conv_msg:{id}(低侵入,不改 handler 接口)。
// pub(crate) use 供本文件 process_tool_calls + chat.rs 审批执行路径调用(单点逻辑,多调用点)。
mod idea_source;
@@ -89,7 +89,7 @@ pub(crate) use idea_source::maybe_fill_idea_source;
/// 工具执行 + 心跳保活:execute 期间每 30s emit AiHeartbeat(对齐 stream_recv.rs
/// stream_llm select! 心跳语义),execute 完 abort 心跳 task。
///
/// 根治 BUG-260624-03:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
/// 根治:工具执行在 stream_llm 之外(本模块),原本无 AiHeartbeat。
/// 单次 execute 超过前端 STREAM_TIMEOUT_MS(130s)——bash 跑 cargo/测试、read 大文件、
/// 全盘 search 等开发长命令——前端 watchdog 误判断流,抛"工具已执行完成后续中断"误报
/// (实测:用户报"一边流一边抛",前一轮 delta 文本在屏 + 当前轮工具执行静默 > 130s)。
@@ -137,19 +137,35 @@ async fn execute_with_heartbeat(
});
// RAII:execute 无论 Ok/Err/panic(unwind),_guard drop 自动 stop+abort,无心跳 task 泄漏。
let _guard = HeartbeatGuard { stop, handle: heartbeat };
// BUG-2026-07-19: tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
// tools.execute 无 timeout 时,卡死工具(run_command 长命令/read_file 大文件/同步
// 阻塞工具)永久挂起 → process_tool_calls 持 session lock 永久 → guard.reset 等 lock → AiCompleted
// 永不发 → 前端"回答完卡住/超时清空"。60s timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
// 心跳 30s 续命前端 watchdog,60s timeout 覆盖绝大多数工具(run_command 已自带 10s 子超时)。
match tokio::time::timeout(Duration::from_secs(60), tools.execute(name, args)).await {
// 永不发 → 前端"回答完卡住/超时清空"。外层 timeout 兜底:超时返错误 tool_result,锁释放,loop 续跑。
// 心跳 30s 续命前端 watchdog。
//
// 外层 timeout 取值(BUF-run-cmd-timeout):原硬编码 60s 会先于 run_command 自带 timeout_secs
// (上限 600s)drop,致 run_command 跑长构建(cargo/npm)永远到不了用户配的 timeout_secs。
// run_command 分支从 args.timeout_secs 取值(clamp [60,600],默认 60,与 tool_registry.rs:2707
// run_command 内部 clamp 同源),其余工具仍 60s。run_command 内部还有自己的子 timeout,
// 外层只需 >= 内部 timeout 即不抢断(run_command 内部超时会返带语义的 tool_result,优于外层裸中止)。
let outer_secs: u64 = if name == "run_command" {
args.get("timeout_secs")
.and_then(|v| v.as_u64())
.unwrap_or(60)
.clamp(60, 600)
} else {
60
};
match tokio::time::timeout(Duration::from_secs(outer_secs), tools.execute(name, args)).await {
Ok(result) => result,
Err(_elapsed) => {
tracing::error!(
conv_id = %conv_id,
tool = %name,
"[ai] 工具执行超时(60s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)"
outer_secs,
"[ai] 工具执行超时({}s),返回错误 tool_result(防 process_tool_calls 持 session lock 永久卡死)",
outer_secs
);
Err(anyhow::anyhow!("工具执行超时(60s),已中止(防死锁)"))
Err(anyhow::anyhow!("工具执行超时({}s),已中止(防死锁)", outer_secs))
}
}
}
@@ -164,26 +180,26 @@ pub(crate) async fn process_tool_calls(
) -> usize {
let mut tc_list: Vec<_> = tool_calls_acc.into_iter().collect();
tc_list.sort_unstable_by_key(|(i, _)| *i);
// B-260616-21 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
// 治本兜底:LLM 异常复用同 tool_use.id(stream_recv 按 content_block index 分桶,
// 同 id 不同 index draft 可并存 → 每 draft emit AiToolCallStarted 致同 id emit 两次 → 前端 push 两卡,
// Completed 按 id 只 update 首张 → 次张残留 running 0行)。process 层按 id 去重——同 id 保留
// 最小 index 的首个,丢弃后续,保证 emit Started 的 id 唯一。前端 useAiEvents.ts:205 findToolCall
// 守卫双保险。详 docs/02-架构设计/已编号方案/B-260616-21排查方案-2026-06-16.md。
// 守卫双保险。
let mut seen_ids: HashSet<String> = HashSet::new();
tc_list.retain(|(_, draft)| seen_ids.insert(draft.id.clone()));
let mut pending_count = 0usize;
let audit_repo = AiToolExecutionRepo::new(db);
// F-260619-04 P1 消息级溯源:取当前 assistant 消息 id。
// P1 消息级溯源:取当前 assistant 消息 id。
// 调用前 agentic/mod.rs 已把本轮 assistant_with_tools 消息(LLM 返回带 tool_calls 的那条)
// push 到 per_conv.messages(audit/mod.rs:882),此处取末条 assistant id 作为本轮工具
// 调用所属的溯源 message_id,贯穿所有 audit_tool_call 写入。None 表示无 assistant 消息
// (异常路径/老数据无 id),audit 落 message_id=None,展示侧兼容。
//
// BUG-260624-03/P0 重构:短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
// 短 lock 段读 current_message_id(纯读,无 await),clone 出来后续传参。
// 整个 process_tool_calls 不再要求调用方持锁,内部所有 session.xxx 访问均短 lock 段,
// 慢操作(execute_with_heartbeat/audit_tool_call/detect_retry_count/DB 查)全在锁外 await。
// BUG-2026-07-19: current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
// current_message_id 读加 200ms timeout(防 session lock 竞争卡死致
// process_tool_calls 进不去 emit AiToolCallStarted → 前端工具卡片不呈现 + 45s 看门狗超时)。
// 超时用 None(溯源降级,非致命)。治本:消除 session lock 长持有(save clone 段已优化)。
let current_message_id: Option<String> = match tokio::time::timeout(
@@ -226,7 +242,7 @@ pub(crate) async fn process_tool_calls(
})
.collect();
// ── F-260619-03 Phase B/C:文件工具路径授权预校验 ──
// ── 文件工具路径授权预校验 ──
// 在 RiskLevel 分类前,对文件工具(read/write/list/patch/info/append/delete/rename/search)
// 逐条预校验路径授权(persistent + 会话 session_allowed_dirs + 黑名单):
// - 任一路径命中黑名单 → Denied:push 错误 tool_result + emit Completed,不挂起(Phase C)
@@ -257,7 +273,7 @@ pub(crate) async fn process_tool_calls(
// Phase C: Denied 路径 → 硬拒(push 错误 tool_result + emit Completed),不挂起 loop。
// 工具返 Err 让 LLM 知路径被禁,自行调整;loop 继续下一轮(不暂停)。
for (draft, reason) in path_denied {
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
// 拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
let err_msg = serde_json::json!({
"status": "rejected",
"reason": "path_blacklist",
@@ -294,7 +310,7 @@ pub(crate) async fn process_tool_calls(
// 复用审批挂起架构:pending_approvals 以 tool_call_id 为键,ai_authorize_dir IPC remove 后恢复。
// pending_count 计入(让 agentic loop 检测到挂起并暂停,等 ai_authorize_dir → try_continue 恢复)。
//
// BUG-260624-03/P0 重构:detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
// detect_retry_count(DB 读)/emit/audit_tool_call(DB 写)均在锁外,
// 仅 push tool_result + pending_approvals.insert 两处纯写改短 lock 段。
for (draft, args, req) in path_auth_pending {
pending_count += 1;
@@ -304,7 +320,7 @@ pub(crate) async fn process_tool_calls(
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
let path_str = req.raw_paths.first().cloned().unwrap_or_default();
// 锁外:阶段4(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
// 锁外:(容错/恢复,开关 df-ai-approval-retry):同 tc_id 重试检测。
let retry_count = detect_retry_count(&audit_repo, &draft.id).await;
if retry_count >= 1 {
let skip_msg = format!(
@@ -348,7 +364,7 @@ pub(crate) async fn process_tool_calls(
arguments: args.clone(),
conversation_id: Some(conv_id.to_string()),
recovered: false,
// 阶段3a:路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
// 路径授权挂起标 kind=Path(req)(下沉原 path_auth 字段)。
kind: ApprovalKind::Path(req),
retry_count,
created_at: Some(std::time::SystemTime::now()),
@@ -381,7 +397,7 @@ pub(crate) async fn process_tool_calls(
// 分类:Low 收集并行执行,Med/High 立即进审批门控(push 占位 tool_result)
//
// F-260616-05:High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
// High risk 在进审批门前先查去重缓存(find_cached_high_risk_result)。
// 若 LLM 重试同命令(同 tool_name + 同 args,键序无关),命中已落定的旧 tool_result,
// 把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,跳过 insert pending + 跳过审批,
// 断「超时→重试→重新审批」循环。Med 不去重(去重易误伤),Low 无审批本就不进此分支。
@@ -431,8 +447,8 @@ pub(crate) async fn process_tool_calls(
).await;
}
// AE-04 trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// BUG-260624-03/P0 重构后调用方不再持锁,本段所有 await 完全在锁外),
// trust-hit 并行执行:execute + 即时 emit 在闭包内(闭包不访问 session,
// 调用方不再持锁,本段所有 await 完全在锁外),
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit)。对齐 Low risk 并行模式。
// CR-51 修:原 inline .await execute 串行执行每个工具(阻塞期间锁被持有,run_command 慢命令
// 阻塞同会话 IPC);改 join_all 并行多工具减少总阻塞时间。P0 重构后锁外并行,根本消除阻塞。
@@ -486,7 +502,18 @@ pub(crate) async fn process_tool_calls(
// 短 lock 段:push tool_result(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
// T2: 大工具结果进 namespace
// 失败包结构化 envelope {"status":"failed","error":...}:让断路器按 status 判
// (不解析内容文本,理论可靠)。emit 给前端的 result 与 audit 仍用原 err_msg 纯文本
// (前端卡片/审计可读,与断路器信号解耦)。
let msg_content = if status == "failed" {
serde_json::json!({"status":"failed","error":&content}).to_string()
} else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) {
session.namespace_store.store(&draft.name, &content)
} else {
content.clone()
};
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
}
// 锁外:审计(trust 放行仍记一条,decided_by=auto_trust),留痕可追溯
audit_tool_call(&audit_repo, conv_id, &draft.id, &draft.name, &draft.args, status, risk_level, Some(content), Some("auto_trust"), current_message_id).await;
@@ -497,13 +524,13 @@ pub(crate) async fn process_tool_calls(
// push tool_result / audit 在 join_all 后串行回填(短 lock push + 锁外 audit,与 Med/High 占位拼接)。
// join_all 保序——结果顺序 = low_risk 输入顺序 = tc_list 原始 index 顺序,不额外 sort
if !low_risk.is_empty() {
// BUG-2026-07-07/P0-2:只读幂等工具去重缓存(治 LLM 死循环重调)。
// 只读幂等工具去重缓存(治 LLM 死循环重调)。
// 实测 9357c27c 会话:LLM 对 read_file(application.xml) 连调 17 次、search_files 连调 13 次,
// 每次返回几乎相同结果却反复重调,是 prompt 64 万的直接元凶。此处先查会话内是否已对
// 同参只读工具成功执行过,命中则直接回填缓存结果跳过真执行,断 LLM 失忆死循环。
// 安全边界见 find_cached_readonly_result 文档(仅白名单只读工具 + 仅 completed 成功结果)。
//
// BUG-260624-03/P0 重构:find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
// find_cached_readonly_result 内部短 lock + 锁外 DB 查,本段不持锁。
let mut low_risk_uncached: Vec<(ToolCallDraft, serde_json::Value, RiskLevel)> = Vec::with_capacity(low_risk.len());
for (draft, args, risk_level) in low_risk {
let cached = find_cached_readonly_result(session_arc, conv_id, &audit_repo, &draft.name, &args).await;
@@ -589,7 +616,7 @@ pub(crate) async fn process_tool_calls(
Ok(c) => ("completed", c),
Err(c) => ("failed", c),
};
// F-260619-04 P2(方案 B):create_idea source 消息级溯源补全(仅 source 空 + 有 message_id)。
// source 消息级溯源补全(仅 source 空 + 有 message_id)。
// 注:低风险路径目前 create_idea 不会进(Medium→pending),此处为防御/未来若调级别覆盖。
// 仅 Ok(completed) 时补(失败 result 无 idea_id 意义);args 从 draft.args 反解(JSON 原样)。
if status == "completed" {
@@ -599,7 +626,18 @@ pub(crate) async fn process_tool_calls(
// 短 lock 段:写 per_conv.messages(纯写,无 await)
{
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, content.clone()));
// T2: 大工具结果进 namespace,主队列只留引用
// 失败包结构化 envelope {"status":"failed","error":...}:让断路器按 status 判
// (不解析内容文本,理论可靠)。emit 给前端的 result 与 audit 仍用原 err_msg 纯文本
// (前端卡片/审计可读,与断路器信号解耦)。
let msg_content = if status == "failed" {
serde_json::json!({"status":"failed","error":&content}).to_string()
} else if df_ai::namespace_store::should_use_namespace(&content, &draft.name) {
session.namespace_store.store(&draft.name, &content)
} else {
content.clone()
};
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &msg_content));
}
// F-#97 审计留痕:low_risk 向量中 risk_level != Low 即 mode 放行(Med 仅 medium/all、High 仅 all 才进)。
// decided_by 区分接管来源,审计表可追溯 all 模式执行了多少高危命令(治 securityReview blocker:
@@ -613,7 +651,7 @@ pub(crate) async fn process_tool_calls(
}
}
// 阶段3a:单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 单表 pending_approvals 统计 pending 总数(path/risk 合一,kind 区分)。
// 短 lock 段:读 pending_approvals 统计(纯读,无 await)
let (path_count, risk_count, pending_approvals_len) = {
let session = session_arc.lock().await;
@@ -636,7 +674,7 @@ mod tests {
/// grep 走单路径授权申请路径(NeedsAuth),非 search_files 盲拒(Denied)。
///
/// F-260621:grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// grep 加入 extract_file_tool_paths 单路径分支,未授权路径触发
/// AiDirAuthRequired 申请(对齐 read_file),不像 search_files 被硬拒。
/// 锁定此差异:grep 与 read_file 同款授权弹窗语义。
#[test]
+4 -4
View File
@@ -1,4 +1,4 @@
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
//! 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
//!
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
@@ -10,7 +10,7 @@
use super::PathAuthRequest;
/// F-260619-03 Phase B: 提取文件工具的路径参数(用于路径授权预校验)。
/// 提取文件工具的路径参数(用于路径授权预校验)。
///
/// 仅对走 resolve_workspace_path 校验的文件工具返回路径;非文件工具返回空 Vec(不预校验)。
/// - 单路径工具(read_file/write_file/list_directory/patch_file/file_info/append_file/delete_file/search_files/grep)
@@ -37,7 +37,7 @@ pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value)
}
}
/// F-260619-03 Phase B/C: 路径授权预校验结果。
/// 路径授权预校验结果。
pub(crate) enum FileToolAuthOutcome {
/// 全部已授权 → 走原 Low/Med/High 流程
Authorized,
@@ -47,7 +47,7 @@ pub(crate) enum FileToolAuthOutcome {
Denied(String),
}
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
/// 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
///
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
+1 -1
View File
@@ -36,7 +36,7 @@ pub(super) async fn resolve_project_label(db: &Arc<Database>, id: &str) -> Strin
/// 查任务可读标签:id → "「任务标题」(id=x)",对齐 resolve_project_label 三臂语义。
///
/// UX-260618-14:advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
/// advance_task 审批卡的 id 是 task_id,原 build_approval_reason 把 "id"
/// 统一走 resolve_project_label(查 projects 表),误把任务 id 当项目 id 解析,永远落到
/// "项目已不存在"。本方法改查 tasks 表,与 resolve_project_label 同 Ok(None)/Err 分流。
pub(super) async fn resolve_task_label(db: &Arc<Database>, id: &str) -> String {
+1 -1
View File
@@ -44,7 +44,7 @@ pub(crate) async fn audit_tool_call(
.insert(AiToolExecutionRecord {
id: new_id(),
conversation_id: Some(conv_id.to_string()),
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// P1 消息级溯源:message_id 由调用方(process_tool_calls)从
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
+23 -10
View File
@@ -16,7 +16,7 @@ use super::risk_from_str;
/// status=pending 的行(持久化真相源),此处读回重建内存态,使重启后待审批不丢。
/// 前端经 ai_pending_tool_calls 查询 + switchConversation 恢复 toolCard 的 pending_approval 态。
///
/// **F-260616-09 B 批8 多 conv 适配(设计 §3 batch8 + §5.2)**:DB pending 审批按
/// **多 conv 适配**:DB pending 审批按
/// `conversation_id` 分配到各 conv 的 per_conv state——对每个含 pending 审批的 conv
/// **惰性建 `PerConvState`**(不依赖 `active_conversation_id` 单值),使后续 ai_approve →
/// try_continue_agent_loop 的 `conv_read(conv_id)` 命中各自 per_conv(各归各,不串)。
@@ -45,7 +45,20 @@ pub async fn restore_pending_approvals(state: &AppState) {
// 故对每条 pending 都调一次 conv() 是幂等的(后续命中 entry().or_insert_with 跳过新建)。
let mut convs_restored: HashSet<String> = HashSet::new();
for rec in pending {
let args: serde_json::Value = serde_json::from_str(&rec.arguments).unwrap_or_default();
// 损坏 arguments 不还原(语义保留:数据异常不恢复)——
// unwrap_or_default() 会吞错误置空 {},污染 pending 还原成无参数 tool_call(语义错);
// 改为解析失败 warn! + continue 跳过该条,与下方 risk_level 解析失败的损坏过滤语义对齐。
let args: serde_json::Value = match serde_json::from_str(&rec.arguments) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
"跳过 pending 审批恢复(tool_call_id={}):arguments 解析失败(不还原以免污染无参数语义): {}",
rec.tool_call_id,
e
);
continue;
}
};
// 过滤 risk_level 解析失败的损坏记录(语义保留:数据异常不恢复)·不再绑定 risk(PendingApproval.risk_level 已删)
if risk_from_str(&rec.risk_level).is_none() {
continue;
@@ -69,19 +82,19 @@ pub async fn restore_pending_approvals(state: &AppState) {
arguments: args,
conversation_id: rec.conversation_id,
recovered: true,
// 阶段3a 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
// 单真相源合并:恢复的审批一律 kind=Risk(diff=None,与原顶层 diff 字段同语义)。
//
// **path 审批不恢复的决策(语义保留)**:路径授权挂起是会话级状态,重启后 session
// 重建,无法恢复挂起语义;且 path 的"always"决策已写入持久白名单(Settings KV),
// 重启后白名单仍生效(文件工具路径预校验会直接 Authorized,不再挂起)。故 path 审批
// 无需恢复 —— 恢复 risk 即可覆盖所有需人工决策的积压。
//
// AE-2025-03: 重启恢复的审批不重读旧文件——审批可能跨重启,
// 重启恢复的审批不重读旧文件——审批可能跨重启,
// 期间文件可能已被外部改动,重读生成 diff 反映的不是当初决策时的状态,
// 且恢复路径在 session.lock 内做 async IO 复杂度高,预览价值低。
// 前端见 diff=None 时回退显新 content。
kind: ApprovalKind::Risk { diff: None },
// 阶段4:重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 重启恢复的审批 retry_count=0(恢复语义即"待用户首次决策",非重试)。
// 即便审计表已有 pending 记录,恢复后用户审批执行属首次正常执行,不断路。
retry_count: 0,
// 恢复审批 created_at=重启时刻(无原挂起时间记录,按当前系统时间计)。
@@ -98,12 +111,12 @@ pub async fn restore_pending_approvals(state: &AppState) {
}
#[cfg(test)]
mod tests_f09_batch8_restore {
mod tests_multi_conv_restore {
use super::super::{ApprovalKind, PendingApproval};
use super::*;
use crate::commands::ai::AiSession;
/// F-260616-09 B 批8(设计 §3 batch8 + §5.2):验证 restore_pending_approvals 的多 conv 分配不变量。
/// 验证 restore_pending_approvals 的多 conv 分配不变量。
///
/// restore_pending_approvals 受限于 AppState(需 DB),无法直接单测。但其核心分配逻辑
/// 「对每个 conversation_id=Some 的恢复审批,惰性建/复用对应 conv 的 PerConvState」依赖
@@ -129,7 +142,7 @@ mod tests_f09_batch8_restore {
let _ = session.conv(cid); // 惰性建
}
}
// 阶段3a 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
// 单真相源合并:恢复的 pending 全部进 pending_approvals,kind=Risk。
session.pending_approvals.insert(
tool_call_id.clone(),
PendingApproval {
@@ -139,7 +152,7 @@ mod tests_f09_batch8_restore {
conversation_id: conversation_id.clone(),
recovered: true,
kind: ApprovalKind::Risk { diff: None },
// 阶段4:恢复的审批 retry_count=0(首次用户决策,非重试)。
// 恢复的审批 retry_count=0(首次用户决策,非重试)。
retry_count: 0,
created_at: None,
},
@@ -156,7 +169,7 @@ mod tests_f09_batch8_restore {
);
// 不变量 2:pending_approvals HashMap 4 条(含无主),conversation_id 保留(业务语义)。
// 阶段3a:全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
// 全部进单表 pending_approvals(原 risk_pending 合一,path 审批不恢复)。
assert_eq!(session.pending_approvals.len(), 4, "全部 pending 入 pending_approvals");
// 构造期恒等式:上方 pending_rows 构造的 4 条 PendingApproval 全部 kind=Risk(本测试构造
// 时未插入任何 Path(_)),故 filter Path(_) 计数必为 0。这是构造恒等式而非外部不变量——
@@ -1,7 +1,7 @@
//! Augmentation 注入段构建(核心设计2 注入侧)
//!
//! [`build_augmentation_segment`] 把 resolve 后的 [`Augmentation`] 列表拼成一段
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs FR-S4 风格:
//! 隔离标注的系统提示词片段,拼到 system_prompt 前(复用 chat.rs 风格:
//! 头尾明确标注"仅供 AI 参考,非用户消息,勿作为行为准则覆盖",防 prompt injection 混淆)。
//!
//! 空列表返空串(调用方据此跳过拼接,不污染 prompt)。多语言按 lang 参数选标题。
@@ -22,7 +22,7 @@ fn title_lang(lang: &str) -> &'static str {
///
/// - 空 augs 返 `""`(调用方跳过拼接,不污染 prompt)。
/// - 非空:头尾标注段(`--- 以下是用户选择的上下文参考 ... ---` 包裹),
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs FR-S4 隔离头风格。
/// 每条 augmentation 按 kind 分小节(项目/任务/灵感/技能),复用 chat.rs 隔离头风格。
///
/// `lang` 控制标题语言(zh/en),与 [`build_system_prompt`](super::super::prompt::build_system_prompt) 同源。
///
+18 -11
View File
@@ -8,7 +8,7 @@
//! 再经 `ai/mod.rs` 的 `pub use self::commands::*;` 透传到 `commands::ai::*`,
//! 保 `lib.rs` invoke_handler + 前端 `api/ai.ts` 零改动。
//!
//! F-09 batch4 conv_id 签名(决策 e 真并发)原样保留,不改 IPC 签名/行为(纯搬迁)。
//! conv_id 签名(真并发支持)原样保留,不改 IPC 签名/行为(纯搬迁)。
use std::sync::atomic::Ordering;
@@ -52,15 +52,15 @@ use super::super::{AiChatEvent, ApprovalKind, SessionState};
///
/// 参数 `final_text` 由调用方按清理场景语义选择(如"会话已停止"/"已取消"/"会话已清除")。
///
/// F-260616-09 B 批4:per_conv.messages 唯一真相源,删顶层 messages 双写。
/// per_conv.messages 唯一真相源,删顶层 messages 双写。
/// 终态化策略:每条 pending 审批按其自身 `conversation_id` 终态化到对应 conv 的 per_conv.messages;
/// conv_id 入参作 fallback(审批无 conversation_id 的无主审批 R-9 异常数据,终态化到入参 conv,
/// 入参也空则跳过——审计仍记,占位不残留内存因 pending 即将被 clear/retain)。
pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSession, conv_id: &str, final_text: &str) {
// SW-260618-02: 先 clone pending 的 tool_call_id(借用在此结束),再可变借 messages。
// 必须整体借 &mut session 在函数体内做 disjoint field borrow —— 调用方若分别传
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502(2026-06-18 主代修)。
// 阶段3a 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
// &mut messages + &pending 两个引用,函数参数列表不做 disjoint 推断会触发 E0502。
// 单真相源合并:两类挂起(path + risk)合一进 pending_approvals,占位都需终态化。
let entries: Vec<(String, Option<String>)> = session
.pending_approvals
.values()
@@ -249,22 +249,22 @@ pub async fn ai_regenerate(
let provider_config = super::super::prompt::get_active_provider(&state).await?;
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
// ConvState 读侧切无锁 conv_states(零锁竞争,不占 session lock)。
if !state.conv_states.can_accept_request(&conversation_id) {
return Err("AI 正在生成中,请等待完成".to_string());
}
{
let mut session = state.ai_session.lock().await;
let conv = session.conv(&conversation_id);
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
conv.stop_flag.store(false, Ordering::SeqCst);
conv.agent_language = language.clone();
// F-260616-11: 重生成 = 新生命周期起点,iteration 从头计数。
// 重生成 = 新生命周期起点,iteration 从头计数。
conv.iteration_used = 0;
// F-01 阶段6: 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
// 记录用户指定模型 override(主对话专用,兜底见 run_agentic_loop)。
conv.model_override = model_override.clone();
let popped = conv.messages.pop_last_assistant_round();
if !popped {
@@ -278,7 +278,7 @@ pub async fn ai_regenerate(
.map(|m| matches!(m.role, df_ai::provider::MessageRole::User))
.unwrap_or(false);
if !last_is_user {
// 批3 收口:generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// generating bool 已退役,此处仅 Err 返回(ConvState 仍 Idle,入口迁移
// 在 run_agentic_loop 内执行,本路径尚未进入 loop,无需手动复位)。
return Err("没有可重新生成的回复".to_string());
}
@@ -1209,11 +1209,18 @@ pub async fn ai_chat_clear_context(
return Ok(());
}
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
// msg-split-phase1:改既有消息 status,需标记全量重写(append-only 增量路径不覆盖旧行)。
// 借用结束后单独调 mark_needs_full_rewrite(借用冲突:循环持 &mut 切片时不能再持 &mut self)。
let mut dirtied = false;
for t in conv.messages.messages_mut()[..protect_start].iter_mut() {
if t.message.is_active() {
t.message.status = Some(MessageStatus::ArchivedSegment);
dirtied = true;
}
}
if dirtied {
conv.messages.mark_needs_full_rewrite();
}
let __hold1145 = __lock_t1145.elapsed();
if __hold1145 > std::time::Duration::from_millis(30) {
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
@@ -1358,7 +1365,7 @@ pub async fn ai_chat_compress_context(
&conv_id,
&llm_concurrency,
).await {
Ok(s) => s,
Ok(s) => s.nl_summary,
Err(e) => {
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
let __lock_t1295 = std::time::Instant::now();
+76 -3
View File
@@ -8,6 +8,7 @@
//! 路由(Lite 智力, 无 tool_use, 标准成本上限)兜底 `default_model`。失败返 `Err`,
//! 调用方按场景降级(手动压缩报错给用户;自动压缩降级为原裁剪行为)。
use df_ai::context_helpers::CompressedSummary;
use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider};
// F-15: 压缩路由 — TaskRequirements(Standard 智力即可,无需工具)。压缩是结构化
// 摘要任务,选 Standard 智力保摘要质量;不传 tools(纯文本出,避免 LLM 调工具跑偏)。
@@ -49,7 +50,7 @@ pub(crate) async fn compress_via_llm(
lang: &str,
conv_id: &str,
llm_concurrency: &LlmConcurrency,
) -> Result<String, String> {
) -> Result<CompressedSummary, String> {
if active_msgs.is_empty() {
return Err("无可压缩消息(active 消息为空)".to_string());
}
@@ -97,7 +98,24 @@ pub(crate) async fn compress_via_llm(
// LLM 并发限流(压缩属独立调用,纳入双层 Semaphore)
// F-09 B 批5: per_conv 改 HashMap<conv_id>,压缩在 loop 内针对本对话,用 conv_id 共享限流槽。
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await;
// compress-permit-try: per_conv 改非阻塞 try。loop 入口已持 1 个 per_conv permit,
// set_per_conv(1) 后 per_conv Semaphore 仅 1 permit 被入口占满,此处阻塞 acquire 将永久等待
// → is_compressing 永真 → 卡死。改 try_acquire_per_conv:拿不到(None)提前返 Err 降级
// (maybe_auto_compress 走失败兜底分支:关键词摘要 + set_compressing(false),下轮再试),非崩溃。
let _per_conv_permit = match llm_concurrency.try_acquire_per_conv(conv_id).await {
Some(p) => p,
None => {
tracing::warn!(
conv_id = %conv_id,
active_count,
"[ai] 压缩 per_conv permit 已耗尽(loop 入口占用),降级返回走兜底分支,下轮再试"
);
return Err(format!(
"压缩 per_conv 并发限流槽已耗尽(active {} 条,loop 入口占用;降级走兜底,下轮再试)",
active_count
));
}
};
// F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
// (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
@@ -115,7 +133,13 @@ pub(crate) async fn compress_via_llm(
if summary.is_empty() {
return Err("LLM 压缩返回空摘要".to_string());
}
Ok(summary)
// T3: 解析四段式输出为 JSON 卡片 + NL 摘要
// 从现有 ## Intent / ## Decisions / ## Files / ## Constraints 格式解析
let json_card = parse_sections_to_json(&summary);
Ok(CompressedSummary {
json_card,
nl_summary: summary,
})
}
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
@@ -151,6 +175,55 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
})
}
/// 从四段式 Markdown 摘要中解析出 JSON 卡片
/// 段标题: ## Intent / ## Decisions / ## Files / ## Constraints
fn parse_sections_to_json(summary: &str) -> String {
let mut topics = Vec::new();
let mut decisions = Vec::new();
let mut key_files = Vec::new();
let mut current_section: Option<&str> = None;
for line in summary.lines() {
let trimmed = line.trim();
if trimmed.starts_with("## ") {
current_section = trimmed.strip_prefix("## ");
continue;
}
let text = trimmed.trim_start_matches("- ").trim();
if text.is_empty() {
continue;
}
match current_section {
Some("Intent") | Some("意图") => {
topics.push(text.to_string());
}
Some("Decisions") | Some("决策") => {
decisions.push(serde_json::json!({"summary": text}));
}
Some("Files") | Some("文件") => {
key_files.push(serde_json::json!({"change": text}));
}
_ => {}
}
}
serde_json::json!({
"topics": topics,
"decisions": decisions,
"key_files": key_files,
"parsed_from": "four_section_summary",
}).to_string()
}
/// 在文本中提取标记间的内容
#[allow(dead_code)]
fn extract_between<'a>(text: &'a str, start_marker: &str, end_marker: &str) -> Option<&'a str> {
let start = text.find(start_marker)?;
let after_start = &text[start + start_marker.len()..];
let end = after_start.find(end_marker)?;
Some(after_start[..end].trim())
}
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
///
+100 -11
View File
@@ -205,14 +205,23 @@ async fn save_conversation_inner(
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
let __lock_start = std::time::Instant::now();
let (mut msgs, provider_id, created_at, pinned_goals) = {
// msg-split-phase1:同时读 persisted_msg_count + needs_full_rewrite(增量写路径判定用)。
// 读基线与读 messages 在同一 lock 段(快,无 await),保证两者一致性快照。
// 借用顺序:先 clone session 顶层字段(immutable borrow),再 conv(conv_id) mutable borrow,
// 避免同时持有 session 的 mut 和 immut 借用(E0502)。
let (mut msgs, provider_id, created_at, pinned_goals, persisted_count, needs_full_rewrite) = {
let mut session = session_arc.lock().await;
let __wait = __lock_start.elapsed();
let provider_id = session.active_provider_id.clone();
let created_at = session.active_conv_created_at.clone();
let conv = session.conv(conv_id);
let cloned = (
session.conv(conv_id).messages.all_messages_clone(),
session.active_provider_id.clone(),
session.active_conv_created_at.clone(),
session.conv(conv_id).pinned_goals.clone(),
conv.messages.all_messages_clone(),
provider_id,
created_at,
conv.pinned_goals.clone(),
conv.messages.persisted_msg_count(),
conv.messages.needs_full_rewrite(),
);
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
cloned
@@ -222,6 +231,17 @@ async fn save_conversation_inner(
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
}
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
// T2: 展开 namespace 引用为原文再落库
{
let session = session_arc.lock().await;
for m in &mut msgs {
if df_ai::namespace_store::is_namespace_ref(&m.content) {
if let Some(original) = session.namespace_store.read_only(&m.content) {
m.content = original.to_string();
}
}
}
}
for m in &mut msgs {
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
@@ -252,8 +272,18 @@ async fn save_conversation_inner(
// F-260619-03 批次 B:映射 Vec<ChatMessage> → Vec<AiMessageRecord>(带 seq 索引 + conv_id)
// 全量重写 ai_messages(单事务 DELETE + INSERT OR IGNORE,原子无中间空窗)。
// created_at 用对话级 created_at(老对话 None 时 now 兜底),保证消息创建时间与对话一致。
//
// msg-split-phase1:增加 append-only 增量写路径(治"50+ 轮对话每轮全量 DELETE+INSERT 重写"):
// - 纯追加(常态):needs_full_rewrite=false 且 len > persisted_count → 只 insert_batch
// records[persisted_count..len](INSERT OR IGNORE 幂等,新消息 id 稳定不冲突),
// 跳过 replace_conversation 的全表 DELETE+重插(省 N 倍写入 + 页链重整)。
// - 脏路径(compress/edit/replace/pop/insert_at/clear 触发 needs_full_rewrite=true):
// 内存与 DB 可能脱钩(既有行 status/content 变了 / len 缩了 / 中段插了),
// 走全量 replace_conversation 收敛(DELETE + INSERT 全部),保证最终一致。
// 首次落库(Ok(None))走 insert_batch(空表,直接插全量,无需 DELETE)。
let now = now_millis();
let msg_created_at = created_at.clone().unwrap_or_else(|| now.clone());
let total_len = persist_msgs.len();
let records: Vec<df_storage::models::AiMessageRecord> = persist_msgs
.iter()
.enumerate()
@@ -262,6 +292,10 @@ async fn save_conversation_inner(
let conv_repo = AiConversationRepo::new(db);
let msg_repo = df_storage::crud::AiMessageRepo::new(db);
// msg-split-phase1:本轮 save 的消息写入是否成功(成功才推进 persisted_count 基线)。
// 失败保基线不变 → 下次 save 仍会尝试写这些消息(INSERT OR IGNORE 幂等 / 全量重写收敛),
// 不丢消息。默认 false(保守:出错时不推进)。
let mut msgs_written_ok = false;
match conv_repo.get_by_id(conv_id).await {
Ok(Some(mut rec)) => {
// 已落库:更新对话元数据(token/model/updated_at);messages JSON 列**不赋新值**(保留旧值作备份)。
@@ -293,9 +327,35 @@ async fn save_conversation_inner(
if let Err(e) = conv_repo.update_full(&rec).await {
tracing::warn!("更新对话元数据失败 {conv_id}: {e}");
}
// 消息拆分存储:全量重写 ai_messages
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
// 消息拆分存储(msg-split-phase1 增量优化):
if !needs_full_rewrite && total_len == persisted_count {
// 无变化捷径:消息条数未变且无 dirty → DB ai_messages 与内存完全一致,
// 跳过 ai_messages 写入(省整次 DELETE+INSERT,仅元数据 update_full 已写过)。
// 场景:loop 中段多次 save 同一消息集合(如 MidStream 保文前后的状态同步);
// 元数据(token/updated_at/pinned_goals/pending_approvals)仍走上方 update_full。
msgs_written_ok = true;
} else if !needs_full_rewrite && total_len > persisted_count {
// append-only 增量:只插 [persisted_count, total_len) 新切片。
// records 的 seq 是全量索引(persisted_count 处即下一条新消息的 seq),
// 切片 [persisted_count..] 正好是本轮新增消息。
let new_records: Vec<df_storage::models::AiMessageRecord> =
records[persisted_count.min(total_len)..].to_vec();
match msg_repo.insert_batch(new_records).await {
Ok(()) => { msgs_written_ok = true; }
Err(e) => tracing::warn!(
"增量写 ai_messages 失败 {conv_id} (slice [{},{}),回退全量重写下轮收敛): {e}",
persisted_count, total_len
),
}
} else {
// 脏路径或无新增但 dirty:全量重写(DELETE + INSERT 全部),保证最终一致。
// needs_full_rewrite=true 时既有行 status/content 可能变了(如 compress 标
// compressed 但消息条数没增),必须重写覆盖旧行。
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
tracing::warn!("全量重写 ai_messages 失败 {conv_id}: {e}");
} else {
msgs_written_ok = true;
}
}
}
Ok(None) => {
@@ -323,14 +383,43 @@ async fn save_conversation_inner(
if let Err(e) = conv_repo.insert(rec).await {
tracing::warn!("落库对话失败 {conv_id}: {e}");
}
// 消息拆分存储:首次落库同样全量写 ai_messages
// (records 已用 conv_created 作 created_at,与对话记录一致)
if let Err(e) = msg_repo.replace_conversation(conv_id, records).await {
// 消息拆分存储:首次落库空表,直接 insert_batch 全量(无需 DELETE)。
// 原 replace_conversation 也可(DELETE 空表 + INSERT),但 insert_batch 省 DELETE 开销。
if let Err(e) = msg_repo.insert_batch(records).await {
tracing::warn!("首次写 ai_messages 失败 {conv_id}: {e}");
} else {
msgs_written_ok = true;
}
}
Err(e) => tracing::warn!("读取对话 {conv_id} 失败: {e}"),
}
// msg-split-phase1:写库成功才推进基线(DB 已与内存一致)。
// 失败保基线不变:下次 save 会重新尝试写未落库的消息(增量路径重试同 slice,INSERT OR IGNORE
// 幂等不冲突;脏路径全量重写收敛)。基线推进必须在锁内与读时一致(本进程内单线程 save 串行)。
// 注意:写库期间若有其他操作 push/置 dirty,基线推进可能与最新内存错位——但 advance 用
// max 防回退,且 dirty 标志由那次操作自己设(下次 save 仍走全量重写收敛),最终一致。
if msgs_written_ok {
let mut session = session_arc.lock().await;
session.conv(conv_id).messages.advance_persisted_count(total_len);
}
// 自动 checkpoint(每 20 轮或总 token > 150k 时创建)
{
let total_tokens: i64 = persist_msgs.len() as i64;
if total_tokens > 0 && total_tokens % 20 == 0 {
let snapshot = serde_json::to_string(&persist_msgs).unwrap_or_default();
if !snapshot.is_empty() && snapshot.len() < 1_000_000 {
let ck_id = format!("ck_{}_{}", conv_id.replace('-', ""), total_tokens);
let ck_now = now_millis().to_string();
// 参数化绑定(走 AiConversationRepo::insert_checkpoint),替代原 format! 拼 SQL,
// 防 snapshot 含引号/特殊字符致注入或损坏。
if let Err(e) = conv_repo.insert_checkpoint(&ck_id, conv_id, &snapshot, total_tokens, &ck_now).await {
tracing::warn!("checkpoint 写入失败 {conv_id}: {e}");
}
}
}
}
}
#[cfg(test)]
+26 -2
View File
@@ -585,8 +585,20 @@ async fn extract_knowledge_from_conversation(
.unwrap_or_else(|| "未命名对话".to_string());
// 消息读取:优先 ai_messages 表(消息拆分存储真相源),表空时 fallback 旧 messages JSON 列(老库兼容)
// 注意:DB 查询 Err 不能吞成空 Vec(否则 records.is_empty() 误为真 → 走旧 messages JSON 回退,
// 语义错:本应报 DB 故障)。这里显式 match:Ok 正常流程,Err 记 warn 后跳过本轮知识抽取。
let msg_repo = AiMessageRepo::new(db);
let records = msg_repo.list_by_conversation(conv_id).await.unwrap_or_default();
let records = match msg_repo.list_by_conversation(conv_id).await {
Ok(records) => records,
Err(e) => {
tracing::warn!(
error = %e,
conv_id,
"[KNOWLEDGE-EXTRACT] list_by_conversation 失败,跳过本轮知识抽取"
);
return Ok(0); // DB 故障,不当空数据回退(0 条,不置去重标志,允许下次重试)
}
};
let messages: Vec<ChatMessage> = if !records.is_empty() {
records.iter().map(crate::commands::ai::commands::record_to_message).collect()
} else {
@@ -598,7 +610,19 @@ async fn extract_knowledge_from_conversation(
"[KNOWLEDGE-EXTRACT] ai_messages 表为空,回退旧 messages JSON 列(老库兼容)"
);
}
serde_json::from_str(&conv.messages).unwrap_or_default()
// 损坏 → match Err 分流:勿 unwrap_or_default 吞成空 Vec(空 Vec 会让 knowledge 抽取基于空上下文,
// 误产空知识)。坏数据 warn 后跳过本轮(0 条,不置去重标志,允许下次重试),与 DB 故障同语义。
match serde_json::from_str::<Vec<ChatMessage>>(&conv.messages) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
error = %e,
conv_id,
"[KNOWLEDGE-EXTRACT] 旧 messages JSON 列解析失败,跳过本轮知识抽取(勿基于空上下文抽取)"
);
return Ok(0);
}
}
};
// 过滤 user/assistant,取最后 6 条
let recent: Vec<&ChatMessage> = messages
+35
View File
@@ -44,6 +44,9 @@ pub mod skills;
pub mod stream_recv;
pub mod title;
pub mod tool_registry;
// 声明式工具注册集合(tool_registry 拆分第一步 · 试点容器)。
// 试点:list_projects 已迁声明式 declare_tool! 宏,其余 47 工具仍在 tool_registry.rs。
pub mod tools;
use agentic::conv_state::{ConvState, ConvStateStore};
@@ -489,6 +492,26 @@ fn dir_of_path_normalized(path: &str) -> String {
}
}
/// 判定 run_command 的命令串是否「破坏性」(rm/del/format/... 等不可逆操作)。
///
/// 用于会话信任放行门控:破坏性命令**永不**走 session_trust auto 放行,即使同会话
/// 已批准过同目录的 run_command,也必须每轮走正常审批(避免「一次批准同目录 →
/// rm -rf 任意文件被自动放行」的安全漏洞)。
///
/// 判定规则:取命令**首 token** 精确匹配,避免前缀法误伤 delta/delve 等以 del 开头的
/// 合法命令。黑名单只覆盖「不可逆破坏」类;curl/wget/scp 等下载拷贝类的风险(数据外泄/
/// 恶意脚本)由 run_command 的 High risk 审批(用户每轮看完整命令)覆盖,不进黑名单,
/// 避免误伤频繁合法下载。
/// 局限:不解析 sudo/doas/env 等提权前缀(`sudo rm` 首 token 是 sudo 不命中)——本层是
/// 「尽力」补充,主防线仍是 High risk 审批。
pub fn is_destructive_command(cmd: &str) -> bool {
const DESTRUCTIVE_VERBS: &[&str] = &[
"rm", "rmdir", "del", "erase", "format", "remove-item", "dd", "mkfs",
];
let first = cmd.trim().split_whitespace().next().unwrap_or("").to_lowercase();
DESTRUCTIVE_VERBS.iter().any(|v| first == *v)
}
/// run_command / write_file 共用的目录 key 规范化:
/// 尝试 canonicalize(去 symlink / .. / 大小写归一),失败回退原字面量 trim。
fn normalize_dir_key(dir: &str) -> String {
@@ -530,6 +553,8 @@ pub struct AiSession {
/// conversation.rs save + title.rs + knowledge_inject.rs + lib.rs L0)读写
/// `session.conv(conv_id).*` / `session.conv_read(conv_id)`。顶层单例会话级字段已全部删除。
pub per_conv: HashMap<String, PerConvState>,
/// T2 工具命名空间存储(运行时缓存,大工具结果不进主队列)
pub namespace_store: df_ai::namespace_store::NamespaceStore,
}
impl AiSession {
@@ -540,6 +565,7 @@ impl AiSession {
active_conv_created_at: None,
pending_approvals: HashMap::new(),
per_conv: HashMap::new(),
namespace_store: df_ai::namespace_store::NamespaceStore::default(),
}
}
@@ -853,6 +879,12 @@ pub struct PerConvState {
///
/// 随会话销毁(per_conv 内存,不落库):会话切换/删除后该标志消失,新会话从 false 开始。
pub knowledge_extracted: bool,
/// T5 WorkingContext 常驻上下文(结构化目标/决策/步骤/脏标记)
pub working_context: df_ai::context_helpers::WorkingContext,
/// T4 关联的工作流 ID(由前端/workflow 引擎在启动时设置)
pub workflow_id: Option<String>,
/// T4 工作流 DAG 摘要(预计算的活跃路径文本,避免运行时加载 DAG)
pub workflow_dag_summary: Option<String>,
}
/// 单个目标记录(含状态追踪)。
@@ -911,6 +943,9 @@ impl PerConvState {
session_trust: HashSet::new(),
created_at: None,
knowledge_extracted: false,
working_context: df_ai::context_helpers::WorkingContext::new(),
workflow_id: None,
workflow_dag_summary: None,
}
}
}
+14 -2
View File
@@ -62,7 +62,7 @@ fn env_profile_line() -> String {
fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str) {
match lang {
"en" => (
"You are DevFlow's AI assistant. You help users manage projects, tasks, ideas, and workflows.\n\
"You are DevFlow's AI assistant. You can manage projects, tasks, ideas, and workflows, as well as analyze code, read/write files, and execute commands.\n\
Please respond in English.\n\n\
## Capabilities\n\
You can perform the following actions via tool calls:\n\
@@ -88,7 +88,7 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
"\n## Current Tasks\n",
),
_ => (
"你是 DevFlow 的 AI 助手。你帮助用户管理项目、任务、灵感和工作流。\n\
"你是 DevFlow 桌面应用的 AI 助手。你可以操作项目/任务/灵感/工作流,也可分析代码、读写文件、执行命令。\n\
必须使用简体中文回复,禁止使用繁体中文字符。\n\n\
## 当前能力\n\
你可以通过工具调用执行以下操作:\n\
@@ -192,6 +192,18 @@ pub(crate) async fn build_system_prompt_with_excluded(
}
}
// 自定义提示词(设置中配置,追加到系统 prompt 末尾)
if let Ok(Some(custom)) = state.settings.get("custom_prompt").await {
if !custom.is_empty() {
let clean = custom.trim().trim_matches('"');
if !clean.is_empty() {
prompt.push_str("\n## 自定义指令\n");
prompt.push_str(clean);
prompt.push('\n');
}
}
}
prompt
}
+11 -1
View File
@@ -446,7 +446,17 @@ async fn route_load_messages(state: &State<'_, AppState>, conversation_id: Strin
conv_id = %conversation_id,
"[remote_bridge] load_messages ai_messages 表空,回退旧 messages JSON 列(老库未迁移)"
);
serde_json::from_str(&rec.messages).unwrap_or_default()
match serde_json::from_str::<Vec<ChatMessage>>(&rec.messages) {
Ok(v) => v,
Err(e) => {
tracing::warn!(
error = %e,
conv_id = %conversation_id,
"[remote_bridge] load_messages 老 messages JSON 解析失败,跳过(推送空列表)"
);
Vec::new()
}
}
}
_ => Vec::new(),
}
+1 -1
View File
@@ -33,7 +33,7 @@ pub fn build_provider_for(
record: &AiProviderRecord,
) -> Result<Box<dyn df_ai::provider::LlmProvider>, String> {
let api_key = df_storage::secret::resolve_provider_secret(record);
df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;
let api_key = df_storage::secret::ensure_resolved_key(&record.name, &api_key)?;
Ok(df_ai::build_provider(
&record.provider_type,
&record.base_url,
+43 -9
View File
@@ -291,12 +291,24 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
async fn skills_lock_async() -> SkillsGuard {
///
/// 锁中毒(P1-260617-3 加固):读写锁 expect 中毒会 panic,技能加载热路径 panic 不可接受。
/// 中毒 → tracing::error! 记录 + 返 None(调用方 `skills_cached` 得空 Vec / `read_skill_content_stripped`
/// 返 None),不再 panic。中毒通常因持锁 panicking 线程(早期改 *g 时 unwrap)残留,缓存本身可重建,
/// 返空后下次 `invalidate_skills` 或进程重启自愈。
async fn skills_lock_async() -> Option<SkillsGuard> {
// 快路径:读锁命中(无 fs,纯内存)
{
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
// 锁中毒不 panic:PoisonError 携 guard 仍可恢复数据,但缓存一致性不保 → 记录后返 None 降级空。
let g = match RwLock::read(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 读锁中毒,返空技能列表");
return None;
}
};
if g.is_some() {
return g;
return Some(g);
}
}
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
@@ -305,16 +317,28 @@ async fn skills_lock_async() -> SkillsGuard {
.map(|res| res.skills)
.unwrap_or_default();
{
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
let mut g = match RwLock::write(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 写锁中毒,返空技能列表");
return None;
}
};
// 另一线程可能已填,二次检查(双检锁)
if g.is_none() {
*g = Some(scanned);
}
}
// 再取读锁返回(此时必 Some)
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
let g = match RwLock::read(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 读锁(慢路径后)中毒,返空技能列表");
return None;
}
};
debug_assert!(g.is_some(), "skills_lock 慢路径后必 Some");
g
Some(g)
}
/// 技能扫描结果缓存(进程内;命中即 clone,不重复扫盘)。
@@ -326,7 +350,8 @@ async fn skills_lock_async() -> SkillsGuard {
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
let g = skills_lock_async().await;
g.clone().unwrap_or_default()
// 锁中毒 → None → unwrap_or_default() 得空 Vec(对齐 P1-260617-3 中毒降级)。
g.and_then(|g| g.clone()).unwrap_or_default()
}
/// 置缓存为 None,下次 `skills_cached()` 触发重扫。
@@ -334,7 +359,15 @@ pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
/// `ai_reload_skills` IPC 调用:写锁置 None → 紧接 `skills_cached()` 重扫,
/// 实现"改技能不重启即生效"。
pub(crate) fn invalidate_skills() {
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
// 锁中毒:本就是想置 None 清缓存重建,但中毒时持锁线程已 panic,此处无法恢复一致状态。
// 记录错误并提前返回(下次加载由 skills_lock_async 中毒降级返空,进程重启自愈)。
let mut g = match RwLock::write(&SKILLS) {
Ok(g) => g,
Err(_) => {
tracing::error!("SKILLS 写锁中毒(invalidate_skills),跳过置 None");
return;
}
};
*g = None;
}
@@ -349,8 +382,9 @@ pub(crate) fn invalidate_skills() {
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
// 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点
// 锁中毒 → skills_lock_async 返 None → ? 早返 None(对齐 P1-260617-3 中毒降级返空 desc)。
let path: String = {
let g = skills_lock_async().await;
let g = skills_lock_async().await?;
let skills = g.as_ref()?;
let info = skills.iter().find(|s| s.name == name)?;
info.path.clone()
+27 -6
View File
@@ -123,6 +123,31 @@ pub(crate) enum StreamResult {
InitFailed { retryable: bool, error: String },
}
/// 将 chunk 携带的 tool_calls delta 累加到累积表。
///
/// 每个 `ToolCallDelta` 按 `index` 归位到同一个 `ToolCallDraft`:
/// - `id` 覆盖(后到的 id 视为权威,匹配 OpenAI/Anthropic 协议行为)
/// - `function_name` / `function_arguments` 增量拼接(流式分片到达)
///
/// 抽取自 stream_llm 的 chunk match arm(原最深 7 层嵌套点),纯状态累加无 emit/return 副作用。
fn accumulate_tool_calls(
tc_deltas: &[df_ai::provider::ToolCallDelta],
tool_calls_acc: &mut HashMap<u32, ToolCallDraft>,
) {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id {
draft.id = id.clone();
}
if let Some(name) = &tc_delta.function_name {
draft.name.push_str(name);
}
if let Some(args) = &tc_delta.function_arguments {
draft.args.push_str(args);
}
}
}
/// 流式接收 LLM 响应。
///
/// 三类异常处理(返回 StreamResult 显式区分出口):
@@ -339,12 +364,7 @@ pub(crate) async fn stream_llm(
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
}
if let Some(tc_deltas) = &chunk.tool_calls {
for tc_delta in tc_deltas {
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
}
accumulate_tool_calls(tc_deltas, &mut tool_calls_acc);
}
if let Some(u) = &chunk.usage {
final_usage = Some(u.clone());
@@ -527,6 +547,7 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
// ============================================================
// 单测:诊断提取/格式化(纯函数,不发 HTTP、不依赖 app_handle)
// ============================================================
#[cfg(test)]
mod tests {
use super::*;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+340
View File
@@ -0,0 +1,340 @@
//! Git 类 AI 工具声明式注册(register_git_tools 6 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_git_tools`(原 6 个:git_status/git_diff/git_log 只读 +
//! git_commit/git_branch/git_merge 写),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_git_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 配套私有 helper(exec_git/run_git_status/run_git_diff/run_git_log)原样搬入本文件,
//! 仅 register_git_tools 使用(external module.rs::run_git_status 是同名不同签名的另一函数)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
// ============================================================
// Git 私有 helper(原 tool_registry.rs 986-1080 行原样搬入,仅本模块使用)
// ============================================================
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
///
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
///
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
let mut cmd = tokio::process::Command::new("git");
cmd.args(args)
.current_dir(working_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.kill_on_drop(true);
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
Ok(Err(_)) => String::new(),
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
}
}
/// git status --porcelain 解析为结构化文件列表。
/// 返回 (当前分支, 改动文件列表 [{path, status}])
async fn run_git_status(working_dir: &str) -> (String, Vec<serde_json::Value>) {
let branch = exec_git(working_dir, &["branch", "--show-current"]).await;
let branch = branch.trim().to_string();
let raw = exec_git(working_dir, &["status", "--porcelain"]).await;
let files: Vec<serde_json::Value> = raw
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
// porcelain 格式:"XY filename",XY 为两位状态码
let status = line.chars().take(2).collect::<String>();
let path = line.get(3..).unwrap_or("").trim().to_string();
// 简化状态码:M/A/D/??/R
let simple = if status.starts_with("??") { "??" }
else if status.contains('A') { "A" }
else if status.contains('D') { "D" }
else if status.contains('R') { "R" }
else { "M" };
serde_json::json!({ "path": path, "status": simple })
})
.collect();
(branch, files)
}
/// git diff 解析为结构化文件列表(每文件统计 + patch 截断)。
async fn run_git_diff(working_dir: &str, staged: bool) -> serde_json::Value {
let mut args = vec!["diff", "--stat"];
if staged { args.push("--cached"); }
let stat_raw = exec_git(working_dir, &args).await;
// 每文件 patch(截断防 token 爆)
let mut patch_args = vec!["diff" ];
if staged { patch_args.push("--cached"); }
let patch_raw = exec_git(working_dir, &patch_args).await;
// 截断到 8000 字符(防大体量 diff)
let patch_truncated = if patch_raw.len() > 8000 {
format!("{}\n... (diff 截断,共 {} 字符)", &patch_raw[..8000], patch_raw.len())
} else {
patch_raw
};
serde_json::json!({
"stat": stat_raw,
"patch": patch_truncated,
})
}
/// git log 解析为结构化提交列表。
async fn run_git_log(working_dir: &str, limit: usize) -> Vec<serde_json::Value> {
let format = "%H|%an|%ad|%s";
let limit_str = format!("-{}", limit);
let raw = exec_git(working_dir, &["log", "--oneline", &format!("--format={}", format), &limit_str, "--date=short"]).await;
raw.lines()
.filter(|l| !l.is_empty())
.filter_map(|line| {
let parts: Vec<&str> = line.splitn(4, '|').collect();
if parts.len() == 4 {
Some(serde_json::json!({
"hash": parts[0],
"author": parts[1],
"date": parts[2],
"message": parts[3],
}))
} else { None }
})
.collect()
}
/// Git AI 工具注册(6 个:status/diff/log 只读 Low + commit/branch 写 Medium + merge High)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(status/diff/log=Low,commit/branch=Medium,merge=High)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── git_status (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_status",
"查看工程 Git 工作区状态。参数:module_id(工程 ID)。返回当前分支、改动文件列表(每个文件含路径+状态 M/A/D/??)、改动总数。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let (branch, files) = run_git_status(&module.path).await;
Ok(serde_json::json!({
"branch": branch,
"files": files,
"total_changes": files.len(),
}))
}
);
// ── git_diff (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_diff",
"查看工程未提交的代码改动。参数:module_id(工程 ID)、staged(可选 bool,仅看已暂存改动,默认 false=全部含未暂存)。返回改动统计 + patch 内容(截断防 token 爆)。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
("staged", "boolean", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let staged = args.get("staged").and_then(|v| v.as_bool()).unwrap_or(false);
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let diff = run_git_diff(&module.path, staged).await;
Ok(diff)
}
);
// ── git_log (Low 只读) ──
declare_tool!(
registry,
db: Arc<Database>,
"git_log",
"查看工程 Git 提交历史。参数:module_id(工程 ID)、limit(可选 int,最近 N 条提交,默认 20,最大 100)。返回提交列表(每个含哈希/作者/消息/日期)。只读无副作用",
RiskLevel::Low,
schema: object_schema(vec![
("module_id", "string", true),
("limit", "integer", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20).min(100) as usize;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let commits = run_git_log(&module.path, limit).await;
Ok(serde_json::json!({ "commits": commits }))
}
);
// ── git_commit (Medium 审批) ──
// 提交工作区改动。需审批(写操作影响代码仓库)。
// 安全边界:不提交敏感文件(.env/.key/.pem,由 .gitignore 兜底 + 此处检查)。
declare_tool!(
registry,
db: Arc<Database>,
"git_commit",
"提交工程工作区改动到本地仓库。参数:module_id(工程 ID)、message(提交信息,必填)、add_all(可选 bool,是否添加全部改动到暂存区,默认 true)。中等风险,需审批。提交前检查不提交敏感文件(.env/.key/.pem)",
RiskLevel::Medium,
schema: object_schema(vec![
("module_id", "string", true),
("message", "string", true),
("add_all", "boolean", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let message = args["message"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 message"))?;
let add_all = args.get("add_all").and_then(|v| v.as_bool()).unwrap_or(true);
if message.trim().is_empty() {
anyhow::bail!("提交信息不能为空");
}
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
// 敏感文件检查:提交前查看待提交文件列表,如有 .env/.key/.pem 则拒绝
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let sensitive_patterns = [".env", ".key", ".pem", "id_rsa", ".htpasswd"];
for line in status_raw.lines() {
let path = line.get(3..).unwrap_or("").trim().to_lowercase();
if sensitive_patterns.iter().any(|p| path.contains(p)) {
anyhow::bail!("检测到敏感文件在待提交列表中: {}。请手动检查 .gitignore 或移除该文件后重试", line.get(3..).unwrap_or(""));
}
}
// git add
if add_all {
let _ = exec_git(&module.path, &["add", "-A"]).await;
}
// git commit
let commit_out = exec_git(&module.path, &["commit", "-m", message]).await;
// 提交后取最新 commit hash
let hash = exec_git(&module.path, &["log", "-1", "--format=%H"]).await.trim().to_string();
Ok(serde_json::json!({
"committed": !hash.is_empty(),
"hash": hash,
"message": message,
"output": commit_out,
}))
}
);
// ── git_branch (Medium 审批) ──
// 分支管理:列表/创建/切换。需审批(影响工作区分支状态)。
declare_tool!(
registry,
db: Arc<Database>,
"git_branch",
"工程分支管理。参数:module_id(工程 ID)、action(list/create/switch,默认 list)、name(分支名,create/switch 时必填)。中等风险,需审批。list=列出所有分支+当前分支,create=创建新分支,switch=切换分支",
RiskLevel::Medium,
schema: object_schema(vec![
("module_id", "string", true),
("action", "string", false),
("name", "string", false),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let action = args.get("action").and_then(|v| v.as_str()).unwrap_or("list");
let name = args.get("name").and_then(|v| v.as_str()).unwrap_or("");
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
match action {
"list" => {
let raw = exec_git(&module.path, &["branch", "--list"]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
let branches: Vec<String> = raw.lines()
.map(|l| l.trim_start_matches("* ").trim().to_string())
.filter(|s| !s.is_empty())
.collect();
Ok(serde_json::json!({ "current": current, "branches": branches }))
}
"create" => {
if name.is_empty() { anyhow::bail!("create 需要 name 参数"); }
let _ = exec_git(&module.path, &["branch", name]).await;
Ok(serde_json::json!({ "created": name }))
}
"switch" => {
if name.is_empty() { anyhow::bail!("switch 需要 name 参数"); }
let _ = exec_git(&module.path, &["checkout", name]).await;
let current = exec_git(&module.path, &["branch", "--show-current"]).await.trim().to_string();
Ok(serde_json::json!({ "switched_to": current }))
}
_ => anyhow::bail!("未知 action: {},合法值: list/create/switch", action),
}
}
);
// ── git_merge (High 审批) ──
// 合并分支。高风险(可能产生冲突,影响代码完整性)。
declare_tool!(
registry,
db: Arc<Database>,
"git_merge",
"合并指定分支到当前分支。参数:module_id(工程 ID)、branch(要合并的分支名,必填)。高风险,需审批。合并冲突时返回冲突文件列表,需用户手动解决",
RiskLevel::High,
schema: object_schema(vec![
("module_id", "string", true),
("branch", "string", true),
]),
args => {
let module_id = args["module_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 module_id"))?;
let branch = args["branch"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 branch"))?;
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let module = repo.get_by_id(module_id).await?
.ok_or_else(|| anyhow::anyhow!("工程不存在: {}", module_id))?;
let merge_out = exec_git(&module.path, &["merge", branch]).await;
let has_conflict = merge_out.contains("CONFLICT") || merge_out.contains("Merge conflict");
if has_conflict {
let status_raw = exec_git(&module.path, &["status", "--porcelain"]).await;
let conflicts: Vec<String> = status_raw.lines()
.filter(|l| l.starts_with("UU") || l.starts_with("AA") || l.starts_with("DD"))
.map(|l| l.get(3..).unwrap_or("").trim().to_string())
.collect();
Ok(serde_json::json!({
"merged": false,
"conflicts": conflicts,
"message": "合并冲突,需手动解决",
}))
} else {
Ok(serde_json::json!({
"merged": true,
"branch": branch,
"output": merge_out,
}))
}
}
);
}
+63
View File
@@ -0,0 +1,63 @@
//! 网络层 AI 工具声明式注册(register_http_tools 1 个:http_request 迁入)。
//!
//! 迁自 `tool_registry.rs::register_http_tools`(原 1 个 http_request),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 async move 块(转调 `crate::commands::ai::http::execute_http_request`),
//! 仅闭包包装改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致(headers 是对象 map,object_schema 仅支持扁平
//! 标量三元组,故保留原手工拼 serde_json::Map schema 表达式)。
//! - 无捕获:原 handler 不 clone db(handler 仅转调 http.rs),用占位 `Arc<()>` 捕获(宏要求 capture 形参)。
//!
//! 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准),一个工具名两种 risk 不支持(register
//! 单一 risk_level),故按最高风险 High 注册(写方法 High 兜底;GET 也走 High 审批更保守)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
/// 注册 http_request 工具到 `$registry`(无 db 捕获,纯 reqwest 调用)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=High(写方法兜底,GET 同走审批更保守)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(原 handler 不持 db,仅转调 http.rs)。
let dummy: Arc<()> = Arc::new(());
// schema:headers 是对象 map,object_schema 仅支持扁平标量三元组,故手工拼 serde_json::Map。
// 提取为 let 绑定:declare_tool! 的 schema: $schema:expr 形参对花括号块表达式解析有歧义,
// 先求值到局部变量再传入,语义等价(原手写 register 亦以此 Map 作为 schema 实参)。
let schema = {
let mut props = serde_json::Map::new();
props.insert("method".into(), serde_json::json!({ "type": "string", "description": "HTTP 方法:GET/POST/PUT/PATCH/DELETE(默认 GET)", "enum": ["GET", "POST", "PUT", "PATCH", "DELETE"] }));
props.insert("url".into(), serde_json::json!({ "type": "string", "description": "请求 URL,仅 http/https,拒私网/localhost(SSRF 防护)" }));
props.insert("headers".into(), serde_json::json!({ "type": "object", "description": "请求头 map<string,string>,如 {\"Authorization\":\"Bearer xxx\",\"Content-Type\":\"application/json\"}", "additionalProperties": { "type": "string" } }));
props.insert("body".into(), serde_json::json!({ "type": "string", "description": "请求体(POST/PUT/PATCH 用),原样发送,Content-Type 须在 headers 显式指定" }));
props.insert("timeout_secs".into(), serde_json::json!({ "type": "integer", "description": "超时秒数(默认 30,上限 60)", "minimum": 1, "maximum": 60 }));
props.insert("parse".into(), serde_json::json!({ "type": "string", "description": "响应 body 解析:json(pretty 格式化)/text(原样)/auto(按 Content-Type 自动,默认)", "enum": ["json", "text", "auto"] }));
serde_json::json!({
"type": "object",
"properties": props,
"required": ["url"],
})
};
declare_tool!(
registry,
dummy: Arc<()>,
"http_request",
"发起结构化 HTTP 请求(GET/POST/PUT/PATCH/DELETE),用于查询外部 API。参数:method(默认 GET)、url(http/https)、headers(对象 map)、body(请求体字符串)、timeout_secs(默认 30,上限 60)、parse(json/text/auto,默认 auto)。安全:仅 http/https 协议,拒绝私网/保留 IP(SSRF 防护含 DNS resolve 后校验防重绑定),重定向≤3 跳且每跳重校验。响应 body 截断 50KB。GET 为只读但触发外发网络,POST/PUT/PATCH/DELETE 有副作用,统一按高风险须人工批准",
RiskLevel::High,
schema: schema,
args => {
// 转调 http.rs handler(SSRF 防护 + 重定向 + 截断全在那)
crate::commands::ai::http::execute_http_request(args).await
}
);
}
+87
View File
@@ -0,0 +1,87 @@
//! 灵感类 AI 工具声明式注册(register_idea_tools 2 个:list + create 迁入)。
//!
//! 迁自 `tool_registry.rs::register_idea_tools`(原 2 个 list_ideas/create_idea),
//! 改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_idea_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用类型/常量:new_id/IdeaStatus/IdeaRecord/now_millis(各 pub(crate) 来源,与原同源) +
//! MAX_LIST_RESULTS(super::tool_registry 单真相源)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::IdeaRecord;
use df_types::types::{new_id, IdeaStatus};
use crate::commands::now_millis;
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 注册 2 个灵感类工具(list/create)到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(list=Low,create=Medium)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_ideas",
"列出所有灵感,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::IdeaRepo::new(&db);
let items = repo.list_all().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
}
);
declare_tool!(
registry,
db: Arc<Database>,
"create_idea",
"捕获一个新灵感",
RiskLevel::Medium,
schema: object_schema(vec![
("title", "string", true), ("description", "string", false),
("tags", "string", false), ("source", "string", false),
// priority:与 commands::idea::CreateIdeaInput 默认值一致(=1,灵感默认普通优先级)
("priority", "integer", false),
]),
args => {
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::IdeaRepo::new(&db);
let record = IdeaRecord {
id: new_id(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 1:灵感默认普通优先级(与 commands::idea::default_priority 及 tasks 表 SQL DEFAULT 1 对齐)
status: IdeaStatus::Draft, priority: args["priority"].as_i64().unwrap_or(1) as i32,
score: None, tags: args["tags"].as_str().map(|s| s.to_string()),
source: args["source"].as_str().map(|s| s.to_string()),
promoted_to: None, ai_analysis: None, scores: None,
related_ids: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "draft" }))
}
);
}
@@ -0,0 +1,57 @@
//! 试点工具:`list_projects` 声明式注册(tool_registry 拆分第一步 · 迁移试点)
//!
//! 验证 `declare_tool!` 宏基础设施可行:从原 `register_project_tools` 第 1 个 register 调用
//! (tool_registry.rs:524-541)迁移到声明式,行为逐字等价(schema/risk/handler body 同源)。
//!
//! 迁移策略(并存零回归):
//! - 原 `register_project_tools` 内的 list_projects register 调用删除,改调本模块 `register`。
//! - 其余 7 个项目工具(update/create/bind/delete/restore/purge/get_count)仍手写在原处,
//! 不动(本步只迁 1 个试点,后续批次渐进)。
//! - `MAX_LIST_RESULTS` 由 tool_registry.rs 改 `pub(crate)` 暴露,试点复用同一常量(单真相源,
//! 避免字面量 50 漂移)。
//!
//! 等价性验证:基线测试 test_build_ai_tool_registry_baseline_tool_count 仍断言 48 总量;
//! execute("list_projects", args) 行为不变(list_active 分页 + has_more 语义)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 用声明式宏注册 `list_projects` 到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk = RiskLevel::Low(只读,无副作用)
/// - handler body 与原 async move 块逐字一致(list_active 排软删 + offset/limit 分页 +
/// has_more 语义 + 默认/上限对齐 MAX_LIST_RESULTS)
///
/// 唯一差异:闭包包装(`{ let db = db.clone(); Box::new(move |args| { ... Box::pin(async move {...}) }) }`)
/// 改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_projects",
"列出所有项目,支持 offset/limit 分页。返回 items(项目列表)、total(总量)、has_more(是否有更多页)。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::ProjectRepo::new(&db);
// list_active 排除回收站(deleted_at),防 LLM 看到已软删项目
let items = repo.list_active().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"]
.as_u64()
.unwrap_or(MAX_LIST_RESULTS as u64)
.min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
}
);
}
+20
View File
@@ -0,0 +1,20 @@
//! 声明式注册工具集合(tool_registry 拆分第一步 · 试点容器)
//!
//! 容纳迁自 `tool_registry.rs` 手写 register 调用、改用 `declare_tool!` 宏的工具。
//! 本步(基础设施 + 1 试点)仅迁 `list_projects`,其余 47 个工具仍在 tool_registry.rs 手写,
//! 后续批次渐进迁移(每批 1-3 个,各自独立文件,互不影响)。
//!
//! 设计:每个工具一个独立文件 + `register(&mut registry, db)` 入口,tool_registry.rs
//! 对应 `register_*` 函数改调本模块(原手写 register 调用删除)。这样工具定义与实现同源、
//! 文件边界清晰,新增工具只加文件 + 在对应 register_* 接一行,不动 4224 行巨函数主体。
pub mod list_projects;
pub mod project;
pub mod task;
pub mod task_graph;
pub mod git;
pub mod http;
pub mod workflow;
pub mod idea;
pub mod trash;
pub mod file;
+179
View File
@@ -0,0 +1,179 @@
//! 项目类 AI 工具声明式注册(register_project_tools 剩余 7 个迁入)。
//!
//! 第一步试点已迁 `list_projects`(见 tools/list_projects.rs),本文件迁完
//! `register_project_tools` 的剩余 7 个:update_project/create_project/bind_directory/
//! delete_project/restore_project/purge_project/get_project_count。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_project_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - `bind_dir_to_project`(create_project/bind_directory 共用)原为 tool_registry.rs
//! 私有 helper,本模块跨 super::tool_registry 调用,故该 helper 已改 `pub(crate)`。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::ProjectRecord;
use df_types::types::{new_id, ProjectStatus};
use crate::commands::now_millis;
use crate::commands::ai::tool_registry::bind_dir_to_project;
/// 注册剩余 7 个项目类工具(update/create/bind/delete/restore/purge/get_count)到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(update/create/bind=Medium,delete/restore/purge=High,get_count=Low)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── update_project (Medium) ──
declare_tool!(
registry,
db: Arc<Database>,
"update_project",
"更新项目的指定字段(name/status/description/path/stack),需要提供项目 ID、字段名和新值。绑定代码目录推荐改用 bind_directory",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("projects", field) {
anyhow::bail!("不允许更新字段 '{}'", field);
}
let repo = df_storage::crud::ProjectRepo::new(&db);
repo.update_field(id, field, value).await?;
Ok(serde_json::json!({ "id": id, "field": field, "updated": true }))
}
);
// ── create_project (Medium) ──
declare_tool!(
registry,
db: Arc<Database>,
"create_project",
"创建新项目,可选传 path/stack 一步完成创建+绑定目录(无需再调 bind_directory)",
RiskLevel::Medium,
schema: object_schema(vec![
("name", "string", true), ("description", "string", false),
("path", "string", false), ("stack", "string", false),
]),
args => {
let name = args["name"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 name 参数"))?;
let description = args["description"].as_str().unwrap_or("");
let repo = df_storage::crud::ProjectRepo::new(&db);
let record = ProjectRecord {
id: new_id(), name: name.to_string(), description: description.to_string(),
status: ProjectStatus::Planning, idea_id: None,
path: None, stack: None,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
// path 缺省:保持原行为(仅创建,不绑定);有 path 则复用公共绑定闭包(create_project 与 bind_directory 同源)
let path_opt = args.get("path").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
if let Some(path) = path_opt {
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let stack = bind_dir_to_project(&repo, &id, path, stack_opt).await?;
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning", "path": path, "stack": stack, "bound": true }))
} else {
Ok(serde_json::json!({ "id": id, "name": name, "status": "planning" }))
}
}
);
// ── bind_directory (Medium) ──
declare_tool!(
registry,
db: Arc<Database>,
"bind_directory",
"为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id 参数"))?;
let path = args["path"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 path 参数"))?;
let stack_opt = args.get("stack").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::ProjectRepo::new(&db);
let stack = bind_dir_to_project(&repo, id, path, stack_opt).await?;
// F-260619-03: 项目绑定目录加 AI 白名单由 reload_allowed_dirs 自动读 projects.bind_directory
// 统一处理(优雅:绑定 = 自动授权,reload 时合并)。bind handler 不直接写 allowed_dirs
// (register_project_tools 无 allowed_dirs 参数,且 reload 集中更易维护)。
// 绑定后触发 reload:通过 app_handle emit 事件让 state reload,或下次工具调用前 reload 兜底。
Ok(serde_json::json!({ "id": id, "path": path, "stack": stack, "bound": true }))
}
);
// ── delete_project (High) ──
declare_tool!(
registry,
db: Arc<Database>,
"delete_project",
"删除项目(移入回收站,可恢复。永久删除用 purge_project)",
RiskLevel::High,
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
}
);
// ── restore_project (High) ──
declare_tool!(
registry,
db: Arc<Database>,
"restore_project",
"从回收站恢复已删除项目",
RiskLevel::High,
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let restored = repo.restore(id).await?;
Ok(serde_json::json!({ "restored": restored, "id": id }))
}
);
// ── purge_project (High) ──
declare_tool!(
registry,
db: Arc<Database>,
"purge_project",
"永久删除项目及关联数据(不可恢复)",
RiskLevel::High,
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::ProjectRepo::new(&db);
let purged = repo.purge_with_descendants(id).await?;
Ok(serde_json::json!({ "purged": purged, "id": id }))
}
);
// ── get_project_count (Low risk, F-260616-08) ──
declare_tool!(
registry,
db: Arc<Database>,
"get_project_count",
"获取项目总数(未删除项目),用于分页策略判断。返回 { total: usize }",
RiskLevel::Low,
schema: object_schema(vec![]),
_args => {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_active().await?;
Ok(serde_json::json!({ "total": items.len() }))
}
);
}
+250
View File
@@ -0,0 +1,250 @@
//! 任务类 AI 工具声明式注册(register_task_tools 6 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_task_tools`(原 6 个 list/create/update/advance/
//! delete/get_count),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_task_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用常量/类型:MAX_LIST_RESULTS(super::tool_registry 单真相源)、new_id/TaskStatus/
//! TaskRecord/now_millis(各 pub(crate) 来源,与原 register_task_tools 引用同源)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::TaskRecord;
use df_types::types::{new_id, TaskStatus};
use crate::commands::now_millis;
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 注册 6 个任务类工具(list/create/update/advance/delete/get_count)到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(list/get_count=Low,create/update/advance=Medium,delete=High)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_tasks",
"列出任务,可按 project_id/status 筛选(status: todo/in_progress/in_review/testing/blocked/done/cancelled),支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("project_id", "string", false), ("status", "string", false), ("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::TaskRepo::new(&db);
let mut tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
// 按状态过滤(可选):todo/in_progress/in_review/testing/blocked/done/cancelled
if let Some(status) = args.get("status").and_then(|v| v.as_str()) {
tasks.retain(|t| t.status.as_str() == status);
}
let total = tasks.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = tasks.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
}
);
declare_tool!(
registry,
db: Arc<Database>,
"create_task",
"在指定项目下创建新任务,可选传 idea_id 关联灵感(1对1 单向)。知识图谱 Phase 1 扩展三个可选参数:queue(管理维度池 backlog/todo/decision,默认 todo,新建不可直接落 active/done 须经 move_task_queue 流转)、parent_id(父任务 ID,限 1 级嵌套,父任务自身不能有 parent_id,IPC 层校验防孙任务)、content_json(结构化需求规格 JSON 字符串 {background,acceptance_criteria[],scope[],technical_design},须合法 JSON)",
RiskLevel::Medium,
schema: object_schema(vec![
("project_id", "string", true), ("title", "string", true),
("description", "string", false), ("priority", "integer", false),
("idea_id", "string", false),
("queue", "string", false), ("parent_id", "string", false),
("content_json", "string", false),
]),
args => {
let project_id = args["project_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?;
let title = args["title"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 title"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
// ── 知识图谱 Phase 1:queue/parent_id/content_json 校验(对齐 commands::task::create_task IPC)──
// queue:白名单 + 空串默认 todo。create_task 时 status 恒 todo,仅 backlog/todo/decision
// 合法(active/done 须经 move_task_queue 流转,新建直接落违反一致性约束)。
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
let queue = match args.get("queue").and_then(|v| v.as_str()) {
None | Some("") => "todo".to_string(),
Some(q) => {
let q = q.trim();
if !TASK_QUEUE_VALUES.contains(&q) {
anyhow::bail!("非法 queue 值 {:?},合法值: {:?}", q, TASK_QUEUE_VALUES);
}
// 一致性:create_task 时 status 恒 todo,仅 backlog/todo/decision 合法
match q {
"backlog" | "todo" | "decision" => q.to_string(),
other => anyhow::bail!(
"新建任务 queue 不可直接落 {:?}(须经 move_task_queue 流转),当前仅允许 backlog/todo/decision",
other
),
}
}
};
// parent_id:1 级嵌套铁律(对标设计 §2.1 D2)。空串/缺省=None;
// 非空时校验父任务存在 + 父任务自身无 parent_id(防孙任务)。
let parent_id = args
.get("parent_id")
.and_then(|v| v.as_str())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
if let Some(pid) = &parent_id {
let parent = repo
.get_by_id(pid)
.await?
.ok_or_else(|| anyhow::anyhow!("父任务 {pid} 不存在,无法创建子任务"))?;
if parent.parent_id.is_some() {
anyhow::bail!(
"违反 1 级嵌套铁律:父任务 {pid} 自身是子任务(parent_id={:?}),不允许在其下创建孙任务",
parent.parent_id
);
}
}
// content_json:轻量 JSON 合法性校验(非空时须是合法 JSON),结构细节由 AI 消费层负责。
let content_json = match args.get("content_json").and_then(|v| v.as_str()) {
Some(c) if !c.trim().is_empty() => {
serde_json::from_str::<serde_json::Value>(c)
.map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?;
Some(c.to_string())
}
_ => None,
};
let record = TaskRecord {
id: new_id(), project_id: project_id.to_string(), title: title.to_string(),
description: args["description"].as_str().unwrap_or("").to_string(),
// priority 默认 2(medium):与 commands::task::default_priority 一致,新任务默认中优先级(非 high)
status: TaskStatus::Todo, priority: args["priority"].as_i64().unwrap_or(2) as i32,
branch_name: None, assignee: None, workflow_def_id: None, base_branch: None,
review_rounds: 0,
output_json: None,
// F-260619-01 可选关联灵感(空字符串/缺省视为不关联)
idea_id: args.get("idea_id").and_then(|v| v.as_str()).filter(|s| !s.is_empty()).map(String::from),
// 知识图谱 Phase 1 V29 三列:经上方校验的 queue / parent_id / content_json
queue,
parent_id,
content_json,
created_at: now_millis(), updated_at: now_millis(),
};
let id = record.id.clone();
repo.insert(record).await?;
Ok(serde_json::json!({ "id": id, "title": title, "status": "todo" }))
}
);
declare_tool!(
registry,
db: Arc<Database>,
"update_task",
"更新任务的指定字段(title/description/priority/assignee 等),需要提供任务 ID、字段名和新值。注意:status 改动须走 advance_task 工具(状态机推进,7 态 todo/in_progress/in_review/testing/blocked/done/cancelled,按 target_status 推进不可跳跃),本工具不接受 status 字段(防绕过状态机)",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("field", "string", true), ("value", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let field = args["field"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 field"))?;
let value = args["value"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 value"))?;
// F-03 收口(子项3):status 改动必须走 advance_task 工具(状态机推进),
// 本工具显式拒绝 status 字段,防 AI 绕过状态机直接改库。
// 对齐 Agent B df-storage tasks 白名单移 status——双重防御(schema 拒绝 + 白名单拒绝)。
if field == "status" {
anyhow::bail!(
"status 改动须走 advance_task 工具(状态机推进,7 态不可跳跃),本工具不接受 status 字段"
);
}
// 复用 df-storage CRUD 白名单(按表隔离),与 update_field 校验同源
if !df_storage::crud::is_allowed_column("tasks", field) {
anyhow::bail!("不允许更新字段 '{}'", field);
}
let repo = df_storage::crud::TaskRepo::new(&db);
repo.update_field(id, field, value).await?;
// UX-260619-04: 返回 title 供前端卡片友好展示(对齐 advance_task 返 TaskRecord,
// 防裸 UUID id 辨识度低)。update_field 返 bool 不含 record,单独 get_by_id 取 title。
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
Ok(serde_json::json!({ "id": id, "title": title, "field": field, "updated": true }))
}
);
// F-03 收口(子项1):advance_task AI 工具 — 推进任务状态的唯一合法路径。
// D-260616-03:推进链业务逻辑(状态机校验 + 原子 CAS + review_rounds 累加)落
// df-nodes::task_advance_node::advance_task_atomic,本 handler 直接调它(与 IPC
// commands::task::advance_task 同源,thin 转发),无需 AppHandle/State/EventBus。
// 风险 Medium:status 变更属状态机推进(非破坏),但改变任务流转语义需人工知情。
declare_tool!(
registry,
db: Arc<Database>,
"advance_task",
"推进任务状态,必须走状态机(7 态: todo/in_progress/in_review/testing/blocked/done/cancelled),按 target_status 推进,不可跳跃(非法转换会被状态机拦截)。退回转换(in_review→in_progress 等)自动累加 review_rounds。返回推进后的最新 TaskRecord",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("target_status", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 复用 df-nodes 推进链核心逻辑(状态机 + 原子 CAS + review_rounds),
// 与 commands::task::advance_task IPC 同源,避免双轨。
let repo = df_storage::crud::TaskRepo::new(&db);
let updated = df_nodes::task_advance_node::advance_task_atomic(
&repo, id, target_status,
).await?;
// 返回推进后的 TaskRecord(含新 status / 累加后的 review_rounds),供 LLM 确认推进结果。
Ok(serde_json::to_value(&updated)?)
}
);
// FR-D6:delete_task 对齐 commands::task::delete_task(D-260616-02 软删除)
// tasks 表 V14 加 deleted_at,delete_task 改调 soft_delete 移入回收站(对标 delete_project)
declare_tool!(
registry,
db: Arc<Database>,
"delete_task",
"删除任务(移入回收站,可恢复)",
RiskLevel::High,
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let deleted = repo.soft_delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
}
);
// ── 探总量工具 (Low risk, F-260616-08) ──
declare_tool!(
registry,
db: Arc<Database>,
"get_task_count",
"获取任务总数(未删除任务),用于分页策略判断。返回 { total: usize }",
RiskLevel::Low,
schema: object_schema(vec![("project_id", "string", false)]),
args => {
let repo = df_storage::crud::TaskRepo::new(&db);
let tasks = if let Some(pid) = args.get("project_id").and_then(|v| v.as_str()) {
repo.query("project_id", pid).await?
} else {
repo.list_all().await?
};
Ok(serde_json::json!({ "total": tasks.len() }))
}
);
}
@@ -0,0 +1,447 @@
//! 知识图谱 / 工程系统类 AI 工具声明式注册(register_task_graph_tools 10 个迁入)。
//!
//! 迁自 `tool_registry.rs::register_task_graph_tools`(原 10 个:task_link CRUD + 父子树
//! + 跨池移动 + content 更新 + 项目事件流 + 项目基础设施配置 + 工程列表),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_task_graph_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用类型:new_id/ProjectServiceRecord/now_millis(各 pub(crate) 来源,与原同源)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use df_storage::models::ProjectServiceRecord;
use df_types::types::new_id;
use crate::commands::now_millis;
/// 注册 10 个知识图谱 / 工程系统类工具到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk 与原一致(link 写/queue 移动/content 更新/add_service=Medium,只读=Low)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
// ── create_task_link (Medium,设计 §2.2 + §五) ──
// 建立任务横向关联。TaskLinkRepo::create_link 内含:
// link_type 白名单(depends_on/blocks/relates_to)+ 自环拒绝 + depends_on BFS 循环依赖检测。
// handler 仅转发参数 + new_id,业务校验全部下沉 Repo 层(单一真相源,与 IPC create_task_link 同源)。
declare_tool!(
registry,
db: Arc<Database>,
"create_task_link",
"建立任务横向关联(AI 拓扑排序编排调度基础)。参数:task_id(source 任务,关联发起方)、target_id(target 任务,关联指向方)、link_type(depends_on 依赖/blocks 阻塞/relates_to 弱关联)、remark(可选备注)。约束:link_type 白名单校验;source==target 自环拒绝;depends_on 链 BFS 循环依赖检测(A→B→A 拒绝);跨项目允许。返回 link id",
RiskLevel::Medium,
schema: object_schema(vec![
("task_id", "string", true), ("target_id", "string", true),
("link_type", "string", true), ("remark", "string", false),
]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let target_id = args["target_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 target_id"))?;
let link_type = args["link_type"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 link_type"))?;
let remark = args.get("remark").and_then(|v| v.as_str()).filter(|s| !s.is_empty());
let repo = df_storage::crud::TaskLinkRepo::new(&db);
let id = new_id();
let link_id = repo.create_link(&id, task_id, target_id, link_type, remark).await?;
Ok(serde_json::json!({ "id": link_id, "task_id": task_id, "target_id": target_id, "link_type": link_type }))
}
);
// ── remove_task_link (Medium,设计 §2.2 + §五) ──
// 解除任务关联(按 link id)。返回是否命中(deleted=false=link 不存在)。
declare_tool!(
registry,
db: Arc<Database>,
"remove_task_link",
"解除任务关联(按 link id)。返回 deleted 布尔(是否命中,link 不存在则 false)",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let repo = df_storage::crud::TaskLinkRepo::new(&db);
let deleted = repo.delete(id).await?;
Ok(serde_json::json!({ "deleted": deleted, "id": id }))
}
);
// ── list_task_links (Low 只读,设计 §2.2 + §五) ──
// 查询任务关联(方向:outgoing 作为 source / incoming 作为 target / both 双向合并去重)。
// AI 编排调度用:取某任务的全部依赖/阻塞/关联,做拓扑排序。
declare_tool!(
registry,
db: Arc<Database>,
"list_task_links",
"查询任务关联(AI 编排调度用)。参数:task_id(目标任务)、direction(查询方向 outgoing=作为 source 查我依赖谁 / incoming=作为 target 查谁依赖我 / both=双向合并去重,默认 both)。返回关联记录列表(按 created_at 升序)",
RiskLevel::Low,
schema: object_schema(vec![("task_id", "string", true), ("direction", "string", false)]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
// 方向默认 both(对齐 commands::task::LinkDirection::default)。空串/未传走 both。
let direction = args.get("direction").and_then(|v| v.as_str()).unwrap_or("both").to_lowercase();
let repo = df_storage::crud::TaskLinkRepo::new(&db);
let links = match direction.as_str() {
"outgoing" => repo.get_by_source(task_id).await?,
"incoming" => repo.get_by_target(task_id).await?,
// both:Outgoing + Incoming 合并后按 id 去重(同一 link 双向查询可能重复)
_ => {
let mut outgoing = repo.get_by_source(task_id).await?;
let incoming = repo.get_by_target(task_id).await?;
let mut seen: std::collections::HashSet<String> =
outgoing.iter().map(|l| l.id.clone()).collect();
for l in incoming {
if seen.insert(l.id.clone()) {
outgoing.push(l);
}
}
outgoing.sort_by(|a, b| a.created_at.cmp(&b.created_at));
outgoing
}
};
let total = links.len();
Ok(serde_json::json!({ "items": links, "total": total, "task_id": task_id, "direction": direction }))
}
);
// ── get_task_tree (Low 只读,设计 §2.1 + §五) ──
// 获取父子任务树(限 1 级嵌套,设计 D2:无孙任务)。返回 parent + 直接 children 列表。
// AI 用途:查看需求分解结构、子任务进度聚合。
declare_tool!(
registry,
db: Arc<Database>,
"get_task_tree",
"获取任务父子树(限 1 级嵌套,设计 D2 无孙任务)。参数 task_id(任务 ID,无论叶子还是父任务)。返回 { parent: 父任务记录, children: 直接子任务列表(parent_id 指向 task_id,按 created_at 升序) }。叶子任务 children 为空。AI 用途:查看需求分解结构、子任务进度聚合",
RiskLevel::Low,
schema: object_schema(vec![("task_id", "string", true)]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let repo = df_storage::crud::TaskRepo::new(&db);
let parent = repo
.get_by_id(task_id)
.await?
.ok_or_else(|| anyhow::anyhow!("任务 {task_id} 不存在"))?;
let children = repo.get_children(task_id).await?;
let child_count = children.len();
Ok(serde_json::json!({ "parent": parent, "children": children, "child_count": child_count }))
}
);
// ── move_task_queue (Medium,设计 §2.1 + §五) ──
// 跨池移动任务(backlog/todo/decision/active/done),按一致性约束联动 status。
// 对齐 commands::task::move_task_queue 语义:
// - done → status 强制 done / backlog → status 强制 todo
// - active → status 若不在执行中三态则强制 in_progress / todo → status 强制 todo
// - decision → status 不变(待决策池保留执行态)
// status 写入走专用 set_status_for_aggregation(绕过 status 收口,move_task_queue 是合法非状态机路径)。
declare_tool!(
registry,
db: Arc<Database>,
"move_task_queue",
"跨池移动任务(管理维度池 backlog/todo/decision/active/done,与 status 执行维度正交)。参数 id(任务 ID)、new_queue(目标池)。一致性约束联动 status:done→status=done / backlog→status=todo / active→status 若非执行中三态强制 in_progress / todo→status=todo / decision→status 不变。返回移动后的最新 TaskRecord",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("new_queue", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let new_queue = args["new_queue"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 new_queue"))?
.trim()
.to_string();
// queue 白名单校验(对标 commands::task::validate_queue)
const TASK_QUEUE_VALUES: &[&str] = &["backlog", "todo", "decision", "active", "done"];
if !TASK_QUEUE_VALUES.contains(&new_queue.as_str()) {
anyhow::bail!("非法 new_queue 值 {:?},合法值: {:?}", new_queue, TASK_QUEUE_VALUES);
}
let repo = df_storage::crud::TaskRepo::new(&db);
let current = repo
.get_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在"))?;
// 一致性约束联动:根据 new_queue 决定 status 是否需调整
// (对标 commands::task::move_task_queue 同源逻辑,单一真相源)
const ACTIVE_OK_STATUSES: &[&str] = &["in_progress", "in_review", "testing"];
let new_status = match new_queue.as_str() {
"done" => "done".to_string(),
"backlog" => "todo".to_string(),
"active" => {
if ACTIVE_OK_STATUSES.contains(&current.status.as_str()) {
current.status.as_str().to_string()
} else {
"in_progress".to_string()
}
}
"todo" => "todo".to_string(),
"decision" => current.status.as_str().to_string(),
_ => unreachable!("queue 白名单已收口"),
};
// 写 queue(queue 已在 tasks 白名单);与 current 不同才写(避免无谓 updated_at 抖动)
if current.queue != new_queue {
repo.update_field(id, "queue", &new_queue).await?;
}
// 写 status(专用 set_status_for_aggregation 绕过 status 收口,合法非状态机路径)
if current.status.as_str() != new_status {
repo.set_status_for_aggregation(id, &new_status).await?;
}
// 回读最新记录返回
let updated = repo
.get_by_id(id)
.await?
.ok_or_else(|| anyhow::anyhow!("任务 {id} 不存在(移动后回读失败)"))?;
Ok(serde_json::to_value(&updated)?)
}
);
// ── update_content (Medium,设计 §五) ──
// 更新 content_json 结构化需求规格({background,acceptance_criteria[],scope[],technical_design})。
// 走通用 update_field(content_json 已在 tasks 白名单) + JSON 合法性校验(对齐 create_task 校验)。
// AI 从对话中提取信息填充 content_json,人确认后 AI 执行中用 acceptance_criteria 自检。
declare_tool!(
registry,
db: Arc<Database>,
"update_content",
"更新任务的结构化需求规格 content_json(JSON 字符串 {background,acceptance_criteria[],scope[],technical_design,custom_fields})。参数 id(任务 ID)、content_json(合法 JSON 字符串,空串清空 content_json)。校验:须是合法 JSON,否则拒。AI 用途:从对话提取信息填充需求规格,供后续 acceptance_criteria 自检",
RiskLevel::Medium,
schema: object_schema(vec![("id", "string", true), ("content_json", "string", true)]),
args => {
let id = args["id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 id"))?;
let content_json = args["content_json"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 content_json"))?;
// JSON 合法性校验(对齐 commands::task::create_task 的 content_json 校验)
// 非空串须是合法 JSON;空串视为清空(写 NULL)——但 update_field 收 string,
// 用空串写库后会留空字符串而非 NULL(可接受,content_json 为 Option<String> 但
// 通用 update_field 走 TEXT 列接受空串)。这里对非空串做校验,空串直接放行清空。
if !content_json.trim().is_empty() {
serde_json::from_str::<serde_json::Value>(content_json)
.map_err(|e| anyhow::anyhow!("content_json 不是合法 JSON: {e}"))?;
}
let repo = df_storage::crud::TaskRepo::new(&db);
repo.update_field(id, "content_json", content_json).await?;
let title = repo.get_by_id(id).await?.map(|t| t.title).unwrap_or_default();
Ok(serde_json::json!({ "id": id, "title": title, "field": "content_json", "updated": true }))
}
);
// ── get_project_timeline (Low 只读,设计 §五 + §2.4) ──
// 查询项目事件流(project_events 统一事件流)。AI 精准检索基础:
// 回答"上周做了什么 / 这个任务为何 blocked / 决策何时做出"。
// 参数 project_id(必填) + event_type(可选过滤) + limit(默认 50,上限 200)。
// source 标 "ai"(AI 工具路径,区别于 IPC 层的 "human")。
declare_tool!(
registry,
db: Arc<Database>,
"get_project_timeline",
"查询项目事件流(AI 精准检索基础,回答\"上周做了什么/这个任务为何 blocked/决策何时做出\")。参数 project_id(必填,项目 ID)、event_type(可选,按事件类型精确过滤,如 task_created/task_advanced/idea_promoted/idea_evaluated/decision_made 等,空=不过滤)、limit(可选,返回条数上限,默认 50,内部钳制 ≤ 200)。返回 { items: 事件列表(时间倒序), total, project_id }。事件含 event_type/entity_type/entity_id/from_state/to_state/source/created_at",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
("event_type", "string", false),
("limit", "integer", false),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
let event_filter = args.get("event_type")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
// limit 默认 50(对齐 commands::events::default_limit);负数/异常值兜底 50。
let limit = args.get("limit")
.and_then(|v| v.as_u64())
.map(|n| n as u32)
.unwrap_or(50);
let repo = df_storage::crud::ProjectEventRepo::new(&db);
let mut events = repo.get_by_project(&project_id, limit).await?;
// 应用层按 event_type 过滤(单用户事件量小,保持 Repo 方法精简)。
if let Some(et) = &event_filter {
events.retain(|e| e.event_type == *et);
}
let total = events.len();
Ok(serde_json::json!({
"items": events,
"total": total,
"project_id": project_id,
}))
}
);
// ── add_project_service (Medium,设计 §2.3 + §五 + D10) ──
// 添加项目依赖的基础设施配置(数据库/缓存/MQ/API 等)。为 AI 执行任务时提供基础设施上下文:
// "这项目用了什么数据库、Redis 在哪、有没有 MQ"。
// service_type 白名单 + D10 凭证审查下沉 Repo::insert_validated(单一真相源,与 IPC
// add_project_service 同源)。
//
// ⚠️ D10 安全边界:config_json / endpoint / remark 不含敏感凭证(密码/密钥/Token)。
// Repo 层审查 password/secret/token/api_key 子串,命中即拒。凭证走环境变量。
declare_tool!(
registry,
db: Arc<Database>,
"add_project_service",
"为项目添加基础设施配置(为 AI 执行任务时提供\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、name(服务名,如 主库/Redis 缓存)、service_type(类型,白名单 mysql/postgresql/sqlite/redis/mongodb/mq/api/other)、environment(环境 development/staging/production,默认 development)、endpoint(可选,连接地址 localhost:3306 或 URL,不含凭证)、config_json(可选,类型相关配置 JSON 字符串,如 {\"pool_size\":10},⚠️不含密码字段,凭证走环境变量)、remark(可选,备注)。返回新增完整记录。约束:service_type 白名单;D10 安全边界 config_json/endpoint/remark 含 password/secret/token/api_key 子串拒绝",
RiskLevel::Medium,
schema: object_schema(vec![
("project_id", "string", true),
("name", "string", true),
("service_type", "string", true),
("environment", "string", false),
("endpoint", "string", false),
("config_json", "string", false),
("remark", "string", false),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
let name = args["name"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 name"))?
.trim()
.to_string();
let service_type = args["service_type"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 service_type"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
if name.is_empty() {
anyhow::bail!("name 不能为空");
}
if service_type.is_empty() {
anyhow::bail!("service_type 不能为空");
}
let endpoint = args.get("endpoint")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let config_json = args.get("config_json")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let environment = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("development")
.to_string();
let remark = args.get("remark")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let record = ProjectServiceRecord {
id: new_id(),
project_id: project_id.clone(),
name,
service_type,
endpoint,
config_json,
environment,
remark,
created_at: now_millis(),
updated_at: now_millis(),
};
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
// insert_validated 内含 service_type 白名单 + D10 凭证审查(密码/token/api_key 子串拒绝)。
let id = repo.insert_validated(record).await?;
let inserted = repo.get_by_id(&id).await?
.ok_or_else(|| anyhow::anyhow!("插入后回读失败"))?;
Ok(serde_json::to_value(&inserted)?)
}
);
// ── list_project_services (Low 只读,设计 §2.3 + §五) ──
// 查询项目基础设施配置。AI 执行任务时按需取基础设施上下文:
// 部署 production 任务只取 production 服务,开发调试取 development(环境隔离避免误连生产库)。
declare_tool!(
registry,
db: Arc<Database>,
"list_project_services",
"查询项目基础设施配置(AI 执行任务时取\"用了什么数据库/缓存/MQ/API\"上下文)。参数:project_id(项目 ID)、environment(可选,按环境过滤 development/staging/production,空=返回所有环境)。返回 { items: 配置列表(时间倒序), total, project_id }。环境隔离:production 部署任务取 production 服务,开发调试取 development,避免误连生产库",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
("environment", "string", false),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
let env_filter = args.get("environment")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| s.to_string());
let repo = df_storage::crud::ProjectServiceRepo::new(&db);
let items = if let Some(env) = &env_filter {
repo.list_by_project_env(&project_id, env).await?
} else {
repo.list_by_project(&project_id).await?
};
let total = items.len();
Ok(serde_json::json!({
"items": items,
"total": total,
"project_id": project_id,
}))
}
);
// ── list_project_modules (Low 只读,工程系统) ──
// 查询项目工程列表(AI 取项目结构上下文:有哪些代码仓库、各自目录/技术栈/Git 地址)。
// 多工程场景(Monorepo/微服务/前后端分离)AI 据此决定在哪个工程执行 git/构建操作。
declare_tool!(
registry,
db: Arc<Database>,
"list_project_modules",
"查询项目工程列表(每个工程是独立代码仓库)。参数:project_id(项目 ID)。返回 { modules: 工程列表(含 name/path/git_url/stack), total }。AI 据此了解项目有哪些代码仓库及各自技术栈",
RiskLevel::Low,
schema: object_schema(vec![
("project_id", "string", true),
]),
args => {
let project_id = args["project_id"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 project_id"))?
.trim()
.to_string();
if project_id.is_empty() {
anyhow::bail!("project_id 不能为空");
}
let repo = df_storage::crud::ProjectModuleRepo::new(&db);
let modules = repo.list_by_project(&project_id).await?;
let total = modules.len();
Ok(serde_json::json!({
"modules": modules,
"total": total,
"project_id": project_id,
}))
}
);
}
+50
View File
@@ -0,0 +1,50 @@
//! 回收站类 AI 工具声明式注册(register_trash_tools 1 个:list_trash 迁入)。
//!
//! 迁自 `tool_registry.rs::register_trash_tools`(原 1 个 list_trash),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 `register_trash_tools` 内 async move 块(逻辑等价),
//! 仅闭包包装(`{ let db = db.clone(); Box::new(move |args| { let db = db.clone();
//! Box::pin(async move { ... }) }) }`)改由 `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 复用常量:MAX_LIST_RESULTS(super::tool_registry 单真相源)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
use df_storage::db::Database;
use crate::commands::ai::tool_registry::MAX_LIST_RESULTS;
/// 注册 list_trash 工具到 `$registry`。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=Low(只读)
/// - handler body 与原 async move 块逐字一致(逻辑零变更)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry, db: &Arc<Database>) {
declare_tool!(
registry,
db: Arc<Database>,
"list_trash",
"列出回收站已删除项目,支持 offset/limit 分页。返回 items、total、has_more。默认 limit=50",
RiskLevel::Low,
schema: object_schema(vec![("offset", "integer", false), ("limit", "integer", false)]),
args => {
let repo = df_storage::crud::ProjectRepo::new(&db);
let items = repo.list_deleted().await?;
let total = items.len();
let offset = args["offset"].as_u64().unwrap_or(0) as usize;
let limit = args["limit"].as_u64().unwrap_or(MAX_LIST_RESULTS as u64).min(MAX_LIST_RESULTS as u64) as usize;
let page_items: Vec<_> = items.into_iter().skip(offset).take(limit).collect();
let has_more = (offset + page_items.len()) < total;
Ok(serde_json::json!({ "items": page_items, "total": total, "has_more": has_more }))
}
);
}
@@ -0,0 +1,55 @@
//! 工作流类 AI 工具声明式注册(register_workflow_tools 1 个:run_workflow 迁入)。
//!
//! 迁自 `tool_registry.rs::register_workflow_tools`(原 1 个 run_workflow),改用 `declare_tool!` 宏。
//!
//! 迁移策略(handler 逻辑零变更):
//! - handler body 逐字照搬原 async move 块(防御兜底返 Err,正常流程不可达),仅闭包包装改由
//! `declare_tool!` 宏生成。
//! - name/desc/schema/risk 与原手写定义逐字一致。
//! - 无捕获:CR-52 删死代码后 handler 不 clone db(防御兜底,run_workflow 真正执行经
//! ai_approve → execute_run_workflow_for_tool → workflow.rs::run_workflow_inner),用占位
//! `Arc<()>` 捕获(宏要求 capture 形参)。
//!
//! 等价性验证:基线测试 `test_build_ai_tool_registry_baseline_tool_count` 仍断言 48 总量 +
//! 工具名集合稳定(防 rename / 漏注册)。
use std::sync::Arc;
use df_ai::ai_tools::{object_schema, AiToolRegistry, RiskLevel};
use df_ai::declare_tool;
/// 注册 run_workflow 工具到 `$registry`(无 db 捕获,防御兜底)。
///
/// 与原手写 register(name, desc, schema, risk, handler) 语义 1:1:
/// - name/desc/schema 字符串与 JSON Schema 逐字照搬原定义
/// - risk=High(触发工作流引擎执行,须人工批准)
/// - handler body 与原 async move 块逐字一致(逻辑零变更,仅返 Err 兜底)
///
/// 唯一差异:闭包包装改由 `declare_tool!` 宏生成,handler body 直接写业务逻辑。
pub fn register(registry: &mut AiToolRegistry) {
// 无捕获:占位 Arc<()>(CR-52 后 handler 不持 db,仅返 Err 防御)。
let dummy: Arc<()> = Arc::new(());
declare_tool!(
registry,
dummy: Arc<()>,
"run_workflow",
"按任务 target_status 推进对应工作流(含 AiNode 自审 / HumanNode 核对闸门)。参数 task_id + target_status 同时提供才联动任务推进(完成后按 target_status 推进任务,失败按退回态回滚)。属高风险操作(触发工作流引擎执行),须人工批准。审批通过后由后端直接执行工作流引擎并联动推进任务,返回 execution_id",
RiskLevel::High,
schema: object_schema(vec![("task_id", "string", true), ("target_status", "string", true)]),
args => {
let task_id = args["task_id"].as_str().ok_or_else(|| anyhow::anyhow!("缺少 task_id"))?;
let target_status = args["target_status"].as_str()
.ok_or_else(|| anyhow::anyhow!("缺少 target_status"))?;
// 防御兜底(正常流程不可达):run_workflow 经 ai_approve 分支执行,不经此 handler。
// 误入此路径说明调用方异常(非 ai_approve 直接 execute),返回明确错误勿盲目重试
// (对齐 run_command 超时标注模式 L700-712),High risk 去重缓存会断 LLM 重试循环。
Err(anyhow::anyhow!(
"run_workflow 须经人工审批后由后端 ai_approve 分支执行(转调 run_workflow_inner,\
持完整 State)。本 handler 经 ai_tools.execute 调用属异常路径(无 AppHandle/State),\
勿盲目重试同调用(task_id={}, target_status={});\
若需推进任务,重新发起 run_workflow tool_call 走审批流程。",
task_id, target_status
))
}
);
}
+120 -53
View File
@@ -34,6 +34,53 @@ struct WorkflowEventPayload {
event: WorkflowEvent,
}
/// Lagged 兜底:查 DB 终态,命中返回 `(要补发的事件, status)`;未命中返回 None。
///
/// broadcast `Lagged` 不暴露被丢事件类型,关键终态事件(WorkflowCompleted/Failed)可能已丢,
/// forward 循环会永久等不到。达阈值时查 DB 终态补发。三种"未命中"(仍 running / 无记录 /
/// 查询失败)统一返回 None 并各自 warn,主循环据此重置 `lagged_total` 继续等。
async fn probe_lagged_terminal(
db: &df_storage::db::Database,
exec_id: &ExecutionId,
) -> Option<(WorkflowEvent, String)> {
let record = match WorkflowRepo::new(db).get_by_id(exec_id).await {
Ok(Some(r)) => r,
Ok(None) => {
tracing::warn!(
execution_id = %exec_id,
"Lagged 兜底查询未找到执行记录,继续等待事件"
);
return None;
}
Err(e) => {
tracing::warn!(
execution_id = %exec_id,
error = %e,
"Lagged 兜底查询 DB 失败,继续等待事件"
);
return None;
}
};
let event = match record.status.as_str() {
"completed" => WorkflowEvent::WorkflowCompleted {
execution_id: exec_id.clone(),
total_duration_ms: 0,
},
"failed" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)".to_string(),
failed_node: String::new(),
},
"cancelled" => WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(),
error: "工作流被取消(Lagged 兜底补发)".to_string(),
failed_node: String::new(),
},
_ => return None,
};
Some((event, record.status))
}
/// 触发工作流执行(核心命令)
///
/// 流程:build_dag 校验 → 写入执行记录(status=running) → 后台异步执行 →
@@ -183,63 +230,29 @@ pub async fn run_workflow_inner(
if lagged_total < LAGGED_PROBE_THRESHOLD {
continue;
}
// 累计/单次达阈值:查 DB 终态兜底
let workflows = WorkflowRepo::new(&forward_db);
match workflows.get_by_id(&forward_exec_id).await {
Ok(Some(record)) => {
let terminal = match record.status.as_str() {
"completed" => Some(WorkflowEvent::WorkflowCompleted {
execution_id: forward_exec_id.clone(),
total_duration_ms: 0,
}),
"failed" => Some(WorkflowEvent::WorkflowFailed {
execution_id: forward_exec_id.clone(),
error: "工作流执行失败(Lagged 兜底补发,详情见 DB)"
.to_string(),
failed_node: String::new(),
}),
"cancelled" => Some(WorkflowEvent::WorkflowFailed {
execution_id: forward_exec_id.clone(),
error: "工作流被取消(Lagged 兜底补发)".to_string(),
failed_node: String::new(),
}),
_ => None,
// 累计/单次达阈值:查 DB 终态兜底(probe_lagged_terminal 收口三种未命中情况)
match probe_lagged_terminal(&forward_db, &forward_exec_id).await {
Some((synth_event, status)) => {
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event: synth_event.clone(),
};
if let Some(synth_event) = terminal {
let payload = WorkflowEventPayload {
execution_id: forward_exec_id.clone(),
event: synth_event.clone(),
};
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
}
tracing::info!(
execution_id = %forward_exec_id,
status = %record.status,
"Lagged 兜底命中终态,补发 {} 并退出 forward",
match synth_event {
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
_ => "WorkflowFailed",
}
);
break;
if let Err(e) = forward_app.emit("workflow-event", &payload) {
tracing::warn!("Lagged 终态兜底事件转发失败: {}", e);
}
// DB 仍非终态(running),重置累计计数继续等待后续事件
lagged_total = 0;
}
Ok(None) => {
tracing::warn!(
tracing::info!(
execution_id = %forward_exec_id,
"Lagged 兜底查询未找到执行记录,继续等待事件"
status = %status,
"Lagged 兜底命中终态,补发 {} 并退出 forward",
match synth_event {
WorkflowEvent::WorkflowCompleted { .. } => "WorkflowCompleted",
_ => "WorkflowFailed",
}
);
lagged_total = 0;
break;
}
Err(e) => {
tracing::warn!(
execution_id = %forward_exec_id,
error = %e,
"Lagged 兜底查询 DB 失败,继续等待事件"
);
None => {
// 未命中(仍 running / 无记录 / 查询失败):重置累计计数继续等待
lagged_total = 0;
}
}
@@ -257,7 +270,21 @@ pub async fn run_workflow_inner(
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
let cb_task_id = task_id.clone();
let cb_target_status = target_status.clone();
tauri::async_runtime::spawn(async move {
// panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。
// panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove),
// 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。
// 注:依赖 panic=unwind(tokio 默认),若构建设 panic=abort 则 catch_unwind 不生效(尽力兜底)。
tauri::async_runtime::spawn({
// panic 兜底需要这些引用:move 进 catch_unwind 闭包前各取一份 clone 供 panic 分支使用。
let panic_exec_id = exec_id.clone();
let panic_db = db.clone();
let panic_event_bus = event_bus.clone();
let panic_registry = state_registry.clone();
async move {
use std::panic::AssertUnwindSafe;
use futures::FutureExt;
// 原闭包主体包进 catch_unwind:panic 以 Err(Box<dyn Any>) 返回,Ok 走正常路径。
let outcome = AssertUnwindSafe(async move {
let mut executor = DagExecutor::new(event_bus.clone(), exec_id.clone());
// 注册执行器状态机:StateMachine 内部 Arc<Mutex>,clone 共享底层 HashMap,
// cancel_workflow_node IPC 经 execution_id 取此引用 set_cancelled,直达运行中 HumanNode
@@ -351,6 +378,46 @@ pub async fn run_workflow_inner(
})
.await;
}
}) // 闭 catch_unwind 内 async move
.catch_unwind()
.await;
// panic 分支:catch_unwind 返回 Err(Box<dyn Any + Send>),走已存在 failed 清理路径。
match outcome {
Ok(_) => {}
Err(panic_payload) => {
// 提取 panic 消息(String/&'static str 常见,其他类型用兜底文案)
let msg = panic_payload
.downcast_ref::<String>()
.map(|s| s.clone())
.or_else(|| panic_payload.downcast_ref::<&'static str>().map(|s| s.to_string()))
.unwrap_or_else(|| "工作流执行 panic".to_string());
tracing::error!(
execution_id = %panic_exec_id,
"工作流执行 panic,走 failed 分支兜底: {}",
msg
);
let workflows = WorkflowRepo::new(&panic_db);
if let Err(e) = workflows.update_field(&panic_exec_id, "status", "failed").await {
tracing::error!("更新工作流状态失败(panic 兜底): {}", e);
}
if let Err(e) = workflows
.update_field(&panic_exec_id, "completed_at", &now_millis())
.await
{
tracing::error!("更新工作流完成时间失败(panic 兜底): {}", e);
}
panic_registry.lock().await.remove(&panic_exec_id);
panic_event_bus
.send(WorkflowEvent::WorkflowFailed {
execution_id: panic_exec_id.clone(),
error: msg,
failed_node: String::new(),
})
.await;
}
}
}
});
Ok(execution_id.to_string())
+36 -18
View File
@@ -292,20 +292,14 @@ pub fn run() {
Err(e) => tracing::warn!("[tunnel] 连接 relay 失败(非阻断,supervisor 将重试): {}", e),
}
// ── supervisor:断线自动重连(根治 device 离线) ──
// df-tunnel 无内置 auto-reconnect(tunnel.rs:332 注释留外层 supervisor)。
// 桌面端进程重启 / 网络断 / relay 重启 → device 离线 → miniapp 命令 delivered=0
// → 点发无响应。supervisor 轮询 is_connected,断开则用保存参数指数退避重连,
// 复用同一 on_command 回调(逻辑不变)。task 永驻(supervisor 生命周期 = 应用生命周期)。
// supervisor:轮询 is_connected,断开则重连
// 连接被拒(relay 未部署)→300s+INFO;其他错误→2s-60s WARN
let mut backoff = std::time::Duration::from_secs(2);
let mut last_was_refused = false;
loop {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if !state.tunnel.is_connected() {
tracing::info!(
"[tunnel-supervisor] 检测到断开,尝试重连 url={} device_id={}",
relay_url,
device_id
);
tracing::info!("[tunnel-supervisor] 断线重连 url={relay_url} device_id={device_id}");
match state
.tunnel
.connect(&relay_url, &device_id, &token, on_command.clone())
@@ -314,24 +308,39 @@ pub fn run() {
Ok(()) => {
tracing::info!("[tunnel-supervisor] 重连成功,退避重置");
backoff = std::time::Duration::from_secs(2);
last_was_refused = false;
}
Err(e) => {
tracing::warn!(
error = %e,
backoff_secs = backoff.as_secs(),
"[tunnel-supervisor] 重连失败,退避后重试"
);
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
let refused = is_connection_refused(&e);
if refused {
// relay 未运行:INFO + 300s 退避,不刷 WARN
if !last_was_refused {
tracing::info!("[tunnel-supervisor] relay 未运行({e}),300s 后轻量探测");
last_was_refused = true;
}
// 300s 退避(避免空转刷屏,relay 未运行时一直保持此间隔)
tokio::time::sleep(std::time::Duration::from_secs(300)).await;
} else {
tracing::warn!("[tunnel-supervisor] 重连失败 {e},退避 {backoff_secs}s", backoff_secs = backoff.as_secs());
tokio::time::sleep(backoff).await;
// 指数退避上限 60s,防网络长故障时高频重连打 relay
backoff = (backoff * 2).min(std::time::Duration::from_secs(60));
last_was_refused = false;
}
}
}
} else if last_was_refused {
last_was_refused = false;
}
}
});
Ok(())
})
// 辅助函数 is_connection_refused 在文件尾部
// 判断 TunnelError 是否为连接被拒(relay 未运行)。
// 匹配 WS connect_async 产生的 IO error:ECONNREFUSED(Win:10061,Unix:111)。
// 被拒 → relay 未部署,用长退避避免刷屏;其他错误(超时/DNS/网络瞬断)走指数退避。
.invoke_handler(tauri::generate_handler![
// 项目
commands::project::list_projects,
@@ -500,3 +509,12 @@ pub fn run() {
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
/// 判断 TunnelError 是否为连接被拒(匹配 ECONNREFUSED 各平台表述)
fn is_connection_refused(e: &df_tunnel::TunnelError) -> bool {
let msg = e.to_string().to_lowercase();
msg.contains("refused")
|| msg.contains("10061")
|| msg.contains("积极拒绝")
|| msg.contains("actively refused")
}
+28
View File
@@ -123,6 +123,34 @@ impl LlmConcurrency {
sema.acquire_owned().await.expect("llm per_conv semaphore closed")
}
/// 取单对话内并发 permit 的**非阻塞**版本(对齐 `acquire_per_sub_flow` 语义)。
///
/// 与 `acquire_per_conv` 同样按 conv_id 取/建 Semaphore(permits=当前 per_conv_permits),
/// 区别仅在最后一步用 `try_acquire_owned`(非阻塞):耗尽返 `None`,调用方降级,不阻塞。
///
/// **用途**:压缩(`compress_via_llm`)在 agentic loop 内被调用,loop 入口已持 1 个 per_conv
/// permit;当 `set_per_conv(1)` 后 per_conv Semaphore 仅 1 permit,loop 入口占满,压缩再
/// `acquire_per_conv`(阻塞)将永久等待 → `is_compressing` 永真 → 卡死。改用本非阻塞版本:
/// 拿不到(None)调用方提前返回降级(下轮再试),非崩溃。
///
/// - 返 `Some(permit)`:成功获取,permit 随 Drop 自动释放。
/// - 返 `None`:本对话并发已耗尽,调用方降级(非错误)。锁持有短(不含 try_acquire)。
pub async fn try_acquire_per_conv(
&self,
conv_id: &str,
) -> Option<tokio::sync::OwnedSemaphorePermit> {
let sema = {
let mut map = self.per_conv.lock().await;
let permits = self.per_conv_permits.load(std::sync::atomic::Ordering::SeqCst);
map.entry(conv_id.to_string())
.or_insert_with(|| Arc::new(Semaphore::new(permits)))
.clone()
};
// try_acquire_owned 非阻塞:Err(NoPermits) 返 None 降级,不阻塞调用方。
// 对齐 acquire_global/per_conv 的 expect 前提:Semaphore 不会 close(无 close() 调用)。
sema.try_acquire_owned().ok()
}
/// F-09 B 批5:conv 退出清理。loop 结束 + 无 pending 审批时 remove 该 conv 的 Semaphore 条目。
/// **时机由调用方判断**(agentic loop 退出点):仅在确信无后续 acquire 时调用,否则误删会致
/// 该 conv 下次 acquire 重建 Semaphore(限流计数清零,非致命,但语义偏离)。
+20 -20
View File
@@ -7,7 +7,7 @@ import type { AiChatEvent, AiConversationDetail, AiConversationSummary, AiProvid
export const aiApi = {
/**
* 发送消息(非阻塞,通过 ai-chat-event 流式返回);skill 传技能名则注入其 SKILL.md。
* F-260614-05 Phase 2c: parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
* parts 透传给后端 ai_chat_send → user_parts → provider vision 端点。
* parts 为空/undefined → 后端走 user() 纯文本(向后兼容)。
* Input Augmentation: mentionSpans 透传给后端 ai_chat_send 的 mention_spans 参数
* (后端 resolve_and_inject 按 kind 投影成 Augmentation 注入 system prompt)。
@@ -20,14 +20,14 @@ export const aiApi = {
skill: skill || null,
modelOverride: modelOverride || null,
parts: parts && parts.length > 0 ? parts : null,
// F-260616-09 B 批4(决策 e):传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
// 传 conv_id,操作指定 conv 的 per_conv(不依赖 active 单值)。
conversationId: conversationId || null,
// Input Augmentation: 非空数组才传(undefined/空让 payload 不含该键,后端默认 None)。
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
})
},
/** 强制发送(B-260616-02: 复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
/** 强制发送(复位 generating 残留后走 send 同款流程);parts/mentionSpans 透传同 sendMessage。 */
forceSend(message: string, language?: string, skill?: string, modelOverride?: string | null, parts?: ContentPart[], conversationId?: string | null, mentionSpans?: MentionSpan[]): Promise<string> {
return invoke('ai_chat_force_send', {
message,
@@ -35,7 +35,7 @@ export const aiApi = {
skill: skill || null,
modelOverride: modelOverride || null,
parts: parts && parts.length > 0 ? parts : null,
// F-260616-09 B 批4(决策 e):传 conv_id,强制复位+发送仅作用于目标 conv。
// 传 conv_id,强制复位+发送仅作用于目标 conv。
conversationId: conversationId || null,
// Input Augmentation: 同 sendMessage,非空数组才传(后端 mention_spans 参数)。
mentionSpans: mentionSpans && mentionSpans.length > 0 ? mentionSpans : null,
@@ -76,7 +76,7 @@ export const aiApi = {
},
/**
* F-260619-03 Phase B: 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* 路径授权弹窗决策(消费 AiDirAuthRequired 挂起)。
* decision: 'once'(本次单次,执行后清)/ 'session'(当前会话,切会话清)/ 'always'(始终,持久化)/ 'deny'(拒绝)。
* 后端 remove pending → 写授权目录(once/session/persistent)→ 执行工具 → try_continue 恢复 loop。
*/
@@ -85,7 +85,7 @@ export const aiApi = {
},
/**
* 续跑 agentic 循环(F-260616-03:达 max_iterations 暂停态点继续)。
* 续跑 agentic 循环(达 max_iterations 暂停态点继续)。
* 后端 ai_continue_loop:复位 stop_flag → try_continue_agent_loop 重新 spawn
* run_agentic_loop(iteration 从 0 重计,再跑 max_iterations 轮)。
*/
@@ -94,7 +94,7 @@ export const aiApi = {
},
/**
* 停止 agentic 循环并走完成流程(F-260616-03:达 max_iterations 暂停态点停止)。
* 停止 agentic 循环并走完成流程(达 max_iterations 暂停态点停止)。
* 后端 ai_stop_loop:复位 generating + emit AiCompleted(暂停前已 save)。
*/
stopLoop(conversationId: string): Promise<string> {
@@ -102,7 +102,7 @@ export const aiApi = {
},
/** 查询某对话积压的待审批工具(重启后恢复 toolCard pending_approval 态用)。
* 阶段4:返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
* 返 kind 字段(risk/path),前端按 kind 渲染不同决策按钮。 */
pendingToolCalls(convId: string): Promise<{ tool_call_id: string; conversation_id: string | null; kind: 'risk' | 'path' }[]> {
return invoke('ai_pending_tool_calls', { convId })
},
@@ -114,7 +114,7 @@ export const aiApi = {
/**
* 停止当前生成。
* F-260616-09 B 批4(决策 e):传 convId,停止仅作用于目标 conv(不杀其他后台 conv 的 loop)。
* 传 convId,停止仅作用于目标 conv(不杀其他后台 conv 的 loop)。
* convId 省略时后端 fallback active conv。
*/
stopChat(convId?: string | null): Promise<void> {
@@ -122,14 +122,14 @@ export const aiApi = {
},
/**
* 查询后端真实 generating 状态(B-260615-22:发送前 IPC 查后端真值)。
* F-260616-09 B 批4(决策 e):传 convId 精确查指定 conv;省略时后端 fallback active conv。
* 查询后端真实 generating 状态(发送前 IPC 查后端真值)。
* 传 convId 精确查指定 conv;省略时后端 fallback active conv。
*/
isGenerating(convId?: string | null): Promise<boolean> {
return invoke('ai_is_generating', { conversationId: convId || null })
},
// ── F-15 阶段2: 手动上下文管理(清空 / 压缩) ──
// ── 手动上下文管理(清空 / 压缩) ──
// 后端经 ai-chat-event emit 生命周期事件:
// ai_context_cleared { conversation_id } — 清空完成
// ai_compressing { conversation_id } — 压缩开始(loading)
@@ -180,7 +180,7 @@ export const aiApi = {
},
/**
* 更新 provider 负载均衡池配置(F-260614-04c):仅 UPDATE enabled/weight + 重建 per_provider caps。
* 更新 provider 负载均衡池配置:仅 UPDATE enabled/weight + 重建 per_provider caps。
* 走轻量分支,不经 ai_save_provider 全量 INSERT OR REPLACE(避免空 api_key 触发 R-PD-1 密钥迁移)。
* 后端 weight clamp [0,100];落库后立即 reload_provider_caps,下条消息即按新配置 acquire。
*/
@@ -194,10 +194,10 @@ export const aiApi = {
},
/**
* 测试连接并拉取厂商模型列表(F-01 阶段5)。
* 后端:DB 取 provider → FR-S1 内存解析 api_key → fetch_and_probe
* 测试连接并拉取厂商模型列表。
* 后端:DB 取 provider → 内存解析 api_key → fetch_and_probe
* (网络拉模型名 + 探测 4 维度)→ 写回 model_configs → 返回。
* 返回值 ModelConfig 不含 api_key(FR-S1 闭环)。
* 返回值 ModelConfig 不含 api_key。
* 需已落库的 providerId(新建态先保存再拉取)。
*/
fetchModels(providerId: string): Promise<ModelConfig[]> {
@@ -205,7 +205,7 @@ export const aiApi = {
},
/**
* 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。
* 单模型探测:纯 CPU 启发式 + 预设表,无网络。
* 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。
*/
probeModel(modelId: string): Promise<ModelConfig> {
@@ -222,7 +222,7 @@ export const aiApi = {
return invoke('ai_set_agent_max_iterations', { value })
},
/** 设置流式对话失败自动重试次数(F-260616-07:只重试流前失败,0=不重试,默认3,上限10) */
/** 设置流式对话失败自动重试次数(只重试流前失败,0=不重试,默认3,上限10) */
setAgentMaxRetries(value: number): Promise<void> {
return invoke('ai_set_agent_max_retries', { value })
},
@@ -243,12 +243,12 @@ export const aiApi = {
return invoke('ai_reload_skills')
},
/** F-260619-03 Phase A: 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
/** 获取 AI 工具文件访问授权目录列表(含 workspace_root) */
getAllowedDirs(): Promise<string[]> {
return invoke<string[]>('ai_get_allowed_dirs')
},
/** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
/** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */
setAllowedDirs(dirs: string[]): Promise<string[]> {
return invoke<string[]>('ai_set_allowed_dirs', { dirs })
},
+7 -16
View File
@@ -102,7 +102,7 @@ import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAiStore } from '../../stores/ai'
import { useProjectStore } from '../../stores/project'
import { getConvState } from '../../composables/ai/useAiEvents'
import { getConvState, textIdle } from '../../composables/ai/useAiEvents'
import SkillMention from './SkillMention.vue'
import ImageInput from './ImageInput.vue'
import MentionPopover from './MentionPopover.vue'
@@ -226,26 +226,17 @@ async function onDrop(e: DragEvent): Promise<void> {
/** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */
const canSend = computed(() => inputText.value.trim().length > 0 || pendingImages.value.length > 0 || !!pendingSkill.value)
// L2 状态机停止按钮三态(批2 1c):从 conv_state 派生,旧 streaming/generating bool 双轨过渡兜底。
//
// 三态映射(对齐后端 ConvState 语义):
// - 'stopping':ConvState=stopping,停止中 → 显"停止中" disabled(防重复点);
// - 'stop': ConvState=generating(含审批挂起/压缩派生等活跃态)→ 显停止按钮可点;
// - 'retry': ConvState=error,停止失败可重试 → 显重试按钮(再发 stop 信号);
// - 'idle': 空闲(或 conv_state 未收到回退旧 bool=false)→ 显发送按钮。
//
// 兜底:conv_state 未追踪过(getConvState 返回 null,CONV_STATE_ENABLED=off 或老后端)时,
// 回退旧 store.state.streaming 判断(generating→显停止,否则 idle)。不强制全替旧 bool,防回归。
// 注:旧 streaming 是全局单值(非 per-conv),仅当前会话生成时为 true;F-09 多会话并发下 conv_state
// 更精确,但回退路径用 streaming 对单会话场景语义等价。
// 按钮状态:streaming=true 且 textIdle=false(最近 300ms 有新 delta) → 'stop'(红);
// 否则 → 'idle'(白)。textIdle 是 reactive ref,在 AiTextDelta 中每 delta 复位 false,
// 300ms 无新 delta 后 setTimeout 置 true。不与 streaming 绑定——文本显示完后 300ms 按钮白,
// 不等 AiCompleted,streaming 持续 true 也不影响。
const stopBtnState = computed<'idle' | 'stop' | 'stopping' | 'retry'>(() => {
const convId = store.state.activeConversationId
const cs = getConvState(convId)
if (cs === 'stopping') return 'stopping'
if (cs === 'error') return 'retry'
if (cs === 'generating' || cs === 'compressed') return 'stop'
// conv_state 未收到(idle 或 null)→ 回退旧 streaming bool
return store.state.streaming ? 'stop' : 'idle'
const textActive = store.state.streaming && !textIdle.value
return textActive ? 'stop' : 'idle'
})
// ── 技能 `/` 联想 ──
@@ -38,6 +38,16 @@
<option :value="3600000">{{ $t('settings.approvalTimeout60m') }}</option>
</select>
</SettingRow>
<!-- 自定义提示词:注入到 AI 系统 prompt 末尾,用户可自定义 AI 行为偏好 -->
<SettingRow ref="rowCustomPrompt" :label="$t('settings.labelCustomPrompt')" :desc="$t('settings.descCustomPrompt')">
<textarea
v-model="customPrompt"
class="custom-prompt-input"
:placeholder="$t('settings.placeholderCustomPrompt')"
rows="4"
@change="onCustomPromptChange"
/>
</SettingRow>
</div>
<!-- all 二次确认弹层(独立,不与 Settings 壳层共用:壳层按钮写死「删除」不适合「确认接管」) -->
@@ -108,9 +118,11 @@ const settings = reactive({
// 审批超时(ms):0=不限时,缺省 900000(15min);仅固定合法选项,脏值回退默认
approvalTimeout: clampApprovalTimeout(appSettings.get<number>('df-approval-timeout', 900000)),
})
const customPrompt = ref(appSettings.get<string>('custom_prompt', ''))
const rowAutoExecute = useTemplateRef<InstanceType<typeof SettingRow>>('rowAutoExecute')
const rowLogLevel = useTemplateRef<InstanceType<typeof SettingRow>>('rowLogLevel')
const rowCustomPrompt = useTemplateRef<InstanceType<typeof SettingRow>>('rowCustomPrompt')
const rowApprovalTimeout = useTemplateRef<InstanceType<typeof SettingRow>>('rowApprovalTimeout')
// 上一次确认生效的档位:all 二次确认取消时恢复此值(非写死 low),避免 medium 用户误选 all
@@ -152,6 +164,11 @@ function markSaved() {
}
// 下一笔发起的审批即生效(aiShared.startApprovalTimer 每次实时读),已在跑的计时器沿用旧值。
function onCustomPromptChange() {
appSettings.set('custom_prompt', customPrompt.value)
rowCustomPrompt.value?.markSaved()
}
function onApprovalTimeoutChange() {
appSettings.set('df-approval-timeout', settings.approvalTimeout)
rowApprovalTimeout.value?.markSaved()
@@ -192,6 +209,23 @@ function onApprovalTimeoutChange() {
.confirm-actions { display: flex; justify-content: flex-end; gap: 8px; }
.btn-danger { background: var(--df-danger); color: #fff; }
.btn-danger:hover { filter: brightness(1.1); }
.custom-prompt-input {
width: 100%;
padding: 8px;
font-size: 13px;
font-family: inherit;
border: 0.5px solid var(--df-border);
border-radius: var(--df-radius-sm);
background: var(--df-bg);
color: var(--df-text);
resize: vertical;
min-height: 80px;
line-height: 1.5;
}
.custom-prompt-input:focus {
outline: none;
border-color: var(--df-primary);
}
.confirm-enter-active, .confirm-leave-active { transition: opacity 0.15s; }
.confirm-enter-from, .confirm-leave-to { opacity: 0; }
</style>
+25
View File
@@ -45,6 +45,24 @@ let _startPromise: Promise<void> | null = null
// 上一次 AiTextDelta 的内容(单窗口内 LLM 重复 delta 防御用,见 handleStreamingEvent)
let _lastDelta = ''
// 文本空闲信号(reactive ref):streaming=true 且 300ms 无新 delta 时=true。
// 按钮据此显「发送」(idle)而非「停止」。不动 streaming(streaming 是渲染态,
// 控制 MessageList 流式 block 显隐,不能在文本中途翻转,否则闪掉)。
export const textIdle = ref(true)
let _textIdleTimer: ReturnType<typeof setTimeout> | null = null
function resetTextIdleTimer(): void {
if (_textIdleTimer) clearTimeout(_textIdleTimer)
textIdle.value = false // 有新 delta → 文本活跃
_textIdleTimer = setTimeout(() => {
textIdle.value = true // 300ms 无新 delta → 文本空闲
_textIdleTimer = null
}, 300)
}
function clearTextIdleTimer(): void {
if (_textIdleTimer) { clearTimeout(_textIdleTimer); _textIdleTimer = null }
textIdle.value = true // 整轮结束/新轮开始 → 默认空闲
}
const appSettings = useAppSettingsStore()
// B-260616-17: 看门狗不重置的事件集合(审批等待/完成/错误由各自 case 内 clear)。
@@ -298,6 +316,8 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
}
_lastDelta = event.delta
state.currentText += event.delta
// 重置文本空闲定时器:每次 delta 重置 300ms。无新 delta 到 300ms → textIdle=true → 按钮白。
resetTextIdleTimer()
return true
}
@@ -305,6 +325,10 @@ function handleStreamingEvent(event: AiChatEvent): boolean {
// Agent 循环新一轮:保存当前文本到上一条 assistant 消息,新建空 assistant 消息
flushCurrentText()
state.currentText = ''
// 文本已刷新完毕 → 清空闲定时器(按钮白,等下一轮 deltas 来再红)
// 不设 streaming=false:streaming 管渲染,多轮间需持续 true 让 MessageList 渲染后续 deltas。
// 按钮白由 textIdle 独立控制,无需翻转 streaming。
clearTextIdleTimer()
state.completedTools = 0 // 新轮重置工具计数器
state.messages.push({
id: `ai-${nextMsgId()}` as MessageId,
@@ -603,6 +627,7 @@ function handleUserMessageEvent(event: AiChatEvent): boolean {
function cleanupTerminatedConversation(convId: string, reason: 'AiCompleted' | 'AiError' | 'AiHelpRequired') {
clearStreamWatchdog(convId || undefined)
clearAllToolSlowTimers() // B-260616-12: 整轮/错误/求助结束清全部工具慢执行计时器与已提示集合
clearTextIdleTimer() // 清文本空闲定时器 + 置 textIdle=true(整轮结束不该有活跃信号残留)
flushCurrentText()
state.currentText = ''
setStreaming(false, { convId: convId || null, reason })
+13 -11
View File
@@ -23,7 +23,7 @@ import type { ContentPart, AiMessage, MentionSpan, MessageId } from '@/api/types
import { t } from '@/i18n/i18n-helpers'
import { resetStreamWatchdog, clearStreamWatchdog } from './useAiStream'
import { setStreaming } from './streamingGuard'
import { startListener } from './useAiEvents'
import { startListener, flushCurrentText } from './useAiEvents'
import { nextMsgId, startApprovalTimer, clearApprovalTimer, clearAllApprovalTimers, resolveAiLang, convStates } from './aiShared'
/// 待发送队列上限(超过抛错提示用户)
@@ -71,6 +71,10 @@ const modelOverride = ref<string | null>(null)
* undefined/空数组 → 本地 user 消息 mentionSpans 置 undefined(纯文本气泡零回归)+ 后端不传。
*/
async function doSend(text: string, skill?: string, force = false, parts?: ContentPart[], spans?: MentionSpan[]) {
// 防御:先 flush 残留 currentText(正常情况已是空,flushCurrentText 直接 return;
// 异常时序下可防止上一轮文本被 state.currentText='' 误清)
flushCurrentText()
const userMsgId = `user-${nextMsgId()}`
state.messages.push({
id: userMsgId as MessageId,
@@ -291,16 +295,16 @@ export function drainQueue(convId?: string | null) {
* 发送消息 — 三级降级(B-260616-02 L2 发送韧性):
*
* L0 normal: 后端 idle → 直接 doSend()
* L1 queued: 后端 busy → 入队等 AiCompleted 续发(≤30s 正常等待)
* L1 queued: 后端 busy → 入队等 AiCompleted 续发
* L2 force: 排队>30s → 返回特殊标记,由调用方(handleSend)弹 confirm;
* 用户确认后重新进 sendMessage(forceMode=true)走 forceSend IPC
*
* forceMode=true 时跳过入队,直接走 ai_chat_force_send。
*
* Input Augmentation: spans 随消息透传(L0 直接 doSend / L2 force doSend);
* L1 入队时 queue item 当前不挂 spans(队列续发属异步重试,mention 区间在首次发送时
* 已与文本对齐,排队等待后用户可能改输入,续发用旧 spans 语义模糊;续发走无 mention 路径,
* 与现有 parts 入队续发的设计取舍一致——队列为韧性保内容,非保全部上下文元数据)。
* 注:入队条件仅用 backendGenerating(IPC 查后端真实态),不再检查 state.streaming。
* streaming 是渲染态(文本是否在屏幕上输出),不能作为「后端能否接收消息」的代理。
* 文本显示完但后端在落库时,streaming 仍 true——此时按钮已白(基于 delta 时间戳),
* 用户点击发送,若后端还在忙则入队短暂等待(~1s),AiCompleted 后自动续发。
*/
async function sendMessage(text: string, skill?: string, forceMode = false, parts?: ContentPart[], spans?: MentionSpan[]) {
if (!text.trim() && !(parts && parts.length)) return
@@ -320,14 +324,12 @@ async function sendMessage(text: string, skill?: string, forceMode = false, part
backendGenerating = false
}
// ── L1:后端 busy → 入队 ──
if (backendGenerating || state.streaming) {
// ── L1:后端 busy → 入队(消息已在本地显示,待后端空闲后自动发送)──
// 仅用 backendGenerating,不用 state.streaming。streaming 是渲染态,不是后端忙的代理。
if (backendGenerating) {
if (state.queue.length >= QUEUE_LIMIT) {
throw new Error(t('ai.queueFull', { limit: QUEUE_LIMIT }))
}
setStreaming(true, { convId: state.activeConversationId, reason: 'L1-enqueue' })
// F-260614-05 Phase 2b: 入队项挂 parts(供 drainQueue/续发时本地 user 消息渲染图)
// 同时挂 conversationId,供 drainQueue 按会话精准续发(防跨会话串话)
state.queue.push({
text: text.trim(),
skill: skill || undefined,
+4
View File
@@ -137,6 +137,10 @@ export default {
labelShowTokenUsage: 'Show token usage',
descShowTokenUsage: 'Show token consumption of each reply in conversations',
labelCustomPrompt: 'Custom prompt',
descCustomPrompt: 'Appended to the AI system prompt to customize AI behavior',
placeholderCustomPrompt: 'e.g.: You specialize in Rust development questions...',
labelStreamingMd: 'Streaming Markdown rendering',
descStreamingMd: 'Render code blocks/lists/headings during streaming (unclosed code blocks degrade to plain text to avoid flicker); when off, only plain text is shown during streaming and full formatting applies after generation completes',

Some files were not shown because too many files have changed in this diff Show More