diff --git a/.cargo/config.toml b/.cargo/config.toml index 2685761..7be80a3 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -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 diff --git a/.zed/settings.json b/.zed/settings.json index dcf0b43..8a876f6 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -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/**" + ] } diff --git a/crates/df-ai-core/src/model.rs b/crates/df-ai-core/src/model.rs index 8e93ab7..795749e 100644 --- a/crates/df-ai-core/src/model.rs +++ b/crates/df-ai-core/src/model.rs @@ -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::(expected).unwrap(), variant); } // Ord:Low < Medium < High(枚举序,路由已解耦不再用于过滤,保留供未来判别源)。 - // Free 变体已删除(B-260618-05:预设/启发式从不赋 Free,死档)。 + // Free 变体已删除(预设/启发式从不赋 Free,死档)。 assert!(CostTier::Low < CostTier::Medium); assert!(CostTier::Medium < CostTier::High); } diff --git a/crates/df-ai-core/src/provider.rs b/crates/df-ai-core/src/provider.rs index 4149cfe..dac5013 100644 --- a/crates/df-ai-core/src/provider.rs +++ b/crates/df-ai-core/src/provider.rs @@ -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] diff --git a/crates/df-ai-core/src/types.rs b/crates/df-ai-core/src/types.rs index 3a777f7..94a15d7 100644 --- a/crates/df-ai-core/src/types.rs +++ b/crates/df-ai-core/src/types.rs @@ -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, pub role: MessageRole, pub content: String, - /// 多模态内容片(F-260614-05 Phase 2a)。 + /// 多模态内容片。 /// /// `None`/空 → 纯文本消息(绝大多数场景,content 即全部载荷)。 /// `Some(含 Image 片)` → 多模态消息,provider 在 `has_image()` 为真时把 parts diff --git a/crates/df-ai/src/anthropic_compat.rs b/crates/df-ai/src/anthropic_compat.rs index 829dfef..5760f7e 100644 --- a/crates/df-ai/src/anthropic_compat.rs +++ b/crates/df-ai/src/anthropic_compat.rs @@ -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 = 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, @@ -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 = 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 = 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() { diff --git a/crates/df-ai/src/context/manager_tests.rs b/crates/df-ai/src/context/manager_tests.rs index 96fb4c8..cdf0d6c 100644 --- a/crates/df-ai/src/context/manager_tests.rs +++ b/crates/df-ai/src/context/manager_tests.rs @@ -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() { diff --git a/crates/df-ai/src/context/mod.rs b/crates/df-ai/src/context/mod.rs index c85fc05..71a0c6f 100644 --- a/crates/df-ai/src/context/mod.rs +++ b/crates/df-ai/src/context/mod.rs @@ -165,7 +165,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 +191,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 +214,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, @@ -350,7 +350,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,7 +417,7 @@ impl ContextManager { &self.config } - /// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token) + /// 可变消息切片(供标记 status="compressed"/"archived_segment" + 调整 token) /// /// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走 /// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。 @@ -427,7 +427,7 @@ impl ContextManager { /// 在给定位置插入一条消息(其余向后移),并把它计入 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); @@ -449,7 +449,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 { let mut units = Vec::new(); let mut i = 0usize; @@ -488,7 +488,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 +498,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 的不返)。 diff --git a/crates/df-ai/src/context/sanitize.rs b/crates/df-ai/src/context/sanitize.rs index c4f2f95..658a853 100644 --- a/crates/df-ai/src/context/sanitize.rs +++ b/crates/df-ai/src/context/sanitize.rs @@ -151,7 +151,7 @@ pub fn sanitize_messages(messages: Vec) -> Vec { 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) -> Vec { 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() { diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index bf3e34d..0da7328 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -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(无头)→ @@ -1118,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()); @@ -1130,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); @@ -1243,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() { diff --git a/crates/df-ai/src/lib.rs b/crates/df-ai/src/lib.rs index e271388..1f45f72 100644 --- a/crates/df-ai/src/lib.rs +++ b/crates/df-ai/src/lib.rs @@ -38,9 +38,9 @@ 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; diff --git a/crates/df-ai/src/model_fetch.rs b/crates/df-ai/src/model_fetch.rs index f4d327c..fde08d2 100644 --- a/crates/df-ai/src/model_fetch.rs +++ b/crates/df-ai/src/model_fetch.rs @@ -1,4 +1,4 @@ -//! 厂商模型列表拉取 — F-01 阶段3 +//! 厂商模型列表拉取。 //! //! 按 `provider_type` 分派拉取厂商模型列表,过滤非 chat 模型,返回模型名 Vec。 //! `fetch_and_probe` 在拉取基础上对每个模型名调 `model_probe::probe` 探测出完整 `ModelConfig`。 diff --git a/crates/df-ai/src/model_probe.rs b/crates/df-ai/src/model_probe.rs index b569836..d38ebd8 100644 --- a/crates/df-ai/src/model_probe.rs +++ b/crates/df-ai/src/model_probe.rs @@ -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)); diff --git a/crates/df-ai/src/model_probe_helpers.rs b/crates/df-ai/src/model_probe_helpers.rs index 2716bdc..87ebd0b 100644 --- a/crates/df-ai/src/model_probe_helpers.rs +++ b/crates/df-ai/src/model_probe_helpers.rs @@ -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 = Vec::new(); let mut capabilities: Vec = Vec::new(); - // 中性默认:不靠模型名猜档位(B-260618-04) + // 中性默认:不靠模型名猜档位 let cost_tier = CostTier::Medium; let intelligence = IntelligenceTier::Standard; diff --git a/crates/df-ai/src/namespace_store.rs b/crates/df-ai/src/namespace_store.rs index 17d26df..0203115 100644 --- a/crates/df-ai/src/namespace_store.rs +++ b/crates/df-ai/src/namespace_store.rs @@ -220,7 +220,10 @@ mod tests { let p1 = ns.store("tool_a", content); let p2 = ns.store("tool_b", content); assert_eq!(ns.len(), 1, "相同内容应去重"); - assert_eq!(ns.read(&p1), ns.read(&p2)); + // 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] diff --git a/crates/df-ai/src/openai_compat.rs b/crates/df-ai/src/openai_compat.rs index c2c744f..ce2b234 100644 --- a/crates/df-ai/src/openai_compat.rs +++ b/crates/df-ai/src/openai_compat.rs @@ -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"); diff --git a/crates/df-ai/src/retry.rs b/crates/df-ai/src/retry.rs index dee4e51..bb8ddd7 100644 --- a/crates/df-ai/src/retry.rs +++ b/crates/df-ai/src/retry.rs @@ -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 时长的浮动比例 diff --git a/crates/df-ai/src/router.rs b/crates/df-ai/src/router.rs index 67e872d..138adef 100644 --- a/crates/df-ai/src/router.rs +++ b/crates/df-ai/src/router.rs @@ -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)→ `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 滤掉) diff --git a/crates/df-ai/src/sse_parser.rs b/crates/df-ai/src/sse_parser.rs index 178c516..6a9afce 100644 --- a/crates/df-ai/src/sse_parser.rs +++ b/crates/df-ai/src/sse_parser.rs @@ -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" 错误。 //! //! 根因分析: diff --git a/crates/df-execute/src/env_snapshot.rs b/crates/df-execute/src/env_snapshot.rs index 225ba74..0986c80 100644 --- a/crates/df-execute/src/env_snapshot.rs +++ b/crates/df-execute/src/env_snapshot.rs @@ -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 diff --git a/crates/df-execute/src/shell.rs b/crates/df-execute/src/shell.rs index d118d46..737fe99 100644 --- a/crates/df-execute/src/shell.rs +++ b/crates/df-execute/src/shell.rs @@ -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 { // 对齐 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)] diff --git a/crates/df-ideas/src/adversarial.rs b/crates/df-ideas/src/adversarial.rs index 86ab69e..2dbfada 100644 --- a/crates/df-ideas/src/adversarial.rs +++ b/crates/df-ideas/src/adversarial.rs @@ -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>, - /// 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, @@ -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, model_pool: Vec) -> 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) -> Result { 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 单测。 diff --git a/crates/df-ideas/src/adversarial_helpers.rs b/crates/df-ideas/src/adversarial_helpers.rs index ffecde2..f1aebc3 100644 --- a/crates/df-ideas/src/adversarial_helpers.rs +++ b/crates/df-ideas/src/adversarial_helpers.rs @@ -84,7 +84,7 @@ pub enum Recommendation { } // ============================================================ -// LLM 对抗评估 — prompt 构造 / JSON 解析(F-260614-03) +// LLM 对抗评估 — prompt 构造 / JSON 解析 // ============================================================ /// LLM 角色 / 输出契约的系统级约束。 diff --git a/crates/df-nodes/src/ai_node.rs b/crates/df-nodes/src/ai_node.rs index 20cb04c..24d3540 100644 --- a/crates/df-nodes/src/ai_node.rs +++ b/crates/df-nodes/src/ai_node.rs @@ -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 = 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)。 diff --git a/crates/df-nodes/src/ai_node_helpers.rs b/crates/df-nodes/src/ai_node_helpers.rs index 49cc387..42fa59b 100644 --- a/crates/df-nodes/src/ai_node_helpers.rs +++ b/crates/df-nodes/src/ai_node_helpers.rs @@ -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, } -/// 经 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,7 +114,7 @@ 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(); @@ -201,7 +201,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 +213,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 +289,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" } diff --git a/crates/df-nodes/src/ai_self_review_node.rs b/crates/df-nodes/src/ai_self_review_node.rs index 2b8a7a1..73a0b6b 100644 --- a/crates/df-nodes/src/ai_self_review_node.rs +++ b/crates/df-nodes/src/ai_self_review_node.rs @@ -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 放行"); diff --git a/crates/df-nodes/src/human_node.rs b/crates/df-nodes/src/human_node.rs index e33aea0..6c600a5 100644 --- a/crates/df-nodes/src/human_node.rs +++ b/crates/df-nodes/src/human_node.rs @@ -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 // "人工审批被拒绝(用户选择: )",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("拒绝")); diff --git a/crates/df-nodes/src/human_node_helpers.rs b/crates/df-nodes/src/human_node_helpers.rs index 23f341e..e894f88 100644 --- a/crates/df-nodes/src/human_node_helpers.rs +++ b/crates/df-nodes/src/human_node_helpers.rs @@ -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)。 /// /// 识别范围(避免误伤): diff --git a/crates/df-nodes/src/task_advance_node.rs b/crates/df-nodes/src/task_advance_node.rs index 4c2531b..9fdde4c 100644 --- a/crates/df-nodes/src/task_advance_node.rs +++ b/crates/df-nodes/src/task_advance_node.rs @@ -1,4 +1,4 @@ -//! 任务推进节点 — advance_task 推进链触发器(F-260616-02) +//! 任务推进节点 — advance_task 推进链触发器。 //! //! 实现推进链的唯一 status 写入路径(D-260616-03 落 df-nodes Node): //! 1. 读当前 TaskRecord(取 from status) diff --git a/crates/df-nodes/src/task_state_machine.rs b/crates/df-nodes/src/task_state_machine.rs index 0458e9c..16e3d0d 100644 --- a/crates/df-nodes/src/task_state_machine.rs +++ b/crates/df-nodes/src/task_state_machine.rs @@ -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(测试失败退回重审) diff --git a/crates/df-nodes/src/task_workflow_templates.rs b/crates/df-nodes/src/task_workflow_templates.rs index 0560487..3bc510e 100644 --- a/crates/df-nodes/src/task_workflow_templates.rs +++ b/crates/df-nodes/src/task_workflow_templates.rs @@ -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 diff --git a/crates/df-project/src/scan_helpers.rs b/crates/df-project/src/scan_helpers.rs index d8e6814..2a41645 100644 --- a/crates/df-project/src/scan_helpers.rs +++ b/crates/df-project/src/scan_helpers.rs @@ -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 { diff --git a/crates/df-storage/src/crud/conversation_repo.rs b/crates/df-storage/src/crud/conversation_repo.rs index 0999bc4..b32bd91 100644 --- a/crates/df-storage/src/crud/conversation_repo.rs +++ b/crates/df-storage/src/crud/conversation_repo.rs @@ -53,7 +53,7 @@ fn ai_provider_from_row(row: &Row<'_>) -> std::result::Result("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 → 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( @@ -420,7 +420,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"); diff --git a/crates/df-storage/src/crud/idea_repo.rs b/crates/df-storage/src/crud/idea_repo.rs index 43025ae..a43191f 100644 --- a/crates/df-storage/src/crud/idea_repo.rs +++ b/crates/df-storage/src/crud/idea_repo.rs @@ -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 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))), } } diff --git a/crates/df-storage/src/crud/task_repo.rs b/crates/df-storage/src/crud/task_repo.rs index 03cdca0..cd2230e 100644 --- a/crates/df-storage/src/crud/task_repo.rs +++ b/crates/df-storage/src/crud/task_repo.rs @@ -47,7 +47,7 @@ fn task_from_row(row: &Row<'_>) -> std::result::Result WHERE 子句 + Vec 参数)替代 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, diff --git a/crates/df-storage/src/migrations.rs b/crates/df-storage/src/migrations.rs index 2278718..a079974 100644 --- a/crates/df-storage/src/migrations.rs +++ b/crates/df-storage/src/migrations.rs @@ -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")。 @@ -290,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(从未退回过的任务)。 @@ -343,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 过渡)。 @@ -358,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 路径零变化)。 @@ -374,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 @@ -401,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 完成"); @@ -433,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 @@ -564,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])?; @@ -661,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 / @@ -1219,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 @@ -1265,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); @@ -1395,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 ( @@ -1451,7 +1451,7 @@ CREATE TABLE IF NOT EXISTS app_settings ( "; // ============================================================ -// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态,F-260619-03) +// 单元测试 — V21 迁移幂等安全(新库/老库/坏数据三态) // ============================================================ #[cfg(test)] @@ -1673,7 +1673,7 @@ mod tests { } // ============================================================ - // V20 迁移幂等安全(F-260619-01 任务关联灵感) + // V20 迁移幂等安全(任务关联灵感) // ============================================================ /// 构造最小老库 schema:tasks 表(无 idea_id 列,模拟 V1 建表老形态)+ schema_version。 diff --git a/crates/df-storage/src/models.rs b/crates/df-storage/src/models.rs index 7a53341..d5a7068 100644 --- a/crates/df-storage/src/models.rs +++ b/crates/df-storage/src/models.rs @@ -94,7 +94,7 @@ pub struct TaskRecord { #[serde(skip_serializing_if = "Option::is_none")] #[serde(default)] pub output_json: Option, - /// 关联灵感 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, // 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, // 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, - /// 消息级溯源:工具调用所属的 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, pub tool_call_id: String, @@ -387,7 +387,7 @@ pub struct AiToolExecutionRecord { pub decided_by: Option, // human/auto } -/// 消息记录(ai_messages 表,F-260619-03 消息拆分存储)。 +/// 消息记录(ai_messages 表,消息拆分存储)。 /// /// 每条 ChatMessage 一行,替代 `ai_conversations.messages` 的整对话 JSON 列存。 /// 主键 `id` = ChatMessage.id(构造时 ULID 风格生成),(conversation_id, seq) UNIQUE diff --git a/crates/df-storage/src/secret.rs b/crates/df-storage/src/secret.rs index de16727..fc5e8a7 100644 --- a/crates/df-storage/src/secret.rs +++ b/crates/df-storage/src/secret.rs @@ -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) { text.push('\n'); } if let Err(e) = fs::write(failcount_path(), text) { - tracing::debug!("[FR-S1] 迁移失败计数文件写入失败(忽略): {}", e); + tracing::debug!("[密钥迁移] 迁移失败计数文件写入失败(忽略): {}", e); } } @@ -172,14 +172,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,14 +188,14 @@ 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) } @@ -241,7 +241,7 @@ mod tests { #[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(), diff --git a/crates/df-tunnel/src/tunnel.rs b/crates/df-tunnel/src/tunnel.rs index 05c4d6b..12786ff 100644 --- a/crates/df-tunnel/src/tunnel.rs +++ b/crates/df-tunnel/src/tunnel.rs @@ -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<()>; diff --git a/crates/df-types/src/events.rs b/crates/df-types/src/events.rs index 67505af..18836ea 100644 --- a/crates/df-types/src/events.rs +++ b/crates/df-types/src/events.rs @@ -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, - /// 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, comment: Option, diff --git a/crates/df-workflow/src/dag_def.rs b/crates/df-workflow/src/dag_def.rs index 9adde2b..411418e 100644 --- a/crates/df-workflow/src/dag_def.rs +++ b/crates/df-workflow/src/dag_def.rs @@ -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] diff --git a/crates/df-workflow/src/executor.rs b/crates/df-workflow/src/executor.rs index f0d01dc..6e94bdb 100644 --- a/crates/df-workflow/src/executor.rs +++ b/crates/df-workflow/src/executor.rs @@ -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 { diff --git a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs index 9cd176f..a84805f 100644 --- a/src-tauri/src/commands/ai/agentic/context_lifecycle.rs +++ b/src-tauri/src/commands/ai/agentic/context_lifecycle.rs @@ -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,11 +17,11 @@ //! 注:延迟 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; @@ -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)。 @@ -100,7 +100,7 @@ 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::().ai_event_bus.publish_event(ev); let (active_msgs, lang) = { let mut session = session_arc.lock().await; @@ -168,7 +168,7 @@ pub(super) async fn maybe_auto_compress( 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::().ai_event_bus.publish_event(ev); } Ok(None) => { @@ -216,7 +216,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, // 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。 diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index 0d26a1c..f6a45d5 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -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) } diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index e4bd9be..a523f1c 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -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, diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 948699d..8a97a1e 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -8,7 +8,7 @@ use tokio::sync::Mutex; use df_ai::ai_tools::AiToolRegistry; use df_ai::context::TokenEstimator; -// 阶段2 占位配对完整性:agentic 出口第二道防线断言(深度防御)。 +// 占位配对完整性:agentic 出口第二道防线断言(深度防御)。 use df_ai::context::ContextManager; // 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。 // 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。 @@ -25,14 +25,14 @@ use df_ai::intent::{filter_tool_defs, filter_tool_defs_planned, IntentRecognizer use df_ai::coordinator::{Coordinator, ExecutionResult}; use df_ai::persona::PersonaRegistry; use df_ai::provider::{ChatMessage, CompletionRequest, LlmProvider, MessageRole}; -// CR-30-1: 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) -// 实现流前失败重试退避对齐(决策 F-260616-07 a1),避免重写退避逻辑。 +// 复用 retry::backoff_delay(jitter 1s→2s→4s) + is_status_retryable(Fatal 分类) +// 实现流前失败重试退避对齐,避免重写退避逻辑。 use df_ai::retry; -// F-01 阶段5: 智能路由 helper + TaskRequirements + 维度枚举。 +// 智能路由 helper + TaskRequirements + 维度枚举。 // 调用点构造 TaskRequirements(主对话:needs_tool_use=true, // 当前仅 Text 模态;后续多模态接入时检测消息内 Part/Image 追加 Vision), // 经 select_model_id 在 provider.model_configs 池中选最优;池空/无匹配兜底 default_model。 -// 注:路由已解耦(B-260618-03),cost_tier/intelligence 不再参与硬路由。 +// 注:路由已解耦,cost_tier/intelligence 不再参与硬路由。 use df_ai::router::{ select_model_id, Modality, TaskRequirements, }; @@ -63,7 +63,7 @@ enum ProviderResolveError { /// Agentic 循环默认最大迭代次数(可配置项的默认值) /// -/// 默认 10 轮。F-260616-01 已接入配置:AppState.agent_max_iterations(Arc) + +/// 默认 10 轮。已接入配置:AppState.agent_max_iterations(Arc) + /// ai_set_agent_max_iterations command + Settings.vue 数字配置。调用方在 loop 入口 /// load AtomicUsize 快照后透传 `max_iterations: usize` 形参,当前 loop 锁定边界, /// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。 @@ -216,7 +216,7 @@ pub const CIRCUIT_BREAKER_ENABLED: bool = true; /// 本开关无意义;CIRCUIT_BREAKER_ENABLED=true 时本开关决定呈现形态。 pub const CIRCUIT_BREAKER_HELP_EVENT: bool = true; -// 阶段2(path_auth 审批链重构):占位配对完整性开关(解 400 orphan)。 +// 占位配对完整性开关(解 400 orphan)。 // // 单一真相源:`df_ai::context_helpers::PLACEHOLDER_INTEGRITY_ENABLED`(本模块顶部已 use)。 // 删除本地副本(B 路线改进:避免与 df-ai 同名 const 双源,改一处易漏改另一处)。 @@ -262,14 +262,13 @@ mod workflow_context; // ============================================================ // L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。 -// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。 +// 设计:generating 状态机加固 + aichat 体验与 agent 能力系统化重构。 // -// 收口后(批3+,2026-06-25): +// 当前架构: // - conv_state.rs:纯逻辑 enum + 守卫 + 单测。 // - run_agentic_loop 入口桥接:迁移 ConvState(Idle→Generating / Error→Generating)。 // - guard.reset/drop 同步 ConvState→Idle(正常退出 + 异常兜底)。 -// - 批3 双轨收口:CONV_STATE_ENABLED 开关与 PerConvState.generating bool 已退役, -// ConvState 成为生成态唯一真相源(无条件迁移 + emit,无 off 降级分支)。 +// - ConvState 是生成态唯一真相源(无条件迁移 + emit)。 // - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。 // ============================================================ pub mod conv_state; @@ -280,10 +279,10 @@ pub(crate) use helpers::infer_goal_from_tool_calls; pub(crate) use helpers::is_empty_tool_result; // ============================================================ -// F-260614-04 / F-260614-04b: 单 Provider 流式结果 + fallback 辅助 +// 单 Provider 流式结果 + fallback 辅助 // ============================================================ -/// 单 candidate provider 一次迭代的流式调用结果(F-260614-04b fallback)。 +/// 单 candidate provider 一次迭代的流式调用结果。 /// /// 显式区分三态,驱动外层 `for candidate` fallback 循环: /// - `Success`:`Complete` 或 `Partial`(MidStream 保文)。携带该 provider 路由出的 @@ -309,19 +308,19 @@ enum StreamOutcome { /// DeepSeek thinking 模式推理内容(需回传到下一轮请求) reasoning_content: Option, }, - /// UX-260618-15: error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。 + /// error 字段供外层全 candidate 耗尽时 emit 最终 AiError(单气泡聚合)。 InitFailedExhausted { error: String }, - /// UX-260618-15: error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。 + /// error 字段供 stream_one_provider 内 Fatal 分支 emit AiError。 Fatal { error: String }, } -/// 单 provider 流式调用 + 重试(F-260614-04b)。 +/// 单 provider 流式调用 + 重试。 /// /// 封装一个 candidate 的:resolve_secret → ensure_resolved_key → build_provider /// → select_model_id(resolved_model 在该 candidate.model_configs 上重算) /// → 流式 stream_recv + 流前 InitFailed retryable 重试循环。 /// -/// 与波 12 主链共享的退避/分类:`retry::backoff_delay`(1s→2s→4s+jitter) + +/// 与主链共享的退避/分类:`retry::backoff_delay`(1s→2s→4s+jitter) + /// `StreamResult::InitFailed{retryable}`(镜像 retry::is_status_retryable) + /// 30s 总挂钟预算(`retry_deadline` 由调用方传入,本轮 fallback 各 candidate 共享一个预算)。 /// @@ -350,33 +349,66 @@ async fn stream_one_provider( // 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return), // 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致, // 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。 - let provider: Arc = Arc::from(match super::secret::build_provider_for(candidate) { - Ok(p) => p, - Err(msg) => { - return StreamOutcome::Fatal { error: msg }; - } - }); + let provider: Arc = match super::secret::build_provider_for(candidate) { + Ok(p) => Arc::from(p), + Err(msg) => return StreamOutcome::Fatal { error: msg }, + }; - // resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后 + // resolved_model 在本 candidate.model_configs 上重算(provider 切换后 // 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。 - let resolved_model = select_model_id(agentic_req, &candidate.model_configs) - .unwrap_or_else(|| candidate.default_model.clone()); - // model_override 穿透(对齐主链 F-01 阶段6):override 非空且在本 candidate 池中 → 用 override; + // model_override 穿透:override 非空且在本 candidate 池中 → 用 override; // 否则用 resolved_model(绝不因 override 致无模型)。 - let resolved_model = match model_override.as_deref() { + let resolved_model = resolve_model_with_override(agentic_req, candidate, model_override); + + // 流前 InitFailed 重试循环。 + // 重试逻辑抽至 stream_with_retry helper(扁平重构),本函数仅负责 provider 准备 + model 决议 + 委派。 + stream_with_retry( + &provider, candidate, messages, tool_defs, app_handle, stop_flag, notify, conv_id, + max_retries, retry_deadline, &resolved_model, last_reasoning_content, + ).await +} + +// ── resolve_model_with_override: 在 candidate.model_configs 上重算 resolved_model,穿透 override ── +// override 非空且在本 candidate 池中(enabled) → 用 override;否则用 select_model_id 结果。 +// 绝不因 override 致无模型(兜底用 default_model)。 +fn resolve_model_with_override( + agentic_req: &TaskRequirements, + candidate: &AiProviderRecord, + model_override: &Option, +) -> String { + let resolved = select_model_id(agentic_req, &candidate.model_configs) + .unwrap_or_else(|| candidate.default_model.clone()); + match model_override.as_deref() { Some(id) if !id.is_empty() && candidate.model_configs.iter().any(|m| m.model_id == id && m.enabled) => { id.to_string() } - _ => resolved_model, - }; + _ => resolved, + } +} - // 流前 InitFailed 重试循环(对齐主链 CR-30-1 / F-260616-07 决策 a1)。 - // outcome 累积 Complete/Partial;InitFailed retryable 未耗尽即重试,Fatal 立即返回。 +// ── stream_with_retry: 流式 + 流前 InitFailed retryable 重试循环(扁平抽自原嵌套 5 层 match) ── +// 返回 StreamOutcome。内部含 retry_attempt 循环 + match StreamResult 三分支: +// Complete/Partial → Success;InitFailed Fatal → Fatal;InitFailed Retryable → 重试/耗尽。 +// 嵌套 ≤ 3 层:for 内 match 内单层 if(早返回避免嵌套加深)。 +async fn stream_with_retry( + provider: &Arc, + candidate: &AiProviderRecord, + messages: &[ChatMessage], + tool_defs: &[df_ai::provider::ToolDefinition], + app_handle: &AppHandle, + stop_flag: &std::sync::atomic::AtomicBool, + notify: &tokio::sync::Notify, + conv_id: &str, + max_retries: usize, + retry_deadline: tokio::time::Instant, + resolved_model: &str, + last_reasoning_content: &Option, +) -> StreamOutcome { for retry_attempt in 0..=max_retries { let retry_request = CompletionRequest { - model: resolved_model.clone(), + model: resolved_model.to_string(), messages: messages.to_vec(), temperature: Some(0.7), max_tokens: Some(8192), @@ -386,17 +418,19 @@ async fn stream_one_provider( reasoning_content: last_reasoning_content.clone(), }; - match stream_llm(Arc::clone(&provider), retry_request, app_handle, stop_flag, notify, conv_id).await { + let result = stream_llm( + Arc::clone(provider), retry_request, app_handle, stop_flag, notify, conv_id, + ).await; + + match result { StreamResult::Complete { text, tool_calls, usage, reasoning_content } => { return StreamOutcome::Success { - text, tool_calls, usage, - incomplete: false, - resolved_model, - reasoning_content, + text, tool_calls, usage, incomplete: false, + resolved_model: resolved_model.to_string(), reasoning_content, }; } StreamResult::Partial { text, tool_calls, usage, reasoning_content } => { - // MidStream 保文不重试(决策 a1)。携带 incomplete=true 交调用方走保文路径。 + // MidStream 保文不重试。携带 incomplete=true 交调用方走保文路径。 tracing::warn!( conv_id = %conv_id, text_len = text.len(), @@ -404,10 +438,8 @@ async fn stream_one_provider( candidate.name, ); return StreamOutcome::Success { - text, tool_calls, usage, - incomplete: true, - resolved_model, - reasoning_content, + text, tool_calls, usage, incomplete: true, + resolved_model: resolved_model.to_string(), reasoning_content, }; } StreamResult::InitFailed { retryable, error } => { @@ -420,57 +452,18 @@ async fn stream_one_provider( "[ai] 候选 {} 流前失败 Fatal(4xx/鉴权),立即放弃 fallback", candidate.name, ); - // UX-260618-15: 携带 error 文本,外层 Fatal 分支 emit AiError(终态单气泡)。 return StreamOutcome::Fatal { error }; } - - let is_last = retry_attempt >= max_retries; - let now = tokio::time::Instant::now(); - let budget_exhausted = now >= retry_deadline; - - if is_last || budget_exhausted { - // 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。 - // UX-260618-15: stream_llm 不再 emit AiError(retryable 重试过程 emit 权归此处)。 - // 此处不 emit(可能切下一 candidate 成功,emit 会留残留气泡); - // 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。 - tracing::warn!( - conv_id = %conv_id, - provider = %candidate.name, - attempt = retry_attempt + 1, - budget_exhausted = budget_exhausted, - "[ai] 候选 {} 流前失败重试{}({}次),切下一 provider", - candidate.name, - if budget_exhausted { "预算耗尽" } else { "耗尽" }, - max_retries + 1, - ); - return StreamOutcome::InitFailedExhausted { error }; + // Retryable:判断是否重试预算耗尽 + if let Some(exhausted_error) = check_retry_exhausted( + conv_id, candidate, retry_attempt, max_retries, retry_deadline, &error, + ) { + return StreamOutcome::InitFailedExhausted { error: exhausted_error }; } - - // 退避复用 retry::backoff_delay(retry_attempt+1)(1s→2s→4s ±20% jitter)。 - let delay = retry::backoff_delay((retry_attempt + 1) as u32) - .min(retry_deadline.saturating_duration_since(now)); - let total_retry = retry_attempt + 1; - tracing::warn!( - conv_id = %conv_id, - provider = %candidate.name, - attempt = total_retry, - max_attempts = max_retries + 1, - delay_ms = delay.as_millis() as u64, - "[ai] 候选 {} 流前失败(Retryable),{}ms 后重试 ({}/{})", - candidate.name, delay.as_millis(), total_retry, max_retries + 1, - ); - - let ev = AiChatEvent::AiStreamRetry { - attempt: total_retry as u32, - max_attempts: (max_retries + 1) as u32, - conversation_id: Some(conv_id.to_string()), - }; - let _ = app_handle.emit("ai-chat-event", ev.clone()); - // L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp - let _ = app_handle.state::().ai_event_bus.publish_event(ev); - - tokio::time::sleep(delay).await; - continue; + // 未耗尽:退避 + emit AiStreamRetry + sleep + continue + emit_retry_attempt( + app_handle, conv_id, retry_attempt, max_retries, retry_deadline, + ).await; } } } @@ -482,6 +475,68 @@ async fn stream_one_provider( } } +// ── check_retry_exhausted: 判断 InitFailed Retryable 是否已耗尽重试预算 ── +// 返回 Some(error) 表示已耗尽(调用方应返回 InitFailedExhausted);None 表示可继续重试。 +fn check_retry_exhausted( + conv_id: &str, + candidate: &AiProviderRecord, + retry_attempt: usize, + max_retries: usize, + retry_deadline: tokio::time::Instant, + error: &str, +) -> Option { + let is_last = retry_attempt >= max_retries; + let now = tokio::time::Instant::now(); + let budget_exhausted = now >= retry_deadline; + if !is_last && !budget_exhausted { + return None; + } + // 本 candidate 重试耗尽 / 预算耗尽 → 交外层切下一 candidate。 + // 不在此 emit AiError(可能切下一 candidate 成功,emit 会留残留气泡); + // 携带 error 交外层「全 candidate 耗尽」时 emit 最后一条 AiError。 + let reason = if budget_exhausted { "预算耗尽" } else { "耗尽" }; + tracing::warn!( + conv_id = %conv_id, + provider = %candidate.name, + attempt = retry_attempt + 1, + budget_exhausted = budget_exhausted, + "[ai] 候选 {} 流前失败重试{}({}次),切下一 provider", + candidate.name, reason, max_retries + 1, + ); + Some(error.to_string()) +} + +// ── emit_retry_attempt: 退避日志 + emit AiStreamRetry + sleep ── +// 前置:未耗尽重试预算(check_retry_exhausted 已返 None)。本函数完成退避后返回。 +async fn emit_retry_attempt( + app_handle: &AppHandle, + conv_id: &str, + retry_attempt: usize, + max_retries: usize, + retry_deadline: tokio::time::Instant, +) { + let now = tokio::time::Instant::now(); + let delay = retry::backoff_delay((retry_attempt + 1) as u32) + .min(retry_deadline.saturating_duration_since(now)); + let total_retry = retry_attempt + 1; + tracing::warn!( + conv_id = %conv_id, + attempt = total_retry, + max_attempts = max_retries + 1, + delay_ms = delay.as_millis() as u64, + "[ai] 流前失败(Retryable),{}ms 后重试 ({}/{})", + delay.as_millis(), total_retry, max_retries + 1, + ); + let ev = AiChatEvent::AiStreamRetry { + attempt: total_retry as u32, + max_attempts: (max_retries + 1) as u32, + conversation_id: Some(conv_id.to_string()), + }; + let _ = app_handle.emit("ai-chat-event", ev.clone()); + let _ = app_handle.state::().ai_event_bus.publish_event(ev); + tokio::time::sleep(delay).await; +} + /// Agentic 循环:流式接收 → 工具执行 → 结果回传 LLM → 循环 /// /// 退出条件: @@ -503,9 +558,7 @@ pub(crate) async fn run_agentic_loop( start_iteration: usize, model_override: Option, ) { - // B-260615-09: generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) - // F-260616-09 B 批2:guard 持 conv_id,复位改 per-conv.generating(设计 §4.3)。 - // L2 批2 1b:guard 持 app_handle,ConvState 迁移后 emit AiConvStateChanged 推前端。 + // generating 状态由 RAII guard 收敛复位(正常 exit 显式 reset;panic/异常 Drop 兜底) let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone()); // 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。 @@ -516,29 +569,13 @@ pub(crate) async fn run_agentic_loop( let hb_app = app_handle.clone(); let hb_conv_id = conv_id.clone(); tokio::spawn(async move { - loop { - tokio::select! { - _ = heartbeat_rx.changed() => { - // watch 通道关闭(发送端 Drop)→ 退出 - break; - } - _ = tokio::time::sleep(std::time::Duration::from_secs(20)) => { - // 每 20s 发一次 AiHeartbeat,前端重置 watchdog timer。 - let _ = hb_app.emit( - "ai-chat-event", - AiChatEvent::AiHeartbeat { - conversation_id: Some(hb_conv_id.clone()), - }, - ); - } - } - } + heartbeat_loop(&mut heartbeat_rx, &hb_app, &hb_conv_id).await; }); - // F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。 + // 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。 // - // 批2 把所有调用方(IPC commands.rs 写路径 + agentic/mod.rs loop + audit.rs process_tool_calls + - // conversation.rs save + title.rs + knowledge_inject.rs)迁移到 per_conv 真相源。IPC 在 spawn + // 所有调用方(IPC commands.rs 写路径 + agentic/mod.rs loop + audit.rs process_tool_calls + + // conversation.rs save + title.rs + knowledge_inject.rs)统一以 per_conv 为真相源。IPC 在 spawn // loop 前已通过 `session.conv(active_conversation_id).*` 建立 per_conv 并写入初始状态 // (messages push user / generating=true / stop_flag=false / iteration_used=0 等),故 loop // 入口只需确保 per_conv 存在(防御性 conv() 惰性建,正常路径下已存在)。 @@ -549,12 +586,12 @@ pub(crate) async fn run_agentic_loop( // // guard 语义:loop 启动占用生成态,per_conv.generating=true(IPC spawn 前已置 true,此处幂等确认)。 // - // 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢 - // per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。 + // 单 loop 安全性:单 active_conversation_id 阶段无并发 loop 抢 per_conv 覆盖。多 loop 并发时, + // 每 conv 各自 per_conv 条目,互不干扰。 // // 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。 - // F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。 + // 多 Provider 负载均衡池 — 选主 + fallback 候选列表。 // // 流程:list_all → ProviderPool::select(按 模型亲和 > weight > is_default 排序)→ 有序 Vec。 // 单 provider 场景:池仅 1 enabled provider → select 返回单元素 Vec → 首位 = 唯一 provider, @@ -596,16 +633,12 @@ pub(crate) async fn run_agentic_loop( let (primary_provider, candidates): (AiProviderRecord, Vec) = match ranked_candidates.split_first() { Some((first, rest)) => (first.clone(), rest.to_vec()), - None => { - // 空池兜底:用调用方传入的 provider_config 作唯一候选(启动行为不变)。 - // candidates 空 → fallback 循环仅跑 primary 一次,等同单 provider 路径。 - (provider_config.clone(), Vec::new()) - } + None => (provider_config.clone(), Vec::new()), }; - // FR-S1: resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂 + // resolve→ensure_resolved_key(空 key 早失败)→build_provider 三步统一走工厂 // 空 key 早失败(逻辑见 secret::ensure_resolved_key 单测):避免空 key 发请求吃 401,错误伪装成"API Key 无效" // - // B-260615-17:resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret + // resolve 一次复用——原实现 build_provider_for 成功后又独立调 resolve_provider_secret // 取 key_len(重复 keyring resolve)。现 resolve 一次:既供 key_len 诊断日志,又供 build_provider, // 去重复 keyring resolve 调用。逻辑等价于 secret::build_provider_for(resolve→ensure→build 三步), // 仅因 build_provider_for 隐藏 resolved key 无法复用而在此内联(未改 secret.rs 锁边界)。 @@ -677,7 +710,7 @@ pub(crate) async fn run_agentic_loop( "[ai] 发起 LLM 请求" ); - // F-01 阶段5: 主对话路由 — TaskRequirements(needs_tool_use=true)。 + // 主对话路由 — TaskRequirements(needs_tool_use=true)。 // 模态当前仅 Text(图像消息类型未实现,后续多模态接入时检测 Part/Image 追加 Vision)。 // select_model_id None(池空/无匹配)→ 兜底 default_model,行为与接入前一致。 let agentic_req = TaskRequirements { @@ -687,7 +720,7 @@ pub(crate) async fn run_agentic_loop( }; let resolved_model = select_model_id(&agentic_req, &provider_config.model_configs) .unwrap_or_else(|| provider_config.default_model.clone()); - // F-01 阶段6: 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。 + // 用户指定模型 override 穿透(仅主对话生效,标题/扫描/灵感仍走路由)。 // 兜底原则:override 非空且在该 provider model_configs 池中 → 用 override;否则用 resolved_model。 // 绝不让 override 导致无模型(空/不在池 → 落回路由结果,行为不变)。 // mut:F-04b 切换 candidate 后由 stream_one_provider 返回的 Success.resolved_model 覆盖 @@ -771,14 +804,12 @@ pub(crate) async fn run_agentic_loop( "[ai] 意图收敛工具" ); // 停止信号副本:stream_llm 与每轮迭代共享读取,避免重复加锁 - // notify 同取一份 Arc 引用(B-260615-14):stream_llm select! 监听 notified() 即时唤醒 - // F-260616-09 B 批2:取 per_conv 的 stop_flag/notify(设计 §4.2 :446)。 + // notify 同取一份 Arc 引用:stream_llm select! 监听 notified() 即时唤醒。 + // 取 per_conv 的 stop_flag/notify。 // conv_id 来源:run_agentic_loop 入参(loop 启动快照,与 guard 一致)。 - // 批2 桥接期 per_conv.stop_flag/notify 与顶层共享同一 Arc(入口桥接时顶层 Arc 直接搬入), - // 故 ai_chat_stop 写顶层 stop_flag 与 loop 读 per_conv stop_flag 互见(同一 AtomicBool 实例)。 let (stop_flag, notify) = { let session = session_arc.lock().await; - let conv = session.conv_read(&conv_id).expect("[F-09 B 批2] loop 入口桥接后 per_conv 必存在"); + let conv = session.conv_read(&conv_id).expect("[F-09] loop 入口桥接后 per_conv 必存在"); (conv.stop_flag.clone(), conv.notify.clone()) }; @@ -799,7 +830,7 @@ pub(crate) async fn run_agentic_loop( let mut stall_count: u32 = 0; let mut stall_warned: bool = false; - // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 + // DeepSeek thinking 模式推理内容跨轮透传 let mut last_reasoning_content: Option = None; // G1 目标钉扎:入口把 PerConvState.pinned_goals 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 @@ -1000,19 +1031,16 @@ pub(crate) async fn run_agentic_loop( } } - // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), + // system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); - // F-09 batch5 修正(用户决策「不设并发会话上限」):删除 loop 入口 acquire_global。 - // 原决策 c-1 把 global 改「会话数上限」(loop 入口持整 loop,N=3 排队第 4 个), - // 现用户取消会话数上限 → 多对话 loop 并发不限,第 N+1 个不再 await 阻塞。 - // global 字段回归原义「LLM 调用并发限流」:仅由 stream_llm 每轮 + 标题/压缩/提炼/项目分析 - // 等单次 LLM 调用点 acquire/drop(防 provider 429),不再绑 loop 生命周期。 - // per_conv(每对话 permits=2)保留 — 单对话内主循环 stream_llm + 后台标题/压缩/提炼共享该 conv 的 - // permits=2,防单对话内并发 LLM 调用失控(单对话内限流,非会话数限制,符合「不设上限」)。 - // permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。 - // retry 同 loop 内,持 per_conv 合理(F-260616-12 核验)。 + // 并发限流策略(用户决策「不设并发会话上限」): + // - global 不再由 loop 入口持整 loop(原会话数上限 N=3 已废),多对话 loop 并发不限。 + // - per_conv 由 loop 入口(_conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试), + // permits=2 防单对话内并发 LLM 调用失控。 + // - permit 绑 guard(函数返回)Drop 自动释放——各 return 点退出即释放槽位。 + // retry 同 loop 内,持 per_conv 合理。 let _conv_per_conv_permit = llm_concurrency.acquire_per_conv(&conv_id).await; // 0 = 不限:effective_max=usize::MAX,for 到不了上界,靠 stop_flag/收敛/审批退出(下方达上限暂停分支不触发) @@ -1109,28 +1137,27 @@ pub(crate) async fn run_agentic_loop( } } - // B-260615-11: 旧 loop 污染防护——每轮开始校验对话一致性。 + // 旧 loop 污染防护——每轮开始校验对话一致性。 // 用户新建/切换对话后 active_conversation_id 变更,本 loop(conv_id 快照)成陈旧, // 继续跑会往新对话 push 消息/pending 造成污染。检测到即退出(guard Drop 复位 generating)。 // - // F-260616-09 B 批2(设计 §4.1):退出判据从 `active_conversation_id != conv_id` 改为 - // `!per_conv.contains_key(conv_id)`(conv 存在性)。决策 e 真并发下旧 loop 跑自己的 conv + // 退出判据:!per_conv.contains_key(conv_id)(conv 存在性)。旧 loop 跑自己的 conv // 不污染他人,active_conversation_id 切换不应让旧 loop 退出;仅当 conv 被删(clear/delete) - // 才退出。批2 阶段单 loop,conv 不会被删,此校验主要保留语义对齐(批3+ 真并发场景生效)。 + // 才退出。单 loop 阶段 conv 不会被删,此校验主要保留语义对齐(真并发场景生效)。 // conv_id 来源:run_agentic_loop 入参(与 guard/stop_flag 取用同源)。 { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(&conv_id) { tracing::warn!( stale_conv = %conv_id, - "[ai] conv 已删除,旧 loop 退出(F-260616-09 B 批2/决策 e)" + "[ai] conv 已删除,旧 loop 退出" ); return; } - // F-260616-11 决策 a: 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。 + // 累计 iteration 计数(「下一轮起算值」= 当前轮+1)。 // 审批等待/达 max 暂停退出时,本字段即「当前轮+1」;审批续跑 ai_approve 读此值作 // start_iteration 透传,实现 iteration 累计不重置(防多次审批反复跑满 max 致 token 失控)。 - // F-09 B 批4:per_conv.iteration_used 唯一真相源,删顶层双写。 + // per_conv.iteration_used 唯一真相源。 let conv = session.conv(&conv_id); conv.iteration_used = iteration + 1; } @@ -1149,11 +1176,11 @@ pub(crate) async fn run_agentic_loop( }); } - // F-15 阶段3: 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 + // 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。 // // 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return // (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。 - // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底 / FR-S1 安全)见 + // 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底)见 // context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。 if maybe_auto_compress( &session_arc, @@ -1169,9 +1196,9 @@ pub(crate) async fn run_agentic_loop( } // 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条) - // F-260616-13: sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用 + // sys_tokens 已在 loop 外缓存;本块构建的 messages 在本轮重试循环中复用 // (本轮 stream_llm 不持 session_arc、不改 messages,重试无 push 发生,重建等价于复用)。 - // F-260616-09 B 批2:build_for_request 改 per_conv.messages(设计 §4.2 :631)。 + // build_for_request 读 per_conv.messages。 // conv_id 来源:run_agentic_loop 入参。 let messages = { let session = session_arc.lock().await; @@ -1200,9 +1227,6 @@ pub(crate) async fn run_agentic_loop( // // TOOL_RESULT_COMPRESS_ENABLED=false(常量开关)→ 跳过(原样送 LLM,排障/对比用)。 let messages: Vec = if TOOL_RESULT_COMPRESS_ENABLED { - // 历史总 token 用于占比判定(history_tokens 即 build_for_request 前的快照, - // 此处 messages 已裁剪过,用 estimated_prompt 不准;改用 history_tokens 快照更稳)。 - // 注:用 conv.messages.history_tokens() 快照做占比基准(裁剪前的真相),避免循环依赖。 let history_tokens_snapshot: u32 = { let session = session_arc.lock().await; session @@ -1210,47 +1234,12 @@ pub(crate) async fn run_agentic_loop( .map(|c| c.messages.history_tokens()) .unwrap_or(0) }; - let est = TokenEstimator::default(); - let mut compressed_bytes: usize = 0; - let mut original_bytes: usize = 0; - let mut summarized_count: usize = 0; - let msgs: Vec = messages - .into_iter() - .map(|mut m| { - if !matches!(m.role, df_ai::provider::MessageRole::Tool) { - return m; - } - let content_len = m.content.len(); - let content_tokens = est.estimate_text(&m.content); - if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { - return m; - } - original_bytes += content_len; - // tool_name:无 tool_calls 关联(本消息是 tool_result,无 name 字段),用 call_id 或占位。 - let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string()); - let compressed = extract_key_info(&m.content, &tool_name); - compressed_bytes += compressed.len(); - summarized_count += 1; - m.content = compressed; - m - }) - .collect(); - if summarized_count > 0 { - tracing::info!( - conv_id = %conv_id, - iteration, - summarized_count, - original_bytes, - compressed_bytes, - "[ai] tool_result view-only 摘要压缩(不改持久化)" - ); - } - msgs + summarize_tool_results(messages, history_tokens_snapshot, &conv_id, iteration) } else { messages }; - // 阶段2 占位配对完整性(第二道防线,depth-defense):build_for_request 已在 df-ai 内部 + // 占位配对完整性(第二道防线,depth-defense):build_for_request 已在 df-ai 内部 // 自愈一次,此处 tool_result 压缩/系统提示插入后再断言一次,防转换引入新 orphan。 // view-only:messages 是 clone,assert_placeholder_pairing 仅改本 Vec,不改 ContextManager 持久化。 let messages = ContextManager::assert_placeholder_pairing(messages, PLACEHOLDER_INTEGRITY_ENABLED); @@ -1263,26 +1252,26 @@ pub(crate) async fn run_agentic_loop( messages.iter().map(|m| est.estimate_message(m)).sum() }; - // LLM 并发限流 — F-09 batch5 修正后: - // per_conv 由 loop 入口(L521 _conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试), + // LLM 并发限流: + // per_conv 由 loop 入口(_conv_per_conv_permit)整 loop 持有(含工具执行/审批等待/重试), // 防单对话内并发 LLM 调用失控(单对话内 permits=2,非会话数限制)。 - // global 已不再由 loop 入口持有(用户决策「不设并发会话上限」),回归原义「LLM 调用并发限流」: + // global 不再由 loop 入口持有,回归原义「LLM 调用并发限流」: // 由各单次 LLM 调用点(stream_llm 重试循环内/标题/压缩/提炼/项目分析)各自 acquire/drop 防 429。 // - // CR-30-1 / F-260616-07 / 决策 a1: 流前失败(Init Err)重试,流中途失败(MidStream - // Partial)不重试保文。重试退避复用 retry::backoff_delay(1s→2s→4s±20% jitter) + - // retry::is_status_retryable Fatal 分类(stream_recv classify_status_or_class 镜像, - // 4xx 非429 立即放弃) + 30s 总挂钟预算。重试期间持有 per_conv permit 不释放(防新请求挤占)。 + // 流前失败(Init Err)重试,流中途失败(MidStream Partial)不重试保文。重试退避复用 + // retry::backoff_delay(1s→2s→4s±20% jitter) + retry::is_status_retryable Fatal 分类 + // (stream_recv classify_status_or_class 镜像, 4xx 非429 立即放弃) + 30s 总挂钟预算。 + // 重试期间持有 per_conv permit 不释放(防新请求挤占)。 // - // F-260614-04 / F-260614-04b: per-provider permit 仍在 candidate 循环内取 - // (切换 candidate 时释放旧取新,避免占用未用 provider 的槽);per_conv 由 loop 入口持有。 + // per-provider permit 仍在 candidate 循环内取(切换 candidate 时释放旧取新,避免占用未用 provider 的槽); + // per_conv 由 loop 入口持有。 // 重试总预算(挂钟,含 sleep + 各次请求耗时),对齐 retry::MAX_TOTAL_BUDGET 30s。 - // F-260614-04b:本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, + // 本轮各 candidate 共享一个 30s 预算(切换 provider 不重置预算, // 防多 provider 串行重试累加超过单轮总预算)。超预算直接放弃重试交最终错误/保文路径。 let retry_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(30); - // F-260614-04b:外层 for candidate 包裹 stream_one_provider。 + // 外层 for candidate 包裹 stream_one_provider。 // 顺序:[primary, ...candidates](空池兜底时仅 primary)。 // - Success → 用结果覆盖迭代级 resolved_model/provider_config(供 push/save/标题); // 成功即 break。 @@ -1300,7 +1289,7 @@ pub(crate) async fn run_agentic_loop( let (full_text, tool_calls_acc, round_usage, incomplete, round_reasoning_content) = { // outcome 累积成功结果(Complete/Partial),InitFailed 不写入。 let mut outcome: Option<(String, std::collections::HashMap, df_ai::provider::TokenUsage, bool, Option)> = None; - // UX-260618-15: 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 + // 追踪最后一个 Exhausted candidate 的诊断文本,供「全 candidate 耗尽」时 emit 最终 AiError。 // Fatal 分支即时 emit(终态不切候选),Exhausted 分支仅记录 error 不 emit(可能切下一 candidate 成功, // emit 会留残留气泡)。 let mut last_exhausted_error: Option = None; @@ -1311,7 +1300,7 @@ pub(crate) async fn run_agentic_loop( // 随 _provider_permit 绑定作用域 Drop 释放。 let _provider_permit = llm_concurrency.acquire_for_provider(&candidate.id).await; - match stream_one_provider( + let stream_outcome = stream_one_provider( candidate, &messages, &tool_defs, @@ -1324,7 +1313,9 @@ pub(crate) async fn run_agentic_loop( &model_override, &agentic_req, &last_reasoning_content, - ).await { + ).await; + + match stream_outcome { StreamOutcome::Success { text, tool_calls, usage, incomplete, resolved_model: m, reasoning_content: rc } => { // 成功:更新迭代级 resolved_model + provider_config(供后续 push/save/标题)。 // mut 解构(已在顶部声明 mut):本轮后续 save/push 用「实际成功所用 provider」 @@ -1339,12 +1330,12 @@ pub(crate) async fn run_agentic_loop( // 本 candidate 重试耗尽(retryable)。切下一 candidate 继续尝试。 // candidates 空(单 provider)时此即「耗尽 return」语义—— // 跳出循环后 outcome 仍 None,落入下方「全耗尽 return」兜底。 - // UX-260618-15: 不在此 emit AiError(可能切下一 candidate 成功留残留气泡), + // 不在此 emit AiError(可能切下一 candidate 成功留残留气泡), // 仅记录 error 供全耗尽兜底 emit 最后一条。 tracing::warn!( conv_id = %conv_id, provider = %candidate.name, - "[ai] 候选 {} 流前失败重试耗尽,F-04b 切换下一 provider", + "[ai] 候选 {} 流前失败重试耗尽,切换下一 provider", candidate.name, ); last_exhausted_error = Some(error); @@ -1352,19 +1343,9 @@ pub(crate) async fn run_agentic_loop( } StreamOutcome::Fatal { error } => { // Fatal(4xx 非429/鉴权/参数错):立即放弃整轮 fallback。 - // UX-260618-15: stream_llm 不再 emit AiError,此处统一 emit 最终错误气泡(单气泡聚合)。 + // 统一 emit 最终错误气泡(单气泡聚合)。 guard.reset().await; - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: error.clone(), - error_type: Some(ErrorType::Network), - conversation_id: Some(conv_id.clone()), - }); - // L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { - error, - error_type: None, - conversation_id: Some(conv_id.clone()), - }); + emit_fatal_error(&app_handle, &conv_id, &error).await; return; } } @@ -1374,8 +1355,6 @@ pub(crate) async fn run_agentic_loop( Some(r) => r, None => { // 全 candidate 耗尽(含单 provider 场景):沿用原单 provider 失败语义。 - // UX-260618-15: stream_llm 不再 emit AiError,此处 emit 最后一个 Exhausted candidate 的 - // 诊断文本作为最终错误气泡(单气泡聚合,替代原各 candidate emit 多气泡)。 tracing::warn!( conv_id = %conv_id, candidates_tried = candidate_chain.len(), @@ -1384,18 +1363,8 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; let err_msg = last_exhausted_error .unwrap_or_else(|| "AI 调用失败:所有候选 provider 重试耗尽".to_string()); - let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { - error: err_msg.clone(), - error_type: Some(ErrorType::Network), - conversation_id: Some(conv_id.clone()), - }); - // L3 emit 双写:全 candidate 耗尽 AiError publish 到事件总线(门控在 publish 内)。 - let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { - error: err_msg, - error_type: None, - conversation_id: Some(conv_id.clone()), - }); - // BUG-260623-02:全 provider 失败也需落库 user 消息(已 push 内存 chat.rs:383), + emit_fatal_error(&app_handle, &conv_id, &err_msg).await; + // 全 provider 失败也需落库 user 消息(已 push 内存), // 否则切换/重载从 DB 恢复时末条 user 丢失(实测压缩触发失败场景:DB 末条 tool 无后续 user)。 save_conversation(&session_arc, &db, &conv_id, None, Some(&resolved_model), true).await; return; @@ -1415,14 +1384,14 @@ pub(crate) async fn run_agentic_loop( tokens.add(usage.prompt_tokens, usage.completion_tokens); // 追加 partial assistant 消息(若无 tool_calls 且有文本) - // F-260616-09 B 批2(设计 §4.1 :786):退出校验改 conv 存在性 + push 改 per_conv.messages。 + // 退出校验改 conv 存在性 + push 改 per_conv.messages。 // conv_id 来源:run_agentic_loop 入参。 { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(&conv_id) { tracing::warn!( stale_conv = %conv_id, - "[ai] MidStream 保文后 conv 已删除,丢弃本轮 push(F-260616-09 B 批2)" + "[ai] MidStream 保文后 conv 已删除,丢弃本轮 push" ); return; } @@ -1430,7 +1399,7 @@ pub(crate) async fn run_agentic_loop( let conv = session.conv(&conv_id); let mut msg = ChatMessage::assistant(&full_text); msg.model = Some(resolved_model.clone()); - // BUG-260617-12: MidStream 保文也回填 reasoning_content + // MidStream 保文也回填 reasoning_content msg.reasoning_content = round_reasoning_content.clone(); conv.messages.push(msg); // 追加系统提示消息:响应因网络中断不完整(对齐决策 a1 系统提示机制) @@ -1464,98 +1433,46 @@ pub(crate) async fn run_agentic_loop( }); return; } - // F-260616-09 B 批5: global/per_conv permit 已上移 loop 入口(L520-521)整 loop 持有, + // global/per_conv permit 已上移 loop 入口整 loop 持有, // stream 后不再立即释放(会话级并发语义:工具执行期间也占槽)。per-provider permit - // (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放(F-04b 语义不变)。 + // (_provider_permit)仍在 candidate 循环内随作用域 Drop 自动释放。 // 累加本轮 token:provider 流式 usage 的 prompt_tokens 为 0 时(GLM 等),用预估输入兜底 let round_prompt = if round_usage.prompt_tokens == 0 { estimated_prompt } else { round_usage.prompt_tokens }; tokens.add(round_prompt, round_usage.completion_tokens); - // 追加 assistant 消息到历史 + // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) let has_tool_calls = !tool_calls_acc.is_empty(); - // F-260616-09 B 批2(设计 §4.1 :837):退出校验改 conv 存在性 + push 改 per_conv.messages。 - // conv_id 来源:run_agentic_loop 入参。 { let mut session = session_arc.lock().await; - // 决策 e 真并发下:push 前再校验 conv 是否仍存在(被删则退出)。 - // 读端读到被 clear 的空历史不致命,但 push 写回已删 conv 是污染,必须挡。 if !session.per_conv.contains_key(&conv_id) { tracing::warn!( stale_conv = %conv_id, - "[ai] stream 后 conv 已删除,丢弃本轮 push(F-260616-09 B 批2)" + "[ai] stream 后 conv 已删除,丢弃本轮 push" ); return; } - if has_tool_calls { - let mut order: Vec = tool_calls_acc.keys().copied().collect(); - order.sort_unstable(); - let ai_tool_calls: Vec = order.iter() - .map(|i| { - let draft = &tool_calls_acc[i]; - df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args) - }) - .collect(); - let mut msg = ChatMessage::assistant_with_tools(&full_text, ai_tool_calls); - msg.model = Some(resolved_model.clone()); - // BUG-260617-12: 回填 reasoning_content 供下一轮请求透传 - msg.reasoning_content = last_reasoning_content.clone(); - session.conv(&conv_id).messages.push(msg); - } else if !full_text.is_empty() { - let mut msg = ChatMessage::assistant(&full_text); - msg.model = Some(resolved_model.clone()); - msg.reasoning_content = last_reasoning_content.clone(); - session.conv(&conv_id).messages.push(msg); - } - // G1 目标提取: 从工具调用推理目标描述。 - // 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。 - // 无工具调用时不提取(已有目标保持不变,该轮无新目标)。 + push_assistant_message( + &mut session, &conv_id, has_tool_calls, &tool_calls_acc, + &full_text, &resolved_model, &last_reasoning_content, + ); if GOAL_PIN_ENABLED { - // 从工具调用推理目标(本轮可能多个工具,全部提取) - let new_goals = infer_goal_from_tool_calls(&tool_calls_acc); - if !new_goals.is_empty() { - let goal_conv = session.conv(&conv_id); - let mut added = false; - for g in &new_goals { - // 去重:文本不在已有目标中(包含关系检查) - let is_dup = goal_conv.pinned_goals.iter().any(|e| - e.text.contains(g.as_str()) || g.contains(&e.text) - ); - if !is_dup { - goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone())); - added = true; - } - } - // 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务) - if added && goal_conv.pinned_goals.len() > new_goals.len() { - let active_until = goal_conv.pinned_goals.len() - new_goals.len(); - for entry in goal_conv.pinned_goals.iter_mut().take(active_until) { - if matches!(entry.status, super::GoalStatus::Active) { - entry.status = super::GoalStatus::Completed; - } - } - } - // 超上限时保留 active,淘汰最早 completed - if goal_conv.pinned_goals.len() > MAX_GOALS { - let active: Vec = goal_conv.pinned_goals.iter() - .filter(|g| matches!(g.status, super::GoalStatus::Active)) - .cloned().collect(); - if active.len() <= MAX_GOALS { - let keep = MAX_GOALS - active.len(); - let completed: Vec = goal_conv.pinned_goals.iter() - .filter(|g| matches!(g.status, super::GoalStatus::Completed)) - .rev().take(keep).cloned().collect(); - let mut merged = active; - merged.extend(completed.into_iter().rev()); - goal_conv.pinned_goals = merged; - } else { - goal_conv.pinned_goals.truncate(MAX_GOALS); - } - } - } - // L3 [UPDATE_GOALS] 标记:LLM 可主动修正目标列表(意图增强/纠偏)。 - // 后续独立 PR 实现:解析 [UPDATE_GOALS]...[END] 块并替换 pinned_goals。 - } + update_pinned_goals(&mut session, &conv_id, &tool_calls_acc); + } + } + + // P0-2 根治:LLM 回复 push 后、工具执行前立即落库。 + // 背景:save_conversation 原仅在 loop 出口调用,process_tool_calls 卡住时(60s timeout + // 或 session lock 竞争)永远到不了出口,用户重启后上轮回复丢失。此处出 push 锁作用域后 + // 立即 save,幂等(每轮重复覆盖落库),即使后续工具卡住本轮消息已持久化。 + { + let usage = df_ai::provider::TokenUsage { + prompt_tokens: tokens.prompt(), + completion_tokens: tokens.completion(), + total_tokens: tokens.total(), + }; + save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; + } // 停止信号:已生成文本入库后退出,不再执行后续工具调用 if stop_flag.load(Ordering::SeqCst) { @@ -1589,7 +1506,7 @@ pub(crate) async fn run_agentic_loop( // 处理工具调用(Low 自动执行 / Medium+High 待审批) let pending_count = process_tool_calls(&session_arc, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await; - // F-260620 卡死已根治(DIRAUTH 审批链已闭环)。原 eprintln 诊断降级为 tracing::debug, + // DIRAUTH 审批链已闭环。原 eprintln 诊断降级为 tracing::debug, // 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。 tracing::debug!( conv_id = %conv_id, @@ -1598,46 +1515,9 @@ pub(crate) async fn run_agentic_loop( ); // L1 断路器:连续同类工具失败熔断(治 agent 无止损死循环,机制非 prompt 说教)。 - // CIRCUIT_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。 - // 仅检查自动执行(Low)的工具结果——pending_count>0(待审批)交给下方审批分支, - // 此处只看已回填的 Tool 消息。取末尾连续 Tool 消息(倒序 take_while role==Tool), - // 失败内容前 40 字符归一 key 计数,同 key 累计达阈值 → guard.reset + AiError + return。 + // count_recent_failures 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 计数。 if CIRCUIT_BREAKER_ENABLED { - let (max_count, sample_key) = { - let session = session_arc.lock().await; - // conv 可能已被删除(stop/新对话),get 不到 → 无消息可判,跳过本轮断路器检查。 - let messages = match session.conv_read(&conv_id) { - Some(conv) => conv.messages.all_messages_clone(), - None => Vec::new(), - }; - // 倒序取末尾连续 role==Tool 消息(本轮工具回填结果;非 Tool 即停)。 - // MessageRole 未派生 PartialEq,用 matches! 宏判变体(不改共享类型 df-ai-core)。 - let recent_tool_results: Vec<&ChatMessage> = messages - .iter() - .rev() - .take_while(|m| matches!(m.role, MessageRole::Tool)) - .collect(); - for m in recent_tool_results { - let content = m.content.as_str(); - // 失败判定:禁止/跳过重试/失败/Error 关键词(覆盖权限拒绝/路径错误/异常等)。 - let is_failure = content.starts_with("禁止") - || content.starts_with("已跳过重试") - || content.contains("失败") - || content.contains("Error") - || content.contains("error"); - if is_failure { - // 前 40 字符归一 key:同类失败(同前缀)累加,不同错误各自计数互不干扰。 - let key: String = content.chars().take(40).collect(); - *fail_counts.entry(key).or_insert(0) += 1; - } - } - // 取当前最大计数及其 key(无失败 → max_count=0,不触发)。 - fail_counts - .iter() - .max_by_key(|(_, &v)| v) - .map(|(k, &v)| (v, k.clone())) - .unwrap_or((0u32, String::new())) - }; + let (max_count, sample_key) = count_recent_failures(&session_arc, &conv_id, &mut fail_counts).await; // 锁已随作用域 drop,可安全 await/emit(避免持锁 await 死锁)。 if max_count >= CIRCUIT_BREAKER_THRESHOLD { tracing::warn!( @@ -1647,157 +1527,23 @@ pub(crate) async fn run_agentic_loop( "[ai] L1 断路器熔断:连续同类失败 {} 次,疑似死循环停止", max_count ); guard.reset().await; - // L1 求助协议(§2.3,2026-06-21):熔断改发结构化 AiHelpRequired(机制优先 prompt 说教)。 - // 开关 CIRCUIT_BREAKER_HELP_EVENT=true(默认)→ AiHelpRequired(求助卡,显 reason/options - // 供用户选);false(兜底回退)→ AiError(旧错误气泡)。两路均 guard.reset + return 强制熔断, - // 仅前端呈现形态不同,语义不变(均终止 loop,逼用户介入)。 - if CIRCUIT_BREAKER_HELP_EVENT { - let _ = app_handle.emit( - "ai-chat-event", - AiChatEvent::AiHelpRequired { - reason: format!( - "连续同类失败 {} 次,疑似死循环已停止", - max_count - ), - context: format!("最近错误: {}", sample_key), - options: vec![ - "换思路".into(), - "授权路径".into(), - "人工接管".into(), - ], - conversation_id: Some(conv_id.clone()), - }, - ); - } else { - // 兜底回退:求助卡未就绪/排障/对比时,沿用旧 AiError 错误气泡呈现。 - let _ = app_handle.emit( - "ai-chat-event", - AiChatEvent::AiError { - error: format!( - "连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}", - max_count, sample_key - ), - error_type: Some(ErrorType::Unknown), - conversation_id: Some(conv_id.clone()), - }, - ); - } + emit_circuit_breaker_tripped(&app_handle, &conv_id, max_count, &sample_key); return; } } - // G2 探索熔断:连续空结果无进展检查(治 R4 游荡死循环 + token 失控,2026-06-26)。 - // - // STALL_BREAKER_ENABLED=false → 整段跳过降级 max_iterations 旧行为(开关 + 兜底)。 - // 仅检查已回填 Tool 消息(同 L1):pending_count>0(待审批)交给上方审批分支,此处看已回填结果。 - // 取末尾连续 Tool 消息(倒序 take_while role==Tool),逐条 is_empty_tool_result: - // - 任一非空 → stall_count=0 重置 + stall_warned=false(有进展,清警示态); - // - 全空 → stall_count+=1。 - // 两段式(STALL_BREAKER_WARN_FIRST):stall_count == THRESHOLD-1 且未警示过 → 置 warned 标志, - // 警示文本拼进 system_prompt(本轮 LLM 已 stream 完,实际下轮生效——因 system_prompt 不变量, - // 下轮重新 stream 时会读最新 system_prompt;此处通过 insert 一条一次性软提示消息保本轮自纠)。 - // stall_count >= THRESHOLD → guard.reset + AiHelpRequired + return 强制熔断。 - // 与 L1 独立:is_empty_tool_result(空成功)不与 L1 is_failure(失败关键词)重叠。 + // G2 探索熔断:连续空结果无进展检查。抽至 check_stall_breaker helper(扁平重构)。 + // 返回 true=已熔断(loop 应 return),false=继续。stall_count/stall_warned 传入传出。 if STALL_BREAKER_ENABLED { - let all_empty = { - let session = session_arc.lock().await; - let messages = match session.conv_read(&conv_id) { - Some(conv) => conv.messages.all_messages_clone(), - None => Vec::new(), - }; - // 倒序取末尾连续 role==Tool 消息(本轮工具回填结果)。 - let recent_tool_results: Vec<&ChatMessage> = messages - .iter() - .rev() - .take_while(|m| matches!(m.role, MessageRole::Tool)) - .collect(); - if recent_tool_results.is_empty() { - // 本轮无工具回填(非工具轮)→ 不计入也不重置,保持 stall_count(不干扰已有计数)。 - None:: - } else { - // 任一非空 → 重置;全空 → Some(true)。 - let any_non_empty = recent_tool_results - .iter() - .any(|m| !is_empty_tool_result(&m.content)); - Some(!any_non_empty) - } - }; - // 锁已 drop,可安全 await/emit。 - if let Some(all_empty_flag) = all_empty { - if all_empty_flag { - stall_count += 1; - } else { - stall_count = 0; - stall_warned = false; - } - // 两段式警示:THRESHOLD-1 次(如 THRESHOLD=3 即第 2 次)首次警示。 - if STALL_BREAKER_WARN_FIRST - && stall_count == STALL_BREAKER_THRESHOLD.saturating_sub(1) - && !stall_warned - { - stall_warned = true; - let goal_text = if STALL_BREAKER_GOAL_REMIND { - let goals = session_arc - .lock() - .await - .conv_read(&conv_id) - .map(|c| c.pinned_goals.clone()) - .unwrap_or_default(); - if goals.is_empty() { - String::new() - } else { - let goal_summary = goals.iter() - .map(|g| g.text.trim()) - .filter(|g| !g.is_empty()) - .collect::>() - .join("; "); - format!("(当前目标: {})", goal_summary) - } - } else { - String::new() - }; - let warn_text = format!( - "⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。", - stall_count, goal_text - ); - let mut session = session_arc.lock().await; - if session.per_conv.contains_key(&conv_id) { - let conv = session.conv(&conv_id); - conv.messages.insert_at(0, ChatMessage::system(&warn_text)); - tracing::info!( - conv_id = %conv_id, - iteration, - stall_count, - "[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)" - ); - } - } - // 熔断:达 THRESHOLD 强制停止。 - if stall_count >= STALL_BREAKER_THRESHOLD { - tracing::warn!( - conv_id = %conv_id, - stall_count, - "[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", stall_count - ); - guard.reset().await; - let _ = app_handle.emit( - "ai-chat-event", - AiChatEvent::AiHelpRequired { - reason: format!( - "连续 {} 次工具返回空结果无进展,疑似目标漂移已停止", - stall_count - ), - context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(), - options: vec![ - "回顾目标".into(), - "换思路".into(), - "停止".into(), - ], - conversation_id: Some(conv_id.clone()), - }, - ); - return; - } + let (tripped, new_count, new_warned) = check_stall_breaker( + &session_arc, &app_handle, &conv_id, iteration, + stall_count, stall_warned, + ).await; + stall_count = new_count; + stall_warned = new_warned; + if tripped { + guard.reset().await; + return; } } @@ -1809,7 +1555,7 @@ pub(crate) async fn run_agentic_loop( total_tokens: tokens.total(), }; save_conversation(&session_arc, &db, &conv_id, Some(&usage), Some(&resolved_model), true).await; - // B-260615-26: 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, + // 审批等待 return 前 disarm guard——保持 generating=true 留 try_continue 续生成, // 同时 Drop 因 done=true 跳过复位 spawn(避免误复位审批态 generating 致 ai_approve→try_continue 不续) guard.disarm(); return; // generating 保持 true @@ -1819,11 +1565,11 @@ pub(crate) async fn run_agentic_loop( } // 达 MAX 未收敛(LLM 末轮仍想调工具被截断,末轮 tool_result 不再回传 LLM):转入暂停态询问用户 - // F-260616-03:不再 emit AiError + 走完成流程,改为 emit AiMaxRoundsReached + 保持 generating=true - // (仿审批等待 L313-316),等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮) + // emit AiMaxRoundsReached + 保持 generating=true(仿审批等待), + // 等用户点继续(ai_continue_loop → try_continue_agent_loop 再跑 max_iterations 轮) // 或点停止(ai_stop_loop → 走完成流程)。try_continue 续跑 iteration 由调用方传 start_iteration 决定: - // 审批续跑累计(F-260616-11 决策 a,防多次审批反复跑满 max 致 token 失控,传 session.iteration_used), - // 达 max 续跑重计(F-260616-03 决策 a,用户点继续=授权重来,传 0 + 重置 iteration_used)。 + // 审批续跑累计(防多次审批反复跑满 max 致 token 失控,传 session.iteration_used), + // 达 max 续跑重计(用户点继续=授权重来,传 0 + 重置 iteration_used)。 // 注:max_iterations=0(不限)时 effective_max=usize::MAX,for 不会正常结束至此,故不触发暂停(靠 stop/收敛/审批退出) if !converged { tracing::warn!( @@ -1882,8 +1628,8 @@ pub(crate) async fn run_agentic_loop( guard.reset().await; // generating 复位后再 emit Completed:落库/标题/提炼已在后台,前端立即感知完成 let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), completion_tokens: tokens.completion(), incomplete: None, conversation_id: Some(conv_id.clone()), pinned_goals: pinned_goals_snapshot.clone() }); - // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内, - // 无消费者空转留批3 真实消费者接入)。AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 + // L3 emit 双写:正常完成 AiCompleted publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。 + // AiTextDelta/AiToolCall* 高频事件不双写(无消费者空转)。 let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiCompleted { total_tokens: usage_total, prompt_tokens: tokens.prompt(), @@ -1893,4 +1639,349 @@ pub(crate) async fn run_agentic_loop( pinned_goals: pinned_goals_snapshot.clone(), }); } + +// ── heartbeat_loop: 后台心跳任务(emit AiHeartbeat 防前端 watchdog 误杀) ── +// 退出条件:heartbeat_rx 发送端 Drop(function 作用域结束)。每 20s emit 一次。 +async fn heartbeat_loop( + heartbeat_rx: &mut tokio::sync::watch::Receiver<()>, + app: &AppHandle, + conv_id: &str, +) { + loop { + tokio::select! { + _ = heartbeat_rx.changed() => break, + _ = tokio::time::sleep(std::time::Duration::from_secs(20)) => { + let _ = app.emit( + "ai-chat-event", + AiChatEvent::AiHeartbeat { conversation_id: Some(conv_id.to_string()) }, + ); + } + } + } +} + +// ── count_recent_failures: L1 断路器失败计数(扁平抽自原嵌套 5 层块) ── +// 读末尾连续 Tool 消息,失败内容前 40 字符归一 key 累入 fail_counts。 +// 返回 (max_count, sample_key):max_count=0 表示无失败,不触发熔断。 +async fn count_recent_failures( + session_arc: &Arc>, + conv_id: &str, + fail_counts: &mut std::collections::HashMap, +) -> (u32, String) { + let messages = { + let session = session_arc.lock().await; + match session.conv_read(conv_id) { + Some(conv) => conv.messages.all_messages_clone(), + None => Vec::new(), + } + }; + let recent_tool_results: Vec<&ChatMessage> = messages + .iter().rev() + .take_while(|m| matches!(m.role, MessageRole::Tool)) + .collect(); + for m in recent_tool_results { + if is_failure_content(&m.content) { + let key: String = m.content.chars().take(40).collect(); + *fail_counts.entry(key).or_insert(0) += 1; + } + } + fail_counts + .iter() + .max_by_key(|(_, &v)| v) + .map(|(k, &v)| (v, k.clone())) + .unwrap_or((0u32, String::new())) +} + +// ── is_failure_content: 失败判定(禁止/跳过重试/失败/Error 关键词) ── +fn is_failure_content(content: &str) -> bool { + content.starts_with("禁止") + || content.starts_with("已跳过重试") + || content.contains("失败") + || content.contains("Error") + || content.contains("error") +} + +// ── summarize_tool_results: tool_result view-only 摘要压缩(扁平抽自原嵌套 map 块) ── +// 超阈值的 Tool 消息用 extract_key_info 压缩 content;非 Tool / 未超阈值 原样返回。 +// 仅改入参 Vec(clone),不改 ContextManager 持久化(下次 build_for_request 从 DB 全量重建)。 +fn summarize_tool_results( + messages: Vec, + history_tokens_snapshot: u32, + conv_id: &str, + iteration: usize, +) -> Vec { + let est = TokenEstimator::default(); + let mut compressed_bytes: usize = 0; + let mut original_bytes: usize = 0; + let mut summarized_count: usize = 0; + let msgs: Vec = messages + .into_iter() + .map(|mut m| { + if !matches!(m.role, df_ai::provider::MessageRole::Tool) { + return m; + } + let content_len = m.content.len(); + let content_tokens = est.estimate_text(&m.content); + if !should_summarize_tool_result(content_len, history_tokens_snapshot, content_tokens) { + return m; + } + original_bytes += content_len; + let tool_name = m.tool_call_id.clone().unwrap_or_else(|| "tool".to_string()); + let compressed = extract_key_info(&m.content, &tool_name); + compressed_bytes += compressed.len(); + summarized_count += 1; + m.content = compressed; + m + }) + .collect(); + if summarized_count > 0 { + tracing::info!( + conv_id = %conv_id, iteration, summarized_count, original_bytes, compressed_bytes, + "[ai] tool_result view-only 摘要压缩(不改持久化)" + ); + } + msgs +} + +// ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ── +// 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。 +fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) { + let ev = if CIRCUIT_BREAKER_HELP_EVENT { + AiChatEvent::AiHelpRequired { + reason: format!("连续同类失败 {} 次,疑似死循环已停止", max_count), + context: format!("最近错误: {}", sample_key), + options: vec!["换思路".into(), "授权路径".into(), "人工接管".into()], + conversation_id: Some(conv_id.to_string()), + } + } else { + AiChatEvent::AiError { + error: format!( + "连续同类失败 {} 次,疑似死循环已停止。请换思路或人工介入。最近错误: {}", + max_count, sample_key + ), + error_type: Some(ErrorType::Unknown), + conversation_id: Some(conv_id.to_string()), + } + }; + let _ = app_handle.emit("ai-chat-event", ev); +} + +// ── check_stall_breaker: G2 探索熔断检查(扁平抽自原嵌套 6 层块) ── +// 返回 (tripped, new_stall_count, new_stall_warned): +// - tripped=true → 已 emit AiHelpRequired 且 loop 应 return(调用方需先 guard.reset 再 return) +// - tripped=false → 正常继续,新 stall_count/warned 供调用方回写。 +// 嵌套 ≤ 3 层:全空判→计数/警示→达阈值 emit,各段早返回。 +async fn check_stall_breaker( + session_arc: &Arc>, + app_handle: &AppHandle, + conv_id: &str, + iteration: usize, + stall_count: u32, + stall_warned: bool, +) -> (bool, u32, bool) { + // 1) 读取末尾连续 Tool 消息(本轮工具回填结果),判断全空或任一非空 + let all_empty: Option = { + let session = session_arc.lock().await; + let messages = match session.conv_read(conv_id) { + Some(conv) => conv.messages.all_messages_clone(), + None => Vec::new(), + }; + let recent_tool_results: Vec<&ChatMessage> = messages + .iter().rev() + .take_while(|m| matches!(m.role, MessageRole::Tool)) + .collect(); + if recent_tool_results.is_empty() { + None // 本轮无工具回填(非工具轮)→ 不计入也不重置 + } else { + let any_non_empty = recent_tool_results + .iter().any(|m| !is_empty_tool_result(&m.content)); + Some(!any_non_empty) + } + }; + + let all_empty_flag = match all_empty { Some(v) => v, None => return (false, stall_count, stall_warned) }; + + // 2) 更新 stall_count / stall_warned + let (mut new_count, mut new_warned) = (stall_count, stall_warned); + if all_empty_flag { + new_count += 1; + } else { + new_count = 0; + new_warned = false; + } + + // 3) 两段式警示:达 THRESHOLD-1 首次警示(给 LLM 自纠机会) + if STALL_BREAKER_WARN_FIRST + && new_count == STALL_BREAKER_THRESHOLD.saturating_sub(1) + && !new_warned + { + new_warned = true; + insert_stall_warning(session_arc, conv_id, iteration, new_count).await; + } + + // 4) 熔断:达 THRESHOLD → emit AiHelpRequired + 返回 tripped=true + if new_count < STALL_BREAKER_THRESHOLD { + return (false, new_count, new_warned); + } + tracing::warn!( + conv_id = %conv_id, + stall_count = new_count, + "[ai] G2 探索熔断:连续 {} 次空结果无进展,疑似目标漂移停止", new_count + ); + let _ = app_handle.emit( + "ai-chat-event", + AiChatEvent::AiHelpRequired { + reason: format!("连续 {} 次工具返回空结果无进展,疑似目标漂移已停止", new_count), + context: "连续探索无产出,可能偏离原始目标或无可用数据。".into(), + options: vec!["回顾目标".into(), "换思路".into(), "停止".into()], + conversation_id: Some(conv_id.to_string()), + }, + ); + (true, new_count, new_warned) +} + +// ── insert_stall_warning: G2 软提示 insert system 消息(给 LLM 自纠机会) ── +async fn insert_stall_warning( + session_arc: &Arc>, + conv_id: &str, + iteration: usize, + stall_count: u32, +) { + let goal_text = if STALL_BREAKER_GOAL_REMIND { + fetch_goal_summary(session_arc, conv_id).await + } else { + String::new() + }; + let warn_text = format!( + "⚠ 已连续 {} 次工具返回空结果,可能偏离目标{},请回顾目标换思路或停止探索。", + stall_count, goal_text + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.insert_at(0, ChatMessage::system(&warn_text)); + tracing::info!( + conv_id = %conv_id, iteration, stall_count, + "[ai] G2 探索熔断:连续空结果警示已 insert(软提示,给 LLM 自纠机会)" + ); + } +} + +// ── fetch_goal_summary: 读 pinned_goals 拼接为 "(当前目标: a; b)" 或空串 ── +async fn fetch_goal_summary(session_arc: &Arc>, conv_id: &str) -> String { + let goals = session_arc + .lock().await + .conv_read(conv_id) + .map(|c| c.pinned_goals.clone()) + .unwrap_or_default(); + if goals.is_empty() { + return String::new(); + } + let goal_summary = goals.iter() + .map(|g| g.text.trim()) + .filter(|g| !g.is_empty()) + .collect::>() + .join("; "); + format!("(当前目标: {})", goal_summary) +} + +// ── emit_fatal_error: 致命错误统一 emit AiError(扁平抽自原 Fatal/Exhausted 双分支重复代码) ── +// emit+publish 双写 Tauri 事件总线。error_type=Network 对齐原 Fatal 分支(鉴权/4xx归类)。 +async fn emit_fatal_error(app_handle: &AppHandle, conv_id: &str, error: &str) { + let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiError { + error: error.to_string(), + error_type: Some(ErrorType::Network), + conversation_id: Some(conv_id.to_string()), + }); + // L3 emit 双写:Fatal AiError publish 到事件总线(门控在 publish 内)。 + let _ = app_handle.state::().ai_event_bus.publish_event(AiChatEvent::AiError { + error: error.to_string(), + error_type: None, + conversation_id: Some(conv_id.to_string()), + }); +} + +// ── push_assistant_message: 轮结束后向 session.messages 推入 assistant 消息(扁平抽自原嵌套块) ── +// has_tool_calls=true: assistant_with_tools(文本+工具调用占位), false 且文本非空: assistant(纯文本)。 +// 两分支都不为空且都不需 push 时(no tool + empty text),返回(no-op)。 +// 入参 session 需外层调用方持锁;本函数只做纯内存 mutate,无 await/emit,不会死锁。 +fn push_assistant_message( + session: &mut AiSession, + conv_id: &str, + has_tool_calls: bool, + tool_calls_acc: &std::collections::HashMap, + full_text: &str, + resolved_model: &str, + last_reasoning_content: &Option, +) { + if has_tool_calls { + let mut order: Vec = tool_calls_acc.keys().copied().collect(); + order.sort_unstable(); + let ai_tool_calls: Vec = order.iter() + .map(|i| { + let draft = &tool_calls_acc[i]; + df_ai::provider::ToolCall::new(&draft.id, &draft.name, &draft.args) + }) + .collect(); + let mut msg = ChatMessage::assistant_with_tools(full_text, ai_tool_calls); + msg.model = Some(resolved_model.to_string()); + msg.reasoning_content = last_reasoning_content.clone(); + session.conv(conv_id).messages.push(msg); + } else if !full_text.is_empty() { + let mut msg = ChatMessage::assistant(full_text); + msg.model = Some(resolved_model.to_string()); + msg.reasoning_content = last_reasoning_content.clone(); + session.conv(conv_id).messages.push(msg); + } +} + +// ── update_pinned_goals: G1 目标钉扎提取+裁剪(扁平抽自原嵌套块,原 8 层 → 3 层) ── +// 从工具调用推理新目标 → 去重 add → 标记旧 active 为 completed → 超上限时淘汰最早 completed。 +// 入参 session 需外层调用方持锁;纯内存 mutate,无 await。 +fn update_pinned_goals( + session: &mut AiSession, + conv_id: &str, + tool_calls_acc: &std::collections::HashMap, +) { + let new_goals = infer_goal_from_tool_calls(tool_calls_acc); + if new_goals.is_empty() { + return; + } + let goal_conv = session.conv(conv_id); + let mut added = false; + for g in &new_goals { + let is_dup = goal_conv.pinned_goals.iter().any(|e| + e.text.contains(g.as_str()) || g.contains(&e.text) + ); + if !is_dup { + goal_conv.pinned_goals.push(super::GoalEntry::new(g.clone())); + added = true; + } + } + // 有新目标 → 标记之前的 active 为 completed(LLM 进入下一任务) + if added && goal_conv.pinned_goals.len() > new_goals.len() { + let active_until = goal_conv.pinned_goals.len() - new_goals.len(); + for entry in goal_conv.pinned_goals.iter_mut().take(active_until) { + if matches!(entry.status, super::GoalStatus::Active) { + entry.status = super::GoalStatus::Completed; + } + } + } + // 超上限时保留 active,淘汰最早 completed + if goal_conv.pinned_goals.len() > MAX_GOALS { + let active: Vec = goal_conv.pinned_goals.iter() + .filter(|g| matches!(g.status, super::GoalStatus::Active)) + .cloned().collect(); + if active.len() <= MAX_GOALS { + let keep = MAX_GOALS - active.len(); + let completed: Vec = goal_conv.pinned_goals.iter() + .filter(|g| matches!(g.status, super::GoalStatus::Completed)) + .rev().take(keep).cloned().collect(); + let mut merged = active; + merged.extend(completed.into_iter().rev()); + goal_conv.pinned_goals = merged; + } else { + goal_conv.pinned_goals.truncate(MAX_GOALS); + } + } } diff --git a/src-tauri/src/commands/ai/audit/approval.rs b/src-tauri/src/commands/ai/audit/approval.rs index ba889e3..9098489 100644 --- a/src-tauri/src/commands/ai/audit/approval.rs +++ b/src-tauri/src/commands/ai/audit/approval.rs @@ -1,4 +1,4 @@ -//! F-#97 / AE-04 / 阶段4 审批门控逻辑。 +//! 审批门控逻辑。 //! //! 审批策略(方案 B:Persona 维度 + 扩展点): //! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义) @@ -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>`,内部短 lock 读 +/// 签名改 `session_arc: &Arc>`,内部短 lock 读 /// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。 pub(super) async fn check_trust_hits( draft: &ToolCallDraft, @@ -118,7 +118,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>`,所有慢操作 +/// 签名改 `session_arc: &Arc>`,所有慢操作 /// (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 +132,7 @@ pub(super) async fn insert_pending_approval( db: &Arc, 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 +142,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 +179,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 +203,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 +224,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 +240,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>`,所有 session 访问 +/// 签名改 `session_arc: &Arc>`,所有 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 +270,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 +283,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 即时反馈)。 diff --git a/src-tauri/src/commands/ai/audit/cache.rs b/src-tauri/src/commands/ai/audit/cache.rs index 2532cce..de9d9f1 100644 --- a/src-tauri/src/commands/ai/audit/cache.rs +++ b/src-tauri/src/commands/ai/audit/cache.rs @@ -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>, @@ -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 }; diff --git a/src-tauri/src/commands/ai/audit/diff.rs b/src-tauri/src/commands/ai/audit/diff.rs index f8273c5..25cde54 100644 --- a/src-tauri/src/commands/ai/audit/diff.rs +++ b/src-tauri/src/commands/ai/audit/diff.rs @@ -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 自身会校验 diff --git a/src-tauri/src/commands/ai/audit/finalize.rs b/src-tauri/src/commands/ai/audit/finalize.rs index 5dc0858..dc503dd 100644 --- a/src-tauri/src/commands/ai/audit/finalize.rs +++ b/src-tauri/src/commands/ai/audit/finalize.rs @@ -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) { // 区分 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) => { diff --git a/src-tauri/src/commands/ai/audit/idea_source.rs b/src-tauri/src/commands/ai/audit/idea_source.rs index 4119be8..cfb17be 100644 --- a/src-tauri/src/commands/ai/audit/idea_source.rs +++ b/src-tauri/src/commands/ai/audit/idea_source.rs @@ -1,4 +1,4 @@ -//! F-260619-04 P2(方案 B): 灵感来源(create_idea)消息级溯源补全。 +//! 灵感来源(create_idea)消息级溯源补全。 //! //! ## 背景 //! P1 把知识/审计的 `source_ref` 升级到消息级(`conv_msg:{id}`),但**灵感 `create_idea` diff --git a/src-tauri/src/commands/ai/audit/mod.rs b/src-tauri/src/commands/ai/audit/mod.rs index 33cb5cc..07ca1be 100644 --- a/src-tauri/src/commands/ai/audit/mod.rs +++ b/src-tauri/src/commands/ai/audit/mod.rs @@ -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,7 +137,7 @@ 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 子超时)。 @@ -164,26 +164,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 = 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 = match tokio::time::timeout( @@ -226,7 +226,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 +257,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 +294,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 +304,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 +348,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 +381,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 +431,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 重构后锁外并行,根本消除阻塞。 @@ -503,13 +503,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; @@ -595,7 +595,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" { @@ -625,7 +625,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; @@ -648,7 +648,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] diff --git a/src-tauri/src/commands/ai/audit/path_auth.rs b/src-tauri/src/commands/ai/audit/path_auth.rs index d383e52..74d6a2e 100644 --- a/src-tauri/src/commands/ai/audit/path_auth.rs +++ b/src-tauri/src/commands/ai/audit/path_auth.rs @@ -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`: diff --git a/src-tauri/src/commands/ai/audit/reason.rs b/src-tauri/src/commands/ai/audit/reason.rs index a3ee62e..98c2292 100644 --- a/src-tauri/src/commands/ai/audit/reason.rs +++ b/src-tauri/src/commands/ai/audit/reason.rs @@ -36,7 +36,7 @@ pub(super) async fn resolve_project_label(db: &Arc, 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, id: &str) -> String { diff --git a/src-tauri/src/commands/ai/audit/record.rs b/src-tauri/src/commands/ai/audit/record.rs index 964414e..68d7d79 100644 --- a/src-tauri/src/commands/ai/audit/record.rs +++ b/src-tauri/src/commands/ai/audit/record.rs @@ -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),展示侧兼容。 diff --git a/src-tauri/src/commands/ai/audit/restore.rs b/src-tauri/src/commands/ai/audit/restore.rs index a512fd7..547de81 100644 --- a/src-tauri/src/commands/ai/audit/restore.rs +++ b/src-tauri/src/commands/ai/audit/restore.rs @@ -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(各归各,不串)。 @@ -69,19 +69,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 +98,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 +129,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 +139,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 +156,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。这是构造恒等式而非外部不变量—— diff --git a/src-tauri/src/commands/ai/augmentation/inject.rs b/src-tauri/src/commands/ai/augmentation/inject.rs index fda0bef..33b1b70 100644 --- a/src-tauri/src/commands/ai/augmentation/inject.rs +++ b/src-tauri/src/commands/ai/augmentation/inject.rs @@ -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) 同源。 /// diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index dff03fb..a7a67d0 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -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)> = 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()); } diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 28a70ed..0b06733 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -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 } diff --git a/src-tauri/src/commands/ai/stream_recv.rs b/src-tauri/src/commands/ai/stream_recv.rs index d40d111..bc817e5 100644 --- a/src-tauri/src/commands/ai/stream_recv.rs +++ b/src-tauri/src/commands/ai/stream_recv.rs @@ -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, +) { + 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::().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()); diff --git a/src-tauri/src/commands/workflow.rs b/src-tauri/src/commands/workflow.rs index 2e8a487..c63da4c 100644 --- a/src-tauri/src/commands/workflow.rs +++ b/src-tauri/src/commands/workflow.rs @@ -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; } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1a0a9c..ff9556c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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") +} diff --git a/src/api/ai.ts b/src/api/ai.ts index 7eec5ec..c25fb6d 100644 --- a/src/api/ai.ts +++ b/src/api/ai.ts @@ -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 { 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 { @@ -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 { @@ -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 { 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 { @@ -205,7 +205,7 @@ export const aiApi = { }, /** - * 单模型探测(F-01 阶段5):纯 CPU 启发式 + 预设表,无网络。 + * 单模型探测:纯 CPU 启发式 + 预设表,无网络。 * 返回填充了 probe_source 的 ModelConfig。用途:手动补模型名后探测能力维度。 */ probeModel(modelId: string): Promise { @@ -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 { 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 { return invoke('ai_get_allowed_dirs') }, - /** F-260619-03 Phase A: 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */ + /** 设置 AI 工具授权目录(持久化 + 同步内存白名单),返回规范化后的列表 */ setAllowedDirs(dirs: string[]): Promise { return invoke('ai_set_allowed_dirs', { dirs }) }, diff --git a/src/components/ai/ChatInput.vue b/src/components/ai/ChatInput.vue index d1e14d7..2d19240 100644 --- a/src/components/ai/ChatInput.vue +++ b/src/components/ai/ChatInput.vue @@ -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 { /** 发送可用:有文本或图片或已选技能(纯技能调用允许空文本) */ 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' - // cs === null (conv_state 已 idle 收敛删项) 或 cs === 'idle' → 发送态 - return 'idle' + const textActive = store.state.streaming && !textIdle.value + return textActive ? 'stop' : 'idle' }) // ── 技能 `/` 联想 ── diff --git a/src/components/settings/AdvancedSection.vue b/src/components/settings/AdvancedSection.vue index 7ceb18d..0a17f58 100644 --- a/src/components/settings/AdvancedSection.vue +++ b/src/components/settings/AdvancedSection.vue @@ -38,6 +38,16 @@ + + +