From 8ce18cb4827c63be1a79248e6abe5927aa1e1f17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Fri, 26 Jun 2026 20:01:28 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20AI=20=E5=AF=B9=E8=AF=9D?= =?UTF-8?q?=E7=9B=AE=E6=A0=87=E9=92=89=E6=89=8E+=E6=8E=A2=E7=B4=A2?= =?UTF-8?q?=E7=86=94=E6=96=AD+=E8=AF=9D=E9=A2=98=E6=A0=87=E8=AE=B0?= =?UTF-8?q?=E9=99=8D=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 治 AI Chat 目标丢失/意图丢失(实测会话 480万token,目标消息被压缩出局,LLM 尾部盲搜死循环): - G1 目标钉扎:首条 user 目标提取存 PerConvState.pinned_goal,run_agentic_loop 入口拼进 system_prompt 尾部(loop 不变量,免疫 F-15 压缩/裁剪/sanitize,治目标物理出局) - G2 探索熔断:连续 N 次空结果无进展 → 两段式警示 + AiHelpRequired 熔断(治游荡死循环) - G4 话题标记降级:pinned_goal 存在时跳过 topic marker insert(治反向误导+双锚点稀释) - G3 衔接:pinned_goal 内容态与 ConvState 生命周期态正交,不进 enum 各改进配开关 flag(GOAL_PIN_ENABLED/STALL_BREAKER_*/TOPIC_MARKER_GOAL_AWARE)可单点回退。 诊断报告见 docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md。 --- .../专项设计/AI对话目标丢失诊断-2026-06-26.md | 106 ++++++ docs/todo.md | 20 ++ .../src/commands/ai/agentic/conv_state.rs | 9 + src-tauri/src/commands/ai/agentic/mod.rs | 322 +++++++++++++++++- src-tauri/src/commands/ai/commands/chat.rs | 91 ++++- src-tauri/src/commands/ai/mod.rs | 25 ++ 6 files changed, 571 insertions(+), 2 deletions(-) create mode 100644 docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md diff --git a/docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md b/docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md new file mode 100644 index 0000000..9e38ecb --- /dev/null +++ b/docs/02-架构设计/专项设计/AI对话目标丢失诊断-2026-06-26.md @@ -0,0 +1,106 @@ +# AI 对话「目标丢失 / 意图丢失」诊断报告 + +> 日期:2026-06-26 +> 触发:AI Chat 使用中,用户发送 Bug / 分析需求目标后,分析过程中目标丢失,系统不知道做什么、完全跑偏。 +> 方法:直读对话数据库(`AppData/Roaming/top.1216.devflow/devflow-dev.db`)实测会话 + 源码机制走查。 +> 性质:基于代码 + 数据库实证的机制级诊断(非模型分析猜测)。 + +--- + +## 一、现场铁证(会话 `54c8b5bd`) + +| 指标 | 实测值 | 含义 | +|---|---|---| +| `prompt_tokens` | **4,809,714**(约 480 万) | 远超 GLM 1M 窗口,反复触发压缩 / 裁剪 | +| 消息数 | 308 条 | 长 ReAct 链,工具结果堆积 | +| 原始目标 `[seq5]` | `分析一下 灵感模块 存在的不足 [项目: DevFlow]`,`status=compressed` | **目标消息已被踢出 LLM 上下文**(`sanitize_messages` step0 过滤 `is_active`) | +| 话题标记 `[seq0]` | `task → project`(active) | 系统判定「当前 project 话题」,但真实目标是 idea 域(灵感模块) | +| 尾部 `[seq287–307]` | 连续 10+ 次 `search_files`,关键词 `auto-execute-mode / auto_execute / 全部接管 / autoExecute / execute-mode / execute_mode / auto-exec / 接管 / df-ai-auto`,**全部 `total:0` 空结果** | 失去目标后的游荡死循环 | + +**结论**:原始目标是「分析灵感模块存在的不足」,LLM 最终在反复搜索「自动执行模式 / 全部接管」——与目标毫无关系。目标彻底丢失。 + +--- + +## 二、根因(机制级) + +### 根因 1:上下文爆炸 → 目标消息物理出局(核心) + +- `df-ai/src/context.rs`:`sanitize_messages` step0 按 `is_active()` 过滤发送视图。目标 user 消息被 F-15 压缩标 `status=compressed` 后,**不再进入 LLM 上下文**。 +- 目标仅靠**压缩摘要 system 消息**「续命」。`prompt.rs::compress_prompt` 四段式(意图 / 决策 / 文件 / 约束)+ 主题词锚点保留——但摘要是 **LLM 二次生成,信息降级严重**。「分析灵感模块不足」这类具体目标,在数次压缩后语义稀释到无法指导后续动作。 +- 480 万 token 的会话,目标消息经历多轮压缩,保真度无法保证。 + +### 根因 2:话题切换标记误判,反向强化错误方向 + +- `agentic/mod.rs:735-772`:`pending_topic_marker` 基于末两条 user 消息的 `IntentRecognizer` 关键词推断(双高置信 ≥0.7 才标),`insert_at(0)` 软提示 system「请以新话题为准」。 +- 失效点: + - 长链里 user 消息早被压缩出局,topic 推断基于**已失真历史**。 + - 本例最后 active 标记是 `task→project`,与真实目标(灵感模块 / idea 域)**背离**——软提示反而告诉 LLM「现在是 project 话题」,帮倒忙。 + - marker 只在 push **user 消息**时检测,工具轮次间不参与。 + +### 根因 3:单链 ReAct,无目标重锚定(架构本质) + +- `df-ai/src/coordinator.rs`:**纯空壳 TODO**(B 路线占位,`AgentCoordinator::run` 直接返 `"TODO: Agent 协作结果"`)。 +- `df-ai/src/planner.rs`:Plan DAG + Kahn 分层 + validate 齐全,但文件头明确「纯函数零 IO 零状态」「不接入 agentic loop,待 Phase B+C」。`PLANNING_ENABLED=true` 只门控**工具排序**(`filter_tool_defs_planned`),**不做多步规划执行**。 +- agentic loop 每轮只在 push 新 user 消息时检测话题切换,**工具轮次间不重申目标**。没有「当前目标 + 进度」结构化状态,LLM 每轮自行决定下一步,长链必然游荡(`[seq287–307]` 实证)。 + +### 根因 4:无探索熔断 + +- `[seq287–307]` 连续 10+ 次空结果不中断。系统不跟踪「已搜过什么 / 连续 N 次无进展」,LLM 无限换关键词。 +- 这是目标丢失的**放大器**:不知道目标 → 不知道该停 → 无限游荡,token 失控(480 万)。 + +### 根因 5:system prompt「聚焦准则」是文字说教,非机制 + +- `prompt.rs:104-108` 有「聚焦准则:始终围绕用户当前请求的核心目标」——纯 prompt 文字,靠 LLM 自觉。 +- 目标一旦从上下文(消息流)消失,文字准则**无锚点可抓**。没有「每轮把目标钉在 system 顶部」的机制。 + +--- + +## 三、为什么现有设计压不住 + +| 现有机制 | 性质 | 为什么压不住 | +|---|---|---| +| 压缩摘要(F-15) | **被动**信息降级 | 摘要保真靠 LLM,多轮压缩后目标语义稀释 | +| 话题切换标记 | 软提示 + 关键词推断 | 基于失真历史,本例反向误导 | +| 聚焦准则 prompt | 文字说教 | 目标消息出局后无锚点 | +| `insert_at(0)` system 锚点 | 多个堆首位 | 话题标记 + 压缩摘要 + 关键词兜底三个锚点互相稀释 | + +三个 `insert_at(0)`(话题标记 `agentic/mod.rs:762`、压缩摘要 `:918`、关键词兜底 `:969`)堆在首位,互相稀释,没有一个是「权威目标」。 + +--- + +## 四、关联现状 + +- 当前分支 `conv-state-convergence` + `agentic/conv_state.rs`(452 行状态机)已在尝试收敛对话状态,**但目标作为一等状态尚未固化进状态机**——这是未完成的工作。 +- memory `devflow-aichat-session-analysis-2026-06-22` 已标的「最高杠杆 P0-2 编排规划能力弱」即此问题的上层概括。本报告是其**数据库实证版**。 + +--- + +## 五、方向建议(仅诊断,未实施) + +最高杠杆:**把「用户当前目标」从消息流提升为一等结构化状态**。 + +1. **目标钉扎**:首轮提取用户目标为结构化字段(不依赖压缩摘要),每轮 agentic loop 顶部重注入 system。目标消息压缩出局后,结构化目标仍在。 +2. **探索熔断**:跟踪连续无进展 / 空结果次数,超阈值强制「回顾目标」或停止,防游荡死循环 + token 失控。 +3. **状态机收敛**:把目标 + 进度纳入 `conv_state.rs` 状态机(当前分支已在做),目标作为状态而非消息。 +4. **弱化 / 修正话题标记**:话题标记基于失真历史且可能反向误导,宜降级或改为「目标漂移检测」而非 topic 关键词切换。 + +--- + +## 附:待查 Bug + +- `[seq1]` 一条 LLM 内部独白("等等,`process_tool_calls` 是从 `super::audit` 导入的。让我检查一下…")被存为 `system` 消息(`status=active`)。疑似 reasoning / 思考内容泄漏进 system 消息(`stream_recv` 归位问题),进一步污染上下文。待独立排查。 + +--- + +## 证据定位(源码) + +| 机制 | 位置 | +|---|---| +| 上下文压缩 / sanitize / topic 字段 | `crates/df-ai/src/context.rs` | +| 话题切换 marker 消费 | `src-tauri/src/commands/ai/agentic/mod.rs:735-772` | +| 压缩 insert_at 摘要 | `src-tauri/src/commands/ai/agentic/mod.rs:820, 918, 969` | +| 多 Agent 协调(空壳) | `crates/df-ai/src/coordinator.rs` | +| Plan 规划(未接入 loop) | `crates/df-ai/src/planner.rs` | +| 意图识别(关键词) | `crates/df-ai/src/intent.rs` | +| system prompt 聚焦准则 / 压缩 prompt | `src-tauri/src/commands/ai/prompt.rs:104, 233` | +| 对话状态机(进行中) | `src-tauri/src/commands/ai/agentic/conv_state.rs` | diff --git a/docs/todo.md b/docs/todo.md index 567b5cd..d65f145 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1009,3 +1009,23 @@ - [x] ✅(2026-06-25) **Task#1 自动压缩成功路径残留**(BUG-260624-05 衍生)— **AiCompressed 单事件被自动压缩路径误用 → 桌面端每次发送误弹 toast + 误刷整会话**。治法:`AiCompressed` 拆 `AiManualCompressed`(手动 IPC,3 处 emit 点,前端弹 toast)+ `AiAutoCompressed`(loop 自动,桌面静默仅复位 isCompressing 防按钮卡死,miniapp 仍插摘要气泡)。按语义拆事件而非按字段。— `src-tauri/src/commands/ai/mod.rs:207/212` - [x] ✅(2026-06-25) **Task#2 双轨状态机收口** — `generating: bool` 字段(顶层单例 + PerConvState)与 `CONV_STATE_ENABLED` 灰度开关双轨全部删除,`ConvState` enum(`conv_state.rs`,5 态)成唯一真相源:写侧经 `transition_to` 守卫(非法转换拒绝),`GeneratingGuard` new/reset/drop 无条件迁移 + emit;读侧 `can_accept_request()` 接入 chat 域 4 入口(ai_chat_send/ai_regenerate/ai_chat_edit/ai_is_generating),比 `is_active()` 更严谨(覆盖 Stopping 态竞态)。开关常量已删,代码内 `CONV_STATE_ENABLED off 回退分支` 注释为说明性遗留(实际不可触发,待清理)。详案见 [generating状态机加固-2026-06-15.md §收口记录](./02-架构设计/专项设计/generating状态机加固-2026-06-15.md)。— `src-tauri/src/commands/ai/agentic/{conv_state.rs,guard.rs}` + `commands/ai/{mod.rs,commands/chat.rs}` + +--- + +### 🔍 2026-06-26 灵感模块诊断待办(合并推进·单任务) + +> 全面审查灵感模块(df-storage / df-ideas / commands/idea.rs / Ideas.vue / IdeaDetail.vue)后发现的 P0-P2 问题。 +> 排除项:~~#3 status 无状态机~~(暂保留状态可跳跃迁移,不处理)。 +> 合并为单个任务推进,不拆子任务。 + +- [ ] **IDEA-FIX-01 [P0🔴]** — **ideas 表无软删除**。`delete_idea` 是硬删除(直接 `delete()`),无 `deleted_at` 列、无 restore API。误删不可恢复。需对标 tasks/projects 的软删除全套模板(`deleted_at` 列 + V28 迁移 + `list_active` 过滤 + restore IPC)。— `commands/idea.rs:104` + `df-storage/src/crud/idea_repo.rs` + `migrations.rs` +- [ ] **IDEA-FIX-02 [P0🔴]** — **ideas 白名单含 id/created_at**。`settings.rs:122` ideas 白名单包含 `id`、`created_at`,可通过 `update_idea` 篡改主键或创建时间。需移除(对标 B-260616-16 tasks 白名单修复)。— `commands/settings.rs:122` +- [ ] **IDEA-FIX-03 [P0🔴]** — **priority 无值域校验**。`create_idea` / `update_idea` 不校验 priority 范围,传入 -5 或 999 静默降级为 Medium。需加 `parse::() ∈ 0..=3` 校验(对标 B-260615-15 tasks priority 修复)。— `commands/idea.rs:73,88` +- [ ] **IDEA-FIX-04 [P1🟡]** — **record_to_idea 有损转换**。`record_to_idea()` 硬编码 `status=Draft`(丢弃 DB 真实 status)+ `related_ids=空 Vec`(丢弃已保存关联)。评估时上下文不完整。需从 IdeaRecord 读真实值。— `commands/idea.rs:298-312` +- [ ] **IDEA-FIX-05 [P1🟡]** — **评分关键词硬编码**。`scoring.rs` 关键词全写死中文("复用"/"重构"/"迁移"…),不可配置,英文/非技术项目评分失真。需提取为配置文件或 DB 表。— `df-ideas/src/scoring.rs` +- [ ] **IDEA-FIX-06 [P1🟡]** — **promote 补偿用 purge(不可恢复)**。`promote_idea` 回写失败时 `purge_with_descendants`(永久删除项目)。应改为 `delete`(软删除,可恢复)。— `commands/idea.rs:179` +- [ ] **IDEA-FIX-07 [P1🟡]** — **关联单向**。A 关联 B 只写 A 的 `related_ids`,B 不自动补 A。需双向同步(关联时补入对方,解绑时同步移除)。— `commands/idea.rs` + 前端 `IdeaDetail.vue` +- [ ] **IDEA-FIX-08 [P1🟡]** — **创建表单无 tags 输入**。后端 `CreateIdeaInput` 支持 tags,但前端捕捉表单(Ideas.vue capture modal)只有 title + description,tags 恒为 None。需加 tags 输入(逗号分隔 → JSON 数组)。— `Ideas.vue:108-119` + `confirmCapture()` +- [ ] **IDEA-FIX-09 [P2🟠]** — **创建表单缺 priority/source**。捕捉模态框无 priority 下拉和 source 输入。需补全。— `Ideas.vue:108-119` +- [ ] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。`hot`(score≥80)和 `pending`(多状态 OR)在前端 filter,但后端已分页返回子集,可能漏掉符合条件的灵感。需下沉后端 IdeaQuery。— `Ideas.vue:221-226` +- [ ] **IDEA-FIX-11 [P2🟠]** — **假雷达图**。i18n key 和 CSS class 叫 "radar",但实际是水平进度条。需正名为 "score-bar" 或引入真正的雷达图组件。— `IdeaDetail.vue` + `i18n/zh-CN/ideas.ts` + `i18n/en/ideas.ts` diff --git a/src-tauri/src/commands/ai/agentic/conv_state.rs b/src-tauri/src/commands/ai/agentic/conv_state.rs index 9f63136..c69ab94 100644 --- a/src-tauri/src/commands/ai/agentic/conv_state.rs +++ b/src-tauri/src/commands/ai/agentic/conv_state.rs @@ -23,6 +23,15 @@ //! //! 散落判别点(`try_continue` / `ai_chat_stop` / MaxRoundsCard)已逐个迁到读 //! `ConvState`(`is_active()` / `can_accept_request()`),不再回退旧 bool。 +//! +//! # 与目标钉扎(G1)的衔接(2026-06-26) +//! +//! `ConvState` 管**生成生命周期**(Idle/Generating/Stopping/Error/Compressed 5 态 7 边); +//! 目标 / 进度等**内容态**挂 [`PerConvState`](../mod.rs) 兄弟字段(如 `pinned_goal`), +//! 两者**正交**。**不要把目标塞进 `ConvState` 变体** —— 否则 5 态会膨胀成 +//! `GeneratingWithGoal` / `IdleWithGoal` 爆炸组合,违反「轻量状态机不引入框架」原则。 +//! 目标钉扎字段(G1)与本 enum 互不感知:G1 改 `PerConvState.pinned_goal`, +//! 本文件 enum/impl/transition_to 守卫/guard.rs 零改动。 use serde::{Deserialize, Serialize}; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index 9775e30..d5b9dba 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -107,6 +107,90 @@ pub const TOOL_RESULT_COMPRESS_ENABLED: bool = true; /// 保守:双高置信才标(任一 topic None 不标),不强制 LLM(软提示非硬约束)。 pub const TOPIC_MARKER_ENABLED: bool = true; +// ============================================================ +// G1 目标钉扎(治 R1 目标消息被压缩出局 + R3 重锚定 + R5 prompt 无锚点,2026-06-26) +// +// 机制:首条 active user 消息提取目标存 PerConvState.pinned_goal(内容态字段,绕过 +// sanitize step0 is_active 过滤),run_agentic_loop 入口拼进 system_prompt 尾部。 +// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪, +// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System +// 1214/首位锚点稀释/小预算被裁三重风险。 +// ============================================================ + +/// G1 总开关:目标钉扎是否启用(默认 true)。 +/// +/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。 +/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化, +/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退, +/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。 +pub const GOAL_PIN_ENABLED: bool = true; + +/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。 +/// +/// true(默认):目标文本前置 `## 当前目标(全程锚定,所有动作须服务于它)` 标题 + 目标内容, +/// 显式分隔防与行为准则混淆,提升 LLM 注意力分配。false(裸拼):目标文本直接 append 到 +/// system_prompt,无标题分隔(紧凑,排障/对比用)。 +pub const GOAL_INJECT_BANNER: bool = true; + +/// G1 截断长度:目标文本截断上限(默认 500 字符)。 +/// +/// 防 R1 反向风险:长 user 消息(粘贴需求文档/长 bug 描述)每轮占 system_prompt 预算。 +/// system_prompt 虽不被裁剪但仍计 sys_tokens 占预算,故截断防长目标撑爆。500 保守(首版, +/// 可调),足够覆盖正常一句话目标。截断后追加「…」省略号标识。 +pub const GOAL_MAX_CHARS: usize = 500; + +/// G4 目标感知降级:话题标记 insert 当 pinned_goal 存在时跳过(默认 true)。 +/// +/// 治 R2(话题标记反向误导):G1 目标钉扎生效后每轮 system_prompt 已含目标,topic marker 的 +/// 「请以新话题为准」软提示成冗余且与目标矛盾(诊断 §三双锚点稀释)。true(默认)= 当 +/// pinned_goal 存在时 take_topic_marker 后丢弃跳过 insert(防 marker 累积 + 消双锚点稀释); +/// false(回退)= 无视 pinned_goal 照常 insert,完全退回原双锚点行为(排障/对比用)。 +/// +/// 与 TOPIC_MARKER_ENABLED 独立:TOPIC_MARKER_ENABLED 是整段总开关(false=整段跳过), +/// TOPIC_MARKER_GOAL_AWARE 是 goal 感知前置 guard(仅 GOAL_AWARE=true 且 pinned_goal +/// 存在时跳过 insert)。双关默认 true,各自单点回退不耦合(G1 关闭时 GOAL_AWARE 仍生效, +/// 但 pinned_goal 永远 None → 不跳过 → 退原 topic 行为,双层兜底)。 +pub const TOPIC_MARKER_GOAL_AWARE: bool = true; + +// ============================================================ +// G2 探索熔断(治 R4 连续空结果无进展死循环 + token 失控,2026-06-26) +// +// 机制(非 prompt 说教):连续 N 轮工具结果全空(空成功,如 search_files/grep 无命中) → +// 两段式处理(STALL_BREAKER_WARN_FIRST):先警示注入下轮 prompt 给 LLM 自纠机会, +// 再熔断(guard.reset + AiHelpRequired)逼用户换思路/人工介入。 +// 与 L1 断路器(CIRCUIT_BREAKER_*)互补:L1 治反复失败(禁止/失败/Error 关键词), +// G2 治反复空成功(total:0/无匹配),两者独立计数互不干扰。 +// 直接治实测 seq287-307 连续 10+ 次 search_files 空结果死循环(阈值 3 提前 7 次熔断,省巨量 token)。 +// ============================================================ + +/// G2 总开关:探索熔断是否启用(默认 true)。 +/// +/// true(默认):process_tool_calls 后取末尾连续 Tool 消息判空结果,全空累计 stall_count, +/// 达 STALL_BREAKER_THRESHOLD → 警示/熔断。false(回退):整段跳过,stall_count 永远 0, +/// 退 max_iterations 旧行为(可能继续游荡但行为不变,单点回退)。与 CIRCUIT_BREAKER_ENABLED 独立。 +pub const STALL_BREAKER_ENABLED: bool = true; + +/// G2 连续空结果阈值(默认 3)。 +/// +/// 评审一致建议 3 而非 5(对齐 CIRCUIT_BREAKER_THRESHOLD=3):空成功比硬失败更隐蔽, +/// 阈值应更激进。治实测连续 10+ 次空结果(提前 7 次熔断)。is_empty_tool_result 关键词 +/// 漏判风险由 threshold=3 + 两段式 WARN_FIRST 容错:多轮漏判才误熔断,最坏退 max_iterations 兜底。 +pub const STALL_BREAKER_THRESHOLD: u32 = 3; + +/// G2 两段式开关:熔断前是否先警示一轮(默认 true)。 +/// +/// true(默认):stall_count == THRESHOLD-1 时注入「⚠ 已连续 N 次空结果,可能偏离目标, +/// 请回顾目标换思路或停止」到下轮 system_prompt(给 LLM 自纠机会);stall_count >= THRESHOLD 才熔断。 +/// 降误杀:用户正当探索(如确认无相关文件)先警示再熔断。false(激进):直接熔断不警示, +/// 确认无误杀场景用。 +pub const STALL_BREAKER_WARN_FIRST: bool = true; + +/// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。 +/// +/// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准; +/// false:警示泛化(不引 goal 文本)。G2 不强依赖 G1(熔断不读 goal 也能工作,仅警示泛化)。 +pub const STALL_BREAKER_GOAL_REMIND: bool = true; + /// L1 断路器:连续同类工具失败熔断阈值(治 kms 会话 53 轮 0 产出死循环)。 /// /// 背景:agent 无止损,某工具反复同类失败(权限拒绝/路径错误等)仍每轮重试, @@ -675,9 +759,61 @@ pub(crate) async fn run_agentic_loop( // loop 生命周期内累加,每轮 process_tool_calls 后检查。达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 let mut fail_counts: std::collections::HashMap = std::collections::HashMap::new(); + // G2 探索熔断:连续空结果无进展计数器(loop 生命周期累计,与 fail_counts 同生命周期)。 + // 每轮 process_tool_calls 后取末尾连续 Tool 消息判 is_empty_tool_result,全空 stall_count+=1, + // 任一非空重置 0。达 STALL_BREAKER_THRESHOLD → 警示/熔断(治 R4 游荡死循环 + token 失控)。 + let mut stall_count: u32 = 0; + let mut stall_warned: bool = false; + // BUG-260617-12: DeepSeek thinking 模式推理内容跨轮透传 let mut last_reasoning_content: Option = None; + // G1 目标钉扎:入口把 PerConvState.pinned_goal 拼进 system_prompt 尾部(一次拼好整个 loop 复用)。 + // + // 治 R1(目标消息被压缩出局)/R5(prompt 说教无锚点):system_prompt 是 loop 不变量 + build_for_request + // 从不裁剪它,故目标天然免疫压缩/裁剪/sanitize。本块是治 R1 的结构性根因(目标进 prompt 字符串非 + // messages 流,无 insert_at(0) 的连续 System 1214/首位锚点稀释/小预算被裁三重风险)。 + // + // 单次 lock 读 pinned_goal clone(复用 L693-697 stop_flag 取用模式,同一 lock 块);Some 且非空 → + // 截断到 GOAL_MAX_CHARS,按 GOAL_INJECT_BANNER 拼 banner+目标。GOAL_PIN_ENABLED=false → 整块跳过, + // pinned_goal 永远 None(单点回退等价改动前)。拼接在 sys_tokens 估算前(sys_tokens 估算拼接后的 prompt)。 + // + // 注:仅 run_agentic_loop 入口注入。手动压缩(ai_chat_compress_context IPC)/标题/提炼等路径不注入 + // 目标(对齐 openQuestions 决策:首版仅 agentic loop 入口拼,其他路径不动)。 + let mut system_prompt = system_prompt; + if GOAL_PIN_ENABLED { + let goal_opt: Option = { + let session = session_arc.lock().await; + session + .conv_read(&conv_id) + .and_then(|c| c.pinned_goal.clone()) + }; + if let Some(goal) = goal_opt { + let goal_text = goal.trim(); + if !goal_text.is_empty() { + let goal_text: String = goal_text.chars().take(GOAL_MAX_CHARS).collect(); + let goal_text = if goal_text.chars().count() >= GOAL_MAX_CHARS { + format!("{}…", goal_text) + } else { + goal_text + }; + system_prompt = if GOAL_INJECT_BANNER { + format!( + "{}\n\n## 当前目标(全程锚定,所有动作须服务于它)\n{}", + system_prompt, goal_text + ) + } else { + format!("{}\n\n{}", system_prompt, goal_text) + }; + tracing::info!( + conv_id = %conv_id, + chars = goal_text.chars().count(), + "[ai] G1 目标钉扎:已把 pinned_goal 拼进 system_prompt" + ); + } + } + } + // F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变), // 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。 let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt); @@ -743,7 +879,27 @@ pub(crate) async fn run_agentic_loop( return; } let conv = session.conv(&conv_id); - conv.messages.take_topic_marker() + // G4 目标感知降级:总是 take_topic_marker(防 marker 累积),但若 pinned_goal 存在 + // 且 TOPIC_MARKER_GOAL_AWARE → 丢弃 take 结果(返 None 跳过 insert)。 + // 一次 lock 同读 pinned_goal(避免额外加锁)。take 后丢弃不影响下一轮(marker 每 push + // user 重检测生成,丢弃一次不残留)。GOAL_AWARE=false → 原样返回 marker(退旧行为)。 + let goal_active = TOPIC_MARKER_GOAL_AWARE + && conv + .pinned_goal + .as_deref() + .map(|g| !g.trim().is_empty()) + .unwrap_or(false); + let marker = conv.messages.take_topic_marker(); + if goal_active && marker.is_some() { + tracing::info!( + conv_id = %conv_id, + iteration, + "[ai] G4 目标钉扎已启用,跳过话题标记 insert 避免双锚点稀释" + ); + None + } else { + marker + } }; if let Some((old_topic, new_topic)) = topic_marker_raw.and_then(|s| { // 解析 "old|new" 格式;splitn 防 topic 名内含 '|' 误切(仅切首 '|' 一次)。 @@ -1457,6 +1613,116 @@ pub(crate) async fn run_agentic_loop( } } + // 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(失败关键词)重叠。 + 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 g = session_arc + .lock() + .await + .conv_read(&conv_id) + .and_then(|c| c.pinned_goal.clone()) + .unwrap_or_default(); + if g.trim().is_empty() { + String::new() + } else { + format!("(当前目标: {})", g.trim()) + } + } 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; + } + } + } + // 有待审批 → 暂停循环,等待用户审批后通过 ai_approve → try_continue_agent_loop 恢复 if pending_count > 0 { let usage = df_ai::provider::TokenUsage { @@ -1550,6 +1816,60 @@ pub(crate) async fn run_agentic_loop( }); } +/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。 +/// +/// 关键词表**对齐真实 tool_result 格式**(audit.rs tool 执行经 `serde_json::Value::to_string()` +/// 序列化为紧凑 JSON,无空格): +/// - search_files / grep 空 → `{"total":0,...}` → 命中 `"total":0`(紧凑,冒号后无空格)。 +/// - list_directory 空 → `{"entries":[],...}` → 命中 `"entries":[]`。 +/// - read_symbol 未找到 → 含「未找到」回退提示。 +/// - 通用空 → `"matches":[]` / `"results":[]` / `No matches` / `无匹配` / `未找到` / `没有找到`。 +/// +/// 与 L1 is_failure(禁止/失败/Error)**不重叠**:空结果「total:0」不含失败关键词,L1 不命中。 +/// 漏判风险由 STALL_BREAKER_THRESHOLD=3 + 两段式 WARN_FIRST 容错(多轮漏判才误熔断,最坏退 max_iterations)。 +pub(crate) fn is_empty_tool_result(content: &str) -> bool { + let trimmed = content.trim(); + if trimmed.is_empty() { + return true; + } + // 紧凑 JSON 空结果标记(对齐 serde_json::Value::to_string() 输出,冒号后无空格)。 + // search_files/grep:"total":0 ;list_directory:"entries":[];通用空数组 matches/results/files。 + const EMPTY_MARKERS: &[&str] = &[ + "\"total\":0", + "\"entries\":[]", + "\"matches\":[]", + "\"results\":[]", + "\"files\":[]", + ]; + for marker in EMPTY_MARKERS { + if trimmed.contains(marker) { + return true; + } + } + // 中文 / 英文「未找到」类提示(read_symbol 等回退文本)。 + const EMPTY_TEXT: &[&str] = &[ + "未找到", + "没有找到", + "无匹配", + "没有匹配", + "未匹配", + "未发现", + "无记录", + "No matches", + "no matches", + "0 results", + "0 matches", + "没有数据", + "没有符合", + ]; + for marker in EMPTY_TEXT { + if trimmed.contains(marker) { + return true; + } + } + false +} + /// 检查是否所有待审批已处理,如果是则恢复 agentic 循环 /// /// B-260615-08:所有静默 return 点显式 emit 收尾事件,避免前端 streaming=true 永久卡。 diff --git a/src-tauri/src/commands/ai/commands/chat.rs b/src-tauri/src/commands/ai/commands/chat.rs index 2396991..a56a2d0 100644 --- a/src-tauri/src/commands/ai/commands/chat.rs +++ b/src-tauri/src/commands/ai/commands/chat.rs @@ -24,7 +24,7 @@ use crate::state::AppState; use crate::commands::{err_str, now_millis}; // chat.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。 -use super::super::agentic::{run_agentic_loop, try_continue_agent_loop}; +use super::super::agentic::{run_agentic_loop, try_continue_agent_loop, GOAL_MAX_CHARS, GOAL_PIN_ENABLED}; // 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。 use super::super::agentic::conv_state::ConvState; use super::super::audit::{audit_finalize, emit_data_changed}; @@ -72,6 +72,78 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio } } +/// G1 目标钉扎:从 user 消息文本提取目标(纯函数,无 IO / 无额外 LLM 请求)。 +/// +/// 治 R1(目标消息被压缩出局)的提取侧:首条 active user 消息即原始目标(push 时还 active), +/// 提取后存 `PerConvState.pinned_goal` 绕过消息 active 状态过滤,即使原消息出局 goal 仍在。 +/// +/// 处理(零额外请求,对齐 GLM 限额口径 1 请求=1 次): +/// 1. strip 所有 `[kind:...]` mention 段(augmentation 已投影,目标文本去噪)。对齐 intent.rs +/// KINDS(项目/任务/想法/技能 + project/task/idea/skill),全文扫描移除(实测诊断 seq5 目标 +/// `分析灵感模块不足 [项目: DevFlow]` mention 在**末尾**,非仅行首),冒号兼容全角:/半角:。 +/// 2. trim 空白。 +/// 3. 截断到 GOAL_MAX_CHARS(防长需求文档撑爆 system_prompt 占预算)。 +/// +/// 返回空串(纯 mention / 空消息)→ 调用方判非空才写入 pinned_goal(避免空目标注入)。 +/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。 +pub(crate) fn extract_pinned_goal(text: &str) -> String { + // strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑, + // 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。 + const KINDS: &[&str] = &["项目", "任务", "想法", "技能", "project", "task", "idea", "skill"]; + let chars: Vec = text.chars().collect(); + let n = chars.len(); + let mut out = String::with_capacity(text.len()); + let mut i = 0; + while i < n { + if chars[i] == '[' { + // 探测 [kind:...],命中则跳过整段(含 ])。 + if let Some(close) = find_mention_close(&chars, i, KINDS) { + i = close + 1; + continue; + } + } + out.push(chars[i]); + i += 1; + } + let cleaned = out.trim(); + // 截断到 GOAL_MAX_CHARS(字符级,非字节级,中文友好) + cleaned.chars().take(GOAL_MAX_CHARS).collect() +} + +/// 探测 `chars[open]=='['` 起的 mention 段 `[kind:...]`,返回匹配 `]` 的索引。 +/// +/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白; +/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。 +fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option { + let after = open + 1; + for k in kinds { + let kc: Vec = k.chars().collect(); + if chars.len() < after + kc.len() { + continue; + } + let slice = &chars[after..after + kc.len()]; + if !kc.iter().zip(slice.iter()).all(|(a, b)| a.eq_ignore_ascii_case(b)) { + continue; + } + let mut j = after + kc.len(); + while j < chars.len() && chars[j].is_whitespace() { + j += 1; + } + if j >= chars.len() || (chars[j] != ':' && chars[j] != ':') { + continue; + } + j += 1; + while j < chars.len() { + if chars[j] == ']' { + return Some(j); + } + j += 1; + } + return None; // 未闭合 ] → 不当 mention + } + None +} + /// 统一解析「/ 技能」+「@ mention 区间」为 augmentation 注入段。 /// /// 把 4 调用方(send / force_send / regenerate / edit)原本各拼一段的 skill 注入收敛至此, @@ -391,6 +463,14 @@ pub async fn ai_chat_send( // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 let user_msg_id = conv.messages.last_user_message_id(); + // G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,连续追问视为同目标)。 + // GOAL_PIN_ENABLED=false → 跳过(单点回退);extract_pinned_goal 返空(纯 mention 前缀)→ 不写入。 + if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { + let goal = extract_pinned_goal(&user_content); + if !goal.is_empty() { + conv.pinned_goal = Some(goal); + } + } (target, user_msg_id) }; @@ -1451,6 +1531,15 @@ pub async fn ai_chat_force_send( } // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 let user_msg_id = conv.messages.last_user_message_id(); + // G1 目标钉扎:push 后提取首条 user 目标存 pinned_goal(仅 None 时写,与 send 路径一致)。 + // force_send 是用户新指令,审批续跑会复用此目标(对齐 G1 changes:审批期间用户发新消息 → + // pinned_goal 已更新,续跑用新目标)。 + if GOAL_PIN_ENABLED && conv.pinned_goal.is_none() { + let goal = extract_pinned_goal(&user_content); + if !goal.is_empty() { + conv.pinned_goal = Some(goal); + } + } (was_gen.then_some(target.clone()), target, user_msg_id) }; diff --git a/src-tauri/src/commands/ai/mod.rs b/src-tauri/src/commands/ai/mod.rs index eb99284..362dd00 100644 --- a/src-tauri/src/commands/ai/mod.rs +++ b/src-tauri/src/commands/ai/mod.rs @@ -757,6 +757,18 @@ mod tests_f09_per_conv { /// 读写,AiSession 顶层单例字段已全部删除。 /// /// 字段初值逐字对齐 [`AiSession::new`](#method.new)。 +/// +/// # 字段正交性(G1 目标钉扎 / G3 状态机收敛衔接,2026-06-26) +/// +/// 本 struct 同时持有两类正交状态: +/// - **生命周期态**(`conv_state: ConvState`):loop 是否在跑(Idle/Generating/Stopping/...), +/// 归 `ConvState` enum 管(写收敛见 [`ConvState::transition_to`])。 +/// - **内容态**(`pinned_goal` 等):用户想达成什么 / 会话进度等内容信息,挂本 struct 兄弟字段。 +/// +/// 两者**正交共存**于同一 struct,不互相进变体:目标**绝不**塞进 `ConvState` enum(否则 +/// 5 态会膨胀成 `GeneratingWithGoalA`/`GeneratingWithGoalB` 爆炸组合,违反 conv-state-convergence +/// 分支「轻量状态机不引入框架」原则,见 agentic/conv_state.rs 文件头)。目标钉扎字段随会话 +/// 销毁不落库(对齐 `knowledge_extracted` 语义,PerConvState 无 serde derive)。 pub struct PerConvState { /// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图) pub messages: ContextManager, @@ -764,6 +776,17 @@ pub struct PerConvState { /// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。 /// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。 pub conv_state: ConvState, + /// G1 目标钉扎真相源:用户首条 active 消息提取的目标(内容态字段,与生命周期态正交)。 + /// + /// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失): + /// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 字段仍在。 + /// run_agentic_loop 入口把它拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然 + /// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。 + /// + /// 写:chat.rs send/force_send push 后提取(仅 None 时写首条);读:loop 入口拼接。 + /// GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远 None(单点回退等价改动前)。 + /// 随会话销毁不落库(对齐 knowledge_extracted L795 语义,PerConvState 无 serde derive)。 + pub pinned_goal: Option, /// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出 pub stop_flag: Arc, /// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select! @@ -810,10 +833,12 @@ impl PerConvState { /// - session_trust: HashSet::new() /// - created_at: None(批1 新增字段,AiSession 现有 active_conv_created_at 同语义) /// - knowledge_extracted: false(新会话未提炼,P1 去重标志) + /// - pinned_goal: None(G1 目标钉扎,新会话未提取目标) pub fn new() -> Self { Self { messages: ContextManager::new(ContextConfig::default()), conv_state: ConvState::Idle, + pinned_goal: None, stop_flag: Arc::new(AtomicBool::new(false)), notify: Arc::new(tokio::sync::Notify::new()), iteration_used: 0,