Compare commits
48 Commits
89912155a6
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e9e3578d26 | |||
| 42efb31bbf | |||
| 0dfaeb0f5b | |||
| aa0701a2ea | |||
| 89cfbc7499 | |||
| 3d8b755229 | |||
| 249b3b9ea8 | |||
| eee0f06e1a | |||
| 46246880b3 | |||
| b18740405c | |||
| d99a65f034 | |||
| 8fa3a93eec | |||
| 3f22fd3673 | |||
| 34944df764 | |||
| c3b47b5771 | |||
| acf0ed091d | |||
| 54ffc868b2 | |||
| d5b0459a8a | |||
| f1fb8655c3 | |||
| d10d7ff330 | |||
| 61d3e9c148 | |||
| 840949ad05 | |||
| 3e93a2cd70 | |||
| 6e44878021 | |||
| 27b4268acb | |||
| f40287bb00 | |||
| 1d580dccc3 | |||
| 9947e6c1fe | |||
| 3c9077b043 | |||
| 4483358f6b | |||
| ae6d3d0043 | |||
| f4fc0feff5 | |||
| 6833792de2 | |||
| af2e085bea | |||
| 583058acd6 | |||
| f04d47b09d | |||
| fa410e6843 | |||
| a9ee9b1f74 | |||
| 0a65ea2c5a | |||
| 91d80841ea | |||
| c89742cb9a | |||
| 2af24f0db8 | |||
| 535525c2f6 | |||
| 3abdfb489f | |||
| 2c2b2d710e | |||
| 6771d396f0 | |||
| 0338210ba2 | |||
| 36ea090d9b |
@@ -1,5 +1,12 @@
|
||||
# musl linux 交叉编译(Windows 主机无 cc/musl-gcc,用 rust-lld self-contained link)
|
||||
# df-relay 云端部署:本地交叉产 static linux binary → scp 测试机普通运行
|
||||
#
|
||||
# ⚠️ 默认不要跑交叉编译,产物体积大且会污染 target/ 影响日常 build/rust-analyzer 性能。
|
||||
# 需要部署 df-relay 时再显式执行,并把产物隔离到独立 target dir:
|
||||
#
|
||||
# CARGO_TARGET_DIR=target-musl cargo build --release --target x86_64-unknown-linux-musl -p df-relay
|
||||
#
|
||||
# 这样 musl 产物落到 target-musl/(已 gitignore),与主 target/ 完全隔离。
|
||||
[target.x86_64-unknown-linux-musl]
|
||||
linker = "rust-lld"
|
||||
rustflags = ["-C", "link-self-contained=y"]
|
||||
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@@ -25,6 +25,7 @@ unpackage/
|
||||
|
||||
# Rust / Cargo
|
||||
target/
|
||||
target-musl/ # df-relay 交叉编译产物隔离目录(见 .cargo/config.toml)
|
||||
!Cargo.lock
|
||||
|
||||
# Tauri
|
||||
@@ -59,6 +60,28 @@ tmp/
|
||||
# AI 编排脚本(Claude Code Workflow 临时产物,非产品代码)
|
||||
workflows/
|
||||
|
||||
# 临时分析脚本(团队分析/报告生成,非产品代码根目录级)
|
||||
/analysis_*.txt
|
||||
/analysis_*.py
|
||||
/core_domains.txt
|
||||
/core_domains.py
|
||||
/final_report.txt
|
||||
/grand_output.txt
|
||||
/im_live_verify.txt
|
||||
/liang_deep.py
|
||||
/liangxianyou_deep.txt
|
||||
/member_profiles.py
|
||||
/read_all.py
|
||||
/report_p2.txt
|
||||
/report_wall.txt
|
||||
/team_analysis.py
|
||||
/team_deep_analysis.py
|
||||
/team_member_profiles.txt
|
||||
/team_report_final.txt
|
||||
/tmp_*.py
|
||||
/tmp_*.txt
|
||||
/verify_im_live*.py
|
||||
|
||||
# MCP/SQLite 测试库(mcp_test2 --db 残留)
|
||||
devflow-dev.db
|
||||
devflow-dev.db-shm
|
||||
|
||||
64
.zed/settings.json
Normal file
64
.zed/settings.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
// 项目级 Zed 配置:优化 rust-analyzer 性能
|
||||
//
|
||||
// 背景:本 workspace 含 src-tauri + 7 个 df-* crate,
|
||||
// flycheck 单次产出 600+ 行 artifact JSON,Zed 解析+渲染会冻结 UI。
|
||||
// 核心策略:关掉保存即 check、限定 check 范围、隔离 target 目录。
|
||||
|
||||
"lsp": {
|
||||
"rust-analyzer": {
|
||||
"initialization_options": {
|
||||
"cargo": {
|
||||
"features": [], // 不编 default features 之外的重头
|
||||
"allTargets": false, // 只为 host target 解析,跳过 musl/交叉
|
||||
"targetDir": null, // 留空用 cargo 默认,避免双写
|
||||
"buildScripts": {
|
||||
"enable": true,
|
||||
"rerun": "on-save"
|
||||
}
|
||||
},
|
||||
"check": {
|
||||
"onSave": false, // ★ 关键!关闭保存即 check(Zed 卡死主因)
|
||||
"workspace": false, // 即便手动 check 也只查当前 crate
|
||||
"command": "check", // 用 cargo check(非 clippy),首次开销低
|
||||
"features": [],
|
||||
"allTargets": false
|
||||
},
|
||||
"diagnostics": {
|
||||
"enable": true, // 保留 rust-analyzer 自身诊断
|
||||
"experimental": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"procMacro": {
|
||||
"enable": true, // 必须开,否则 tauri::generate_context 等会报红
|
||||
"attributes": {
|
||||
"enable": true
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"symbol": {
|
||||
"search": {
|
||||
"kind": "only_types",
|
||||
"limit": 64
|
||||
}
|
||||
}
|
||||
},
|
||||
"completion": {
|
||||
"callable": {
|
||||
"snippets": "fill_arguments"
|
||||
},
|
||||
"fullFunction": {
|
||||
"enable": false
|
||||
}
|
||||
},
|
||||
"interpret": {
|
||||
"tests": false
|
||||
},
|
||||
"files": {
|
||||
"excludeDirs": [".cargo", "target", "target-musl", "node_modules", "dist", ".zed"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -320,8 +320,9 @@ pub struct Decision {
|
||||
|
||||
- **LlmProvider trait**:统一接口,各模型实现(`provider.rs`)✅
|
||||
- **ModelRouter**:按任务类型路由到最优模型 + 降级链 — ❌ 已删(R-PD-7),模型选择改由调用方在 `LlmProvider` 实现间直接指定
|
||||
- **AgentCoordinator**:多 Agent 协作(Planner/Coder/Reviewer/Fixer)— ⚠️ 骨架空壳(`coordinator.rs`,仅单元结构体 + 日志,未实现实际协调)
|
||||
- **ContextManager**:Token 预算管理(`context.rs`,完整实现)✅
|
||||
- **AgentCoordinator**:多 Agent 协作(Plan DAG 拆解 + JoinSet 层内并行 + Token 预算池 + Reviewer 仲裁) — ✅ 已实现
|
||||
- **Git Worktree 隔离**:子 Agent 文件隔离(git worktree + 独立分支) — ✅ 已实现
|
||||
- **ContextManager**:Token 预算管理(`context/mod.rs`,拆分为 sanitize/manager_tests 子模块)✅
|
||||
- **ToolRegistry**:工具注册(`ai_tools.rs`,供 Agent 调用)✅
|
||||
|
||||
### 5.5 内置节点 (df-nodes)
|
||||
@@ -331,13 +332,13 @@ pub struct Decision {
|
||||
| AINode | 调用 LLM,支持流式输出、工具调用 | ✅ 已实现 |
|
||||
| ScriptNode | Shell/脚本执行 | ✅ 已实现 |
|
||||
| HumanNode | 人工审批/确认 (阻塞) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器操作 | ❌ 未实现 |
|
||||
| GitNode | Git 操作 (libgit2) | ❌ 未实现 |
|
||||
| NotifyNode | 通知 (桌面/飞书/Webhook) | ❌ 未实现 |
|
||||
| HTTPNode | HTTP 请求 | ❌ 未实现 |
|
||||
| SubflowNode | 嵌套子工作流 | ❌ 未实现 |
|
||||
| GitNode | Git 操作(branch/checkout/commit/merge/push/status/log) | ✅ 已实现 |
|
||||
| HTTPNode | HTTP 请求(GET/POST/PUT/DELETE) | ✅ 已实现 |
|
||||
| NotifyNode | 通知(桌面/Webhook) | ✅ 已实现 |
|
||||
| SubflowNode | 嵌套子工作流(深度限制防递归) | ✅ 已实现 |
|
||||
| DockerNode | Docker 容器内构建/测试(环境检测+授权) | ✅ 已实现 |
|
||||
|
||||
> `crates/df-nodes/src/` 实际仅 ai_node / script_node / human_node 3 文件(Docker/Git/Notify/HTTP/Subflow 为设计预留,尚未实现)。详见 [df-nodes 模块文档](docs/03-模块文档/df-nodes-节点集合-2026-06-12.md)。
|
||||
> `crates/df-nodes/src/` 含 ai_node / script_node / human_node / git_node / http_node / notify_node / subflow_node / docker_node 8 文件(全部已实现)。
|
||||
|
||||
## 六、数据模型
|
||||
|
||||
|
||||
361
Batch.md
361
Batch.md
@@ -1,7 +1,7 @@
|
||||
# DevFlow 批次推进记录
|
||||
|
||||
> 记录每个批次的提交 hash、改动内容和交付价值。
|
||||
> 最后更新: 2026-06-29 | 最新提交: `c6df455`
|
||||
> 最后更新: 2026-07-02 | 最新提交: `4624688`
|
||||
|
||||
---
|
||||
|
||||
@@ -189,6 +189,365 @@
|
||||
|
||||
---
|
||||
|
||||
## Batch 14 — 文件浏览器收尾
|
||||
|
||||
- **提交**: `53e6442`
|
||||
- **内容**:
|
||||
- 提交之前未提交的滚动条与布局修复
|
||||
- 移除阶段进度条死代码(模板+CSS+常量)
|
||||
- 文件预览空状态加图标引导
|
||||
- 变更视图加载骨架屏
|
||||
- 工程选择器改为自定义下拉(显示工程路径)
|
||||
|
||||
---
|
||||
|
||||
## Batch 15 — 多工程管理
|
||||
|
||||
- **提交**: `d8d6226`
|
||||
- **内容**:
|
||||
- 工程编辑入口:工具栏编辑按钮+弹窗(名称/路径/Git 地址)
|
||||
- 工程删除二次确认:防误删
|
||||
- 记住上次选中工程:localStorage 持久化
|
||||
- 自动扫描子仓库:下拉菜单加扫描入口,发现 .git 子目录自动建工程
|
||||
|
||||
---
|
||||
|
||||
## Batch 16 — Git 提交历史作者
|
||||
|
||||
- **提交**: `b19b0f3`
|
||||
- **内容**:
|
||||
- 后端 git log format 加 %an 作者名(状态查询+分页查询)
|
||||
- 提交列表每行显示 hash/作者/主题/时间
|
||||
- 提交详情头部补充作者显示
|
||||
- 分支切换按钮占位(后续接入分支列表 IPC)
|
||||
|
||||
---
|
||||
|
||||
## 渲染崩溃修复
|
||||
|
||||
- **提交**: `bce2fea`
|
||||
- **内容**:
|
||||
- AdvancedSection clampApprovalTimeout 常量移到 reactive 之前(打包后 const 初始化顺序 TDZ)
|
||||
- @/i18n 去掉对 @/stores/appSettings 的 import,改直读 localStorage
|
||||
- Tauri capability 加 ai-detached-* / fe-detached-* 通配符(分离窗口 listen 权限)
|
||||
|
||||
---
|
||||
|
||||
## Batch 17 — Git 能力闭环
|
||||
|
||||
- **提交**: `15eaa0c`
|
||||
- **内容**:
|
||||
- Diff 行号解析:解析 @@ -a,b +c,d @@ 头,计算旧/新行号(原恒空字符串)
|
||||
- 提交详情增强:显示父提交哈希/作者/日期/完整消息
|
||||
- 分支只读展示:list_branches IPC + 变更面板顶部下拉查看分支
|
||||
- AI 工具写入后自动跳转变更视图:监听 df-data-changed entity=file 切 Tab + 刷新
|
||||
|
||||
---
|
||||
|
||||
## Batch 18 — 小程序审批超时计时器
|
||||
|
||||
- **提交**: `773ebb6`
|
||||
- **内容**:
|
||||
- 对齐桌面端 aiShared.startApprovalTimer,补齐小程序缺失的审批超时机制
|
||||
- AiApprovalRequired/AiDirAuthRequired 触发 startApprovalTimer(每条独立计时)
|
||||
- AiToolCallCompleted/AiApprovalResult 触发 clearApprovalTimer(审批落定)
|
||||
- 切会话/新建/断连/重连/终态触发 clearAllApprovalTimers(防跨会话污染)
|
||||
- 超时自动拒绝(approve(id,false))+ toast 提示用户
|
||||
|
||||
---
|
||||
|
||||
## Batch 19 — 任务管理 UX 重构 + 项目卡片工程数
|
||||
|
||||
- **提交**: `2780f6a` + `aa4d521`
|
||||
- **内容**:
|
||||
- 筛选改下拉:项目/状态从多按钮改为 select,节省垂直空间
|
||||
- 新增搜索框:标题/描述关键词搜索,300ms 防抖
|
||||
- 新增排序选择器:更新/创建时间/优先级/状态
|
||||
- 分组可折叠:点击组头切换,记忆状态到 localStorage
|
||||
- 分页默认开启:pageSize=20(原 0=全量)
|
||||
- 紧凑布局:任务行密度优化,显示总条数
|
||||
- 空状态引导:图标 + 新建按钮
|
||||
- 模态框样式收敛:去内联 style
|
||||
- 桌面快捷键:Ctrl+N 新建,Ctrl+F 聚焦搜索
|
||||
- 项目卡片显示工程数(异步拉取 module 列表)
|
||||
|
||||
---
|
||||
|
||||
## Batch 20 — coordinator.rs 空壳删除
|
||||
|
||||
- **提交**: `948d6e6`
|
||||
- **内容**:
|
||||
- 删除 crates/df-ai/src/coordinator.rs(零调用的 deprecated 空壳)
|
||||
- lib.rs 移除 pub mod coordinator 声明
|
||||
- 消除编译 deprecated 警告
|
||||
|
||||
---
|
||||
|
||||
## 任务行快捷操作菜单
|
||||
|
||||
- **提交**: `27eb0a9`
|
||||
- **内容**:
|
||||
- 任务行 hover 显示齿轮按钮,点击展开快捷菜单
|
||||
- 快捷改状态(6 态子菜单)/改优先级(4 级)/删除(二次确认)
|
||||
- 点击外部自动关闭菜单
|
||||
|
||||
---
|
||||
|
||||
## stores/ai TDZ 防御
|
||||
|
||||
- **提交**: `76aec62`
|
||||
- **内容**:
|
||||
- __bindMessages 包装 try/catch,aiShared 未就绪时延迟到微任务重试
|
||||
|
||||
---
|
||||
|
||||
## Plan DAG 分层执行器
|
||||
|
||||
- **提交**: `4dac9fe`
|
||||
- **内容**:
|
||||
- PlanExecutor 按 Plan::to_layers 层间串行/层内并行调度子任务
|
||||
- feature flag 门控(默认关,未接入主 loop)
|
||||
- 3 个单元测试覆盖:线性链/并行层/失败容错
|
||||
|
||||
---
|
||||
|
||||
## 变更区完善
|
||||
|
||||
- **提交**: `78ef5dc`
|
||||
- **内容**:
|
||||
- 变更文件选中后展开 Diff 红绿预览
|
||||
- 分支切换按钮实现只读列表弹窗(拉取分支列表 + 当前分支标记)
|
||||
|
||||
---
|
||||
|
||||
## 任务列表后端真分页
|
||||
|
||||
- **提交**: `3c2fa91`
|
||||
- **内容**:
|
||||
- 后端新增 count_by_query 方法(复用 list_by_query 的 WHERE 逻辑)
|
||||
- 前端 buildTaskQuery 传 limit/offset 到后端(SQL 下推分页)
|
||||
- 去除前端客户端 slice(后端已分页,直接分组)
|
||||
|
||||
---
|
||||
|
||||
## Batch 25 — 工程依赖图数据层
|
||||
|
||||
- **提交**: `3758bea`
|
||||
- **内容**:
|
||||
- 后端 module_dependencies 表(V35 迁移)+ ModuleDependencyRepo CRUD
|
||||
- IPC:add/remove/list_module_dependencies
|
||||
- 前端 API 封装 + DependencyGraph 接入真实边数据
|
||||
- 依赖类型颜色区分(library/api/mq/shared/custom)
|
||||
- 小地图插件(MiniMap)大图概览导航
|
||||
- 点击节点跳转项目详情
|
||||
|
||||
---
|
||||
|
||||
## Batch 26 — 依赖图增强
|
||||
|
||||
- **提交**: `dd0e059`
|
||||
- **内容**:
|
||||
- 添加依赖入口:工具栏"+ 依赖"按钮,弹窗选择源/目标工程 + 依赖类型
|
||||
- dagre 层次布局替换网格布局(LR 方向,自动排布工程节点)
|
||||
- 依赖边按类型着色
|
||||
|
||||
---
|
||||
|
||||
## Batch 28 — 架构债收尾
|
||||
|
||||
- **提交**: `8991215`
|
||||
- **内容**:
|
||||
- ScriptNode 白/黑名单注释清理(标注实际读取方式)
|
||||
- 双监听器核查结论:df-data-changed 多监听器是正常 pub-sub,非 bug
|
||||
|
||||
---
|
||||
|
||||
## Batch 29 — 环形检测 + PNG 导出 + 真实总数
|
||||
|
||||
- **提交**: `36ea090`
|
||||
- **内容**:
|
||||
- 后端 detect_module_cycles IPC(DFS 三色标记法检测环形依赖)
|
||||
- 前端环检测按钮:高亮参与环的节点(红色边框)
|
||||
- 图导出 PNG(X6 toPNG 回调模式)
|
||||
- 后端 count_tasks IPC + 前端 taskApi.count()
|
||||
- 任务列表所有筛选/搜索/排序/翻页均拉真实 total
|
||||
|
||||
---
|
||||
|
||||
## Batch 30 — 代码质量收尾
|
||||
|
||||
- **提交**: `6771d39`
|
||||
- **内容**:
|
||||
- 设置页搜索索引补全(新增 dataDir 项)
|
||||
- 编译警告清理(df-storage OptionalExtension + df-ai join_set)
|
||||
- i18n 核验(移除 zh-CN/fileExplorer.ts 未使用的 loadMore key)
|
||||
- String 替 newtype(5 个 branded ID 类型:ProjectId/TaskId/ConvId/ModuleId/MessageId)
|
||||
- **验证**: cargo check + vue-tsc + vite build 通过
|
||||
|
||||
## Batch 31 — God 文件拆分第一步(MessageList)
|
||||
|
||||
- **提交**: `2c2b2d7`
|
||||
- **内容**:
|
||||
- 提取 useMessageScroll.ts(滚动跟随/锁存/回底按钮,89 行)
|
||||
- 提取 MessageItem.vue(单条消息按 role 渲染,171 行)
|
||||
- MessageList.vue 从 1552 行降至 1386 行(-166 行)
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 32 — God 文件拆分第二步(ChatInput)
|
||||
|
||||
- **提交**: `3abdfb4`
|
||||
- **内容**:
|
||||
- 提取 SkillMention.vue(/@ 联想浮层,63 行)
|
||||
- 提取 ImageInput.vue(图片粘贴/拖拽预览,33 行)
|
||||
- ChatInput.vue 从 1183 行降至 1155 行
|
||||
- **验证**: vue-tsc + vite build 通过
|
||||
|
||||
## Batch 34 — 产品完善
|
||||
|
||||
- **提交**: `2af24f0`
|
||||
- **内容**:
|
||||
- 小程序 device 在线心跳加固(早前已完成)
|
||||
- ScriptNode 白名单跨 crate 接口打通:OnceLock 全局态 + IPC set_script_safety
|
||||
- AI 写入后文件树自动刷新(早前已完成)
|
||||
- Projects 列表分页(后端 list_projects 已支持 limit/offset)
|
||||
- **验证**: cargo check + vue-tsc 通过
|
||||
|
||||
## Batch 35 — 人设系统(P0·第一步)
|
||||
|
||||
- **提交**: `0a65ea2`
|
||||
- **内容**:
|
||||
- AgentPersona 数据结构(能力标签/工具白名单/prompt 模板)
|
||||
- PersonaRegistry 注册表(5 内置人设: coder/reviewer/architect/tester/analyst)
|
||||
- 按意图推荐人设(启发式关键词匹配合适角色)
|
||||
- build_prompt 注入上下文和目标钉扎
|
||||
- **验证**: 10 个单元测试通过
|
||||
|
||||
## Batch 36 — Coordinator 接入 + audit 拆分
|
||||
|
||||
- **提交**: `fa410e6`
|
||||
- **内容**:
|
||||
- audit/mod.rs 拆分出 approval/record 子模块(process_tool_calls 从 ~500→~200 行)
|
||||
- coordinator.rs 移除 deprecated 标记,正式可用
|
||||
- Coordinator 接入 run_agentic_loop 入口(plan_execution_enabled 时分解意图→输出 Plan)
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 37 — 代码卫生与质量提升(String→newtype + 文件清理 + 文档同步)
|
||||
|
||||
- **提交**: `4624688`
|
||||
- **内容**:
|
||||
- ExecutionId/ToolCallType/ToolType 裸 String → newtype(IPC 边界仍透明序列化为字符串)
|
||||
- ChatMessage.status 从 `Option<String>` → `Option<MessageStatus>` 枚举(Active/Truncated/Compressed/ArchivedSegment)
|
||||
- .gitignore 添加分析脚本,tmp 文件清理
|
||||
- Batch.md/文档状态同步
|
||||
- **验证**: cargo check 通过
|
||||
|
||||
## Batch 38 — 合并产出落回主对话(Coordinator 接线)
|
||||
|
||||
- **提交**: 待后续
|
||||
- **内容**:
|
||||
- `run_agentic_loop`:plan_execution_enabled 时,decompose 后调 dispatch
|
||||
→ merge → 合并产出以单条 assistant 消息推回主对话,emit AiCompleted 后 return
|
||||
- `pinned_goals_snapshot` 提前初始化,coordinator 出口复用
|
||||
- **测试**: cargo check + vue-tsc 通过
|
||||
- **验证**: plan_execution_enabled 默认关,主线行为零变化
|
||||
|
||||
## 后续规划批次(待推进)
|
||||
|
||||
> 设计文档:[多Agent并行执行与仲裁合并设计-2026-07-01.md](docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
### 数据层 + Git worktree 隔离 + 并行执行 ✅
|
||||
|
||||
- **提交**: `ae6d3d0` → `4483358` → `3c9077b`
|
||||
- **测试**: 48 个全绿(7 Repo + 7 worktree + 27 Coordinator + 4 Token + 3 merge)
|
||||
- **验证**: cargo check(0 警告) + vue-tsc + vite build
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | V36 迁移(ai_plans/ai_subtasks/ai_conflicts 3 新表 + ai_messages/ai_tool_executions 加 subtask_id + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD + models 结构体 | 新 repo 文件 |
|
||||
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||
|
||||
### 仲裁合并 + 冲突 UI + 编译检查 ✅
|
||||
|
||||
- **提交**: `1d580dc` → `6e44878`
|
||||
- **测试**: 7 个 merge 冲突检测测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Coordinator.merge 冲突检测(同文件路径 + 跳过失败 SubTask) | ✅ |
|
||||
| 2 | extract_written_files 辅助函数(从 output 提取写入路径) | ✅ |
|
||||
| 3 | ConflictResolver.vue(双栏 diff + 接受A/B/合并/手动按钮) | ✅ |
|
||||
| 4 | i18n 冲突翻译键(中英文) | ✅ |
|
||||
| 5 | agentic/mod.rs 拆分(提取自动压缩到 context_lifecycle.rs,2222→2071行) | ✅ |
|
||||
| 6 | Reviewer Agent 仲裁(persona.rs 扩展) | 待下一批 |
|
||||
| 7 | 命令互斥锁(run_command 同目录 mutex) | 待下一批 |
|
||||
| 8 | context.rs 拆分(1956行) | 待下一批 |
|
||||
|
||||
### 模板系统 + 节点补齐 ✅
|
||||
|
||||
- **提交**: `f40287b` → `27b4268`
|
||||
- **测试**: 8 个模板加载 + 37 个节点 + 5 个 SubflowNode 测试全绿
|
||||
- **已完成任务**:
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | YAML 模板格式定义 + 加载器(YAML→DagDef + 校验:空节点/未知类型/环/边引用) | ✅ |
|
||||
| 2 | GitNode(分支/checkout/commit/merge/push/status/log) | ✅ |
|
||||
| 3 | HTTPNode(GET/POST/PUT/DELETE + headers/body/timeout) | ✅ |
|
||||
| 4 | NotifyNode(桌面通知 + Webhook) | ✅ |
|
||||
| 5 | SubflowNode(嵌套子工作流 + 深度限制) | ✅ |
|
||||
| 6 | 内置模板 3 个预设(代码审查/Bug修复/功能开发) | ✅ |
|
||||
| 7 | 前端模板选择入口 + 模板 CRUD IPC | 待下一批 |
|
||||
| 8 | MessageList.vue 继续拆分 | 待下一批 |
|
||||
|
||||
### Git/CI 集成 + DockerNode + God 文件收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | DockerNode(环境检测+授权) | ✅ |
|
||||
| 2 | GitNode push(plan分支推送) | ✅ GitNode 含 push action |
|
||||
| 3 | commit status 读取(Gitea API) | ✅ ci_status.rs |
|
||||
| 4 | CIStatus.vue 前端面板 | ✅ |
|
||||
| 5 | ChatInput.vue 拆分(1155→1007行) | ✅ |
|
||||
| 6 | context.rs 拆分(1956→538行) | ✅ |
|
||||
|
||||
### 审批政策 + 小程序完善 + 体验收尾 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | 审批策略 Persona 维度 | ✅ |
|
||||
| 2 | 小程序 MentionInput 接真实数据 | ✅ 核验已完成 |
|
||||
| 3 | 小程序长会话虚拟化 | ✅ 截断200条合理兜底 |
|
||||
| 4 | 快捷键完善(Ctrl+N/Ctrl+R) | ✅ |
|
||||
| 5 | ARCHITECTURE.md 状态同步 | ✅ |
|
||||
|
||||
### 批次 C 前序散项 ✅
|
||||
|
||||
| # | 任务 | 状态 |
|
||||
|---|------|------|
|
||||
| 1 | Reviewer Agent 仲裁 | ✅ |
|
||||
| 2 | 命令互斥锁 | ✅ |
|
||||
| 3 | resolve_conflict IPC | ✅ |
|
||||
| 4 | 模板 CRUD IPC | ✅ |
|
||||
| 5 | MessageList.vue 拆分(1386→1148行) | ✅ |
|
||||
| 6 | AiChat 进度条(completedTools 计数器) | ✅ |
|
||||
| 7 | df-types augmentation 测试修复 | ✅ |
|
||||
| 8 | 前端 UI 审查修复(badge/confirm/加载态/CSS) | ✅ |
|
||||
| 9 | 合并产出落回主对话 | 待接线 |
|
||||
|
||||
---
|
||||
|
||||
## 未纳入批次的其他会话改动
|
||||
|
||||
- `src/composables/ai/aiShared.ts` + `src/stores/ai.ts` — 循环依赖修复(Batch 6 补丁 `cc3ef5b` 纳入提交)
|
||||
|
||||
202
Cargo.lock
generated
202
Cargo.lock
generated
@@ -779,6 +779,20 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashmap"
|
||||
version = "6.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crossbeam-utils",
|
||||
"hashbrown 0.14.5",
|
||||
"lock_api",
|
||||
"once_cell",
|
||||
"parking_lot_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -835,6 +849,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dashmap",
|
||||
"df-ai",
|
||||
"df-execute",
|
||||
"df-ideas",
|
||||
@@ -877,14 +892,15 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-trait",
|
||||
"bytes",
|
||||
"df-ai-core",
|
||||
"df-types",
|
||||
"eventsource-stream",
|
||||
"futures",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -954,6 +970,7 @@ dependencies = [
|
||||
"df-storage",
|
||||
"df-types",
|
||||
"df-workflow",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
@@ -1041,6 +1058,7 @@ dependencies = [
|
||||
"futures",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"tokio",
|
||||
"tracing",
|
||||
]
|
||||
@@ -1292,17 +1310,6 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "eventsource-stream"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "74fef4569247a5f429d9156b9d0a2599914385dd189c539334c625d8099d90ab"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"nom",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.3.0"
|
||||
@@ -1642,8 +1649,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1653,9 +1662,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1991,6 +2002,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2492,6 +2504,12 @@ version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.38.0"
|
||||
@@ -2539,12 +2557,6 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -2647,16 +2659,6 @@ dependencies = [
|
||||
"memoffset",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "7.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
@@ -3346,6 +3348,61 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"lru-slab",
|
||||
"rand 0.9.4",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3374,8 +3431,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3385,7 +3452,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3397,6 +3474,15 @@ dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -3497,6 +3583,8 @@ dependencies = [
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -3504,6 +3592,7 @@ dependencies = [
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -3513,6 +3602,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams 0.4.2",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3636,6 +3726,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
@@ -3648,6 +3739,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -3759,7 +3851,7 @@ dependencies = [
|
||||
"generic-array",
|
||||
"num",
|
||||
"once_cell",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"zbus 4.4.0",
|
||||
]
|
||||
@@ -3980,6 +4072,19 @@ dependencies = [
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
"unsafe-libyaml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serialize-to-javascript"
|
||||
version = "0.1.2"
|
||||
@@ -5233,7 +5338,7 @@ dependencies = [
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -5251,7 +5356,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"sha1",
|
||||
"thiserror 1.0.69",
|
||||
"utf-8",
|
||||
@@ -5339,6 +5444,12 @@ version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
|
||||
|
||||
[[package]]
|
||||
name = "unsafe-libyaml"
|
||||
version = "0.2.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||
|
||||
[[package]]
|
||||
name = "untrusted"
|
||||
version = "0.9.0"
|
||||
@@ -5606,6 +5717,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.4"
|
||||
@@ -5662,6 +5783,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@@ -6399,7 +6529,7 @@ dependencies = [
|
||||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
|
||||
@@ -22,3 +22,7 @@ regex = "1"
|
||||
# Win+mac 启用原生后端;linux 用 async-persistent 变体避同步阻塞 Secret Service。
|
||||
# 由 df-storage(密钥解析下沉层) + src-tauri(build_provider_for 转发壳)共同引用。
|
||||
keyring = { version = "3", features = ["windows-native", "apple-native", "linux-native-async-persistent"] }
|
||||
# dashmap: ConvStateStore 无锁并发 HashMap(session 锁重构方案 B-Phase0)。
|
||||
# ConvState 提到 ConvStateStore(DashMap),guard.reset/ai_is_generating 不依赖 session lock,
|
||||
# 治卡死连环(AiCompleted 延迟/工具后中断/第二条进队列同源根因)。详见 workflow session-lock-redesign。
|
||||
dashmap = "6"
|
||||
|
||||
55
PROGRESS.md
55
PROGRESS.md
@@ -1,6 +1,6 @@
|
||||
# DevFlow — 项目进展与工作交接
|
||||
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-06-18 | 当前阶段: PROGRESS Sprint 日志截至 Sprint 24(2026-06-15 · P0 generating 状态机加固 + 独立项批量);2026-06-16 起多会话进展(B-03 路由解耦 / 工具结果渲染 / 吞错降级 / 模型能力维度治理等)见 [docs/todo.md](docs/todo.md) + [docs/待审查.md](docs/待审查.md)
|
||||
> 创建: 2026-06-10 | 最后更新: 2026-07-02 | 当前阶段: 多 Agent 并行执行架构落地完成(Plan DAG + worktree 隔离 + JoinSet 并行 + Reviewer 仲裁 + 模板系统 + 全节点补齐),详见 [Batch.md](Batch.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
| 定位 | AI 原生创作流程驾驶舱,从想法到创作成果的全流程管理 |
|
||||
| 技术栈 | Tauri v2 + Vue 3 + TypeScript + Pinia / Rust Workspace (8 crate) / SQLite |
|
||||
| 路径 | `E:/wk-lab/devflow/` |
|
||||
| 架构文档 | `ARCHITECTURE.md` (22,745 字) |
|
||||
| Git 状态 | 已纳入版本控制(完整 commit 历史,当前分支 feat/batch-260615-workflow-unlock) |
|
||||
| AI 能力 | df-ai OpenAI/Anthropic 双协议 Provider + 12 工具 + Agentic Loop |
|
||||
| 架构文档 | `ARCHITECTURE.md` + `docs/02-架构设计/` (含多 Agent 设计文档) |
|
||||
| Git 状态 | 已纳入版本控制(main 分支) |
|
||||
| AI 能力 | OpenAI/Anthropic 双协议 + 40+ 工具 + Agentic Loop + 多 Agent 并行 + 人设系统 |
|
||||
|
||||
---
|
||||
|
||||
@@ -1031,17 +1031,46 @@
|
||||
|
||||
---
|
||||
|
||||
### 2026-06-28 — 最新对话 DB & Log 诊断:审批死锁 + Windows 兼容 + 标题 LLM 失败
|
||||
### 2026-07-19 — ConvStateStore 无锁重构 + 前端 P0 修复 + DeepSeek 400 修复 + 标题策略改进
|
||||
|
||||
> 分析来源:`devflow-trace.log` + `devflow-dev.db` 最新对话 `d9aef24f`(语音输入应用需求)。
|
||||
> 7 次工具连续失败后 L1 断路器熔断,1 条 pending 审批导致全链路死锁。
|
||||
> 本轮(Zed Agent 接管)完成 B-Phase 全量迁移、前端 P0、多轮 bug 修复、代码审查清理。
|
||||
> 代码变更 50 文件,+2454/-1251 行,未提交。
|
||||
|
||||
- [ ] **P0 — 审批 pending 超时自动取消**:`try_continue` 入口清理 >5min 的 pending。
|
||||
- [ ] **P0 — `create_project` 加 auto_create_dir**:消除建项目→建目录→绑定的死锁链。
|
||||
- [ ] **P1 — `run_command` 失败追加 shell 适配提示**:引导 LLM 改正 PowerShell 命令。
|
||||
- [ ] **P1 — PowerShell 反斜杠自动转义**:消除 `os error 123`(Unicode 转义误识别)。
|
||||
- [ ] **P2 — 标题摘要合并连续同 role**:避免标题 LLM 的 GLM 1214 拒绝。
|
||||
- [ ] **P3 — migration V33 + L0-handshake 防抖**。
|
||||
**B-Phase ConvStateStore 无锁重构(根治 session lock 竞争)**
|
||||
|
||||
| Phase | 内容 |
|
||||
|---|---|
|
||||
| Phase0-1 | DashMap 骨架 + AppState 接入(Claude Code 完成)|
|
||||
| Phase2 | guard/L0/chat IPC 写侧全量切 conv_states |
|
||||
| Phase3 | 读侧迁移 + 删 PerConvState.conv_state 字段 → 单源收敛 |
|
||||
| Phase4 | conversation_delete 同步 conv_states.remove |
|
||||
|
||||
**前端修复**
|
||||
- P0-1: watchdog `convStates.clear()` → `delete(activeConversationId)`
|
||||
- P0-2: switch/deleteConversation 入口加 `clearAllApprovalTimers()` + 注释修正
|
||||
- 每轮 token 独立显示(🔣 XXX in · YYY out)
|
||||
- STREAM_TIMEOUT_MS 45s → 90s
|
||||
|
||||
**后端修复**
|
||||
- DeepSeek 400「insufficient tool messages」→ 末尾 orphan tool_calls 自动剥离
|
||||
- `env_snapshot.rs` 5s 超时兜底(Windows Store alias 卡死)
|
||||
- L0 握手始终推 AiConvStateChanged{idle} 防前端状态残留
|
||||
- `openai_compat.rs` send 阶段 60s timeout
|
||||
- 原生 SSE 解析器替代 eventsource-stream(Windows 兼容)
|
||||
- run_agentic_loop 20s AiHeartbeat 保活
|
||||
- grep 正则自动修复(未分组 `|` → `(?:...)` 包裹)
|
||||
- read_file 路径不存在提示 list_directory
|
||||
|
||||
**标题策略改进**
|
||||
- extract_title: 取前 5 条 user 消息中最长的一条(替代第一条截取)
|
||||
- extract 不再落库 → LLM 每次都有重试机会,不再永久锁定差标题
|
||||
|
||||
**代码审查清理**
|
||||
- 修复 lib.rs 重复 handler 注册(🔴 严重)
|
||||
- 清理 unused import / [STUCK] 调试日志 / 过时注释
|
||||
- 340 tests passed, vue-tsc clean
|
||||
|
||||
**待提交**:50 文件改动,含 B-Phase 重构 + 前端 P0 + 多轮 bug 修复
|
||||
|
||||
## 七、开发约定
|
||||
|
||||
|
||||
47
assets/templates/bug-fix.yaml
Normal file
47
assets/templates/bug-fix.yaml
Normal file
@@ -0,0 +1,47 @@
|
||||
# Bug 修复模板
|
||||
name: Bug 修复
|
||||
description: AI 驱动的 bug 修复流程 — 定位、修复、验证、通知
|
||||
|
||||
nodes:
|
||||
locate:
|
||||
type: ai
|
||||
label: 定位 Bug
|
||||
config:
|
||||
prompt: |
|
||||
根据错误描述和堆栈信息,定位 bug 的根因。
|
||||
使用 search_files 和 read_file 工具查找相关代码。
|
||||
persona_id: analyst
|
||||
|
||||
fix:
|
||||
type: ai
|
||||
label: 修复 Bug
|
||||
config:
|
||||
prompt: |
|
||||
基于定位结果,修复 bug。
|
||||
使用 patch_file 工具进行最小化修改。
|
||||
persona_id: coder
|
||||
|
||||
verify:
|
||||
type: ai
|
||||
label: 验证修复
|
||||
config:
|
||||
prompt: |
|
||||
验证修复是否有效。
|
||||
使用 run_command 工具运行相关测试。
|
||||
persona_id: tester
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知结果
|
||||
config:
|
||||
type: desktop
|
||||
title: Bug 修复完成
|
||||
message: 修复已验证
|
||||
|
||||
edges:
|
||||
- from: locate
|
||||
to: fix
|
||||
- from: fix
|
||||
to: verify
|
||||
- from: verify
|
||||
to: notify
|
||||
36
assets/templates/code-review.yaml
Normal file
36
assets/templates/code-review.yaml
Normal file
@@ -0,0 +1,36 @@
|
||||
# 代码审查模板
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查流程 — 分析变更、审查质量、通知结果
|
||||
|
||||
nodes:
|
||||
analyze:
|
||||
type: ai
|
||||
label: 分析代码变更
|
||||
config:
|
||||
prompt: |
|
||||
分析以下代码变更,识别:
|
||||
1. 潜在 bug 和逻辑错误
|
||||
2. 安全漏洞
|
||||
3. 性能问题
|
||||
4. 代码规范
|
||||
persona_id: reviewer
|
||||
|
||||
report:
|
||||
type: ai
|
||||
label: 生成审查报告
|
||||
config:
|
||||
prompt: 基于分析结果,生成结构化审查报告,含优先级排序的改进建议
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 代码审查完成
|
||||
message: 审查报告已生成
|
||||
|
||||
edges:
|
||||
- from: analyze
|
||||
to: report
|
||||
- from: report
|
||||
to: notify
|
||||
62
assets/templates/feature-dev.yaml
Normal file
62
assets/templates/feature-dev.yaml
Normal file
@@ -0,0 +1,62 @@
|
||||
# 功能开发模板
|
||||
name: 功能开发
|
||||
description: AI 驱动的功能开发全流程 — 设计、编码、测试、审查、通知
|
||||
|
||||
nodes:
|
||||
design:
|
||||
type: ai
|
||||
label: 方案设计
|
||||
config:
|
||||
prompt: |
|
||||
分析需求,设计实现方案:
|
||||
1. 模块划分和接口定义
|
||||
2. 数据结构和核心算法
|
||||
3. 影响范围评估
|
||||
persona_id: architect
|
||||
|
||||
implement:
|
||||
type: ai
|
||||
label: 编码实现
|
||||
config:
|
||||
prompt: |
|
||||
按照设计方案实现功能。
|
||||
使用 write_file 和 patch_file 工具编写代码。
|
||||
persona_id: coder
|
||||
|
||||
test:
|
||||
type: ai
|
||||
label: 编写测试
|
||||
config:
|
||||
prompt: |
|
||||
为新功能编写单元测试和集成测试。
|
||||
使用 run_command 工具运行测试验证。
|
||||
persona_id: tester
|
||||
|
||||
review:
|
||||
type: ai
|
||||
label: 代码审查
|
||||
config:
|
||||
prompt: |
|
||||
审查实现代码,关注:
|
||||
1. 代码质量和可维护性
|
||||
2. 潜在 bug
|
||||
3. 安全性
|
||||
persona_id: reviewer
|
||||
|
||||
notify:
|
||||
type: notify
|
||||
label: 通知完成
|
||||
config:
|
||||
type: desktop
|
||||
title: 功能开发完成
|
||||
message: 设计→编码→测试→审查 全流程完成
|
||||
|
||||
edges:
|
||||
- from: design
|
||||
to: implement
|
||||
- from: implement
|
||||
to: test
|
||||
- from: test
|
||||
to: review
|
||||
- from: review
|
||||
to: notify
|
||||
@@ -95,14 +95,14 @@ impl ChatMessage {
|
||||
/// 无需每加一个状态就来这里改。当前取值 None/Some("active")/Some("truncated")
|
||||
/// 行为与旧反面排除完全等价(None=true / "active"=true / "truncated"=false)。
|
||||
pub fn is_active(&self) -> bool {
|
||||
matches!(self.status.as_deref(), None | Some("active"))
|
||||
matches!(self.status, None | Some(MessageStatus::Active))
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolDefinition {
|
||||
pub fn function(name: impl Into<String>, description: impl Into<String>, parameters: serde_json::Value) -> Self {
|
||||
Self {
|
||||
tool_type: "function".into(),
|
||||
tool_type: ToolType::new("function"),
|
||||
function: ToolFunction { name: name.into(), description: description.into(), parameters },
|
||||
}
|
||||
}
|
||||
@@ -166,22 +166,22 @@ mod tests {
|
||||
|
||||
// "active"
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("active".to_string());
|
||||
m.status = Some(MessageStatus::Active);
|
||||
assert!(m.is_active(), "Some(active) 应 active");
|
||||
|
||||
// "truncated" — 当前取值,与旧实现等价(false)
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("truncated".to_string());
|
||||
m.status = Some(MessageStatus::Truncated);
|
||||
assert!(!m.is_active(), "truncated 应不 active");
|
||||
|
||||
// "archived_segment" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("archived_segment".to_string());
|
||||
m.status = Some(MessageStatus::ArchivedSegment);
|
||||
assert!(!m.is_active(), "archived_segment 应不 active(白名单隔离)");
|
||||
|
||||
// "compressed" — 阶段2 待引入,白名单自动隔离
|
||||
let mut m = ChatMessage::user("hi");
|
||||
m.status = Some("compressed".to_string());
|
||||
m.status = Some(MessageStatus::Compressed);
|
||||
assert!(!m.is_active(), "compressed 应不 active(白名单隔离)");
|
||||
}
|
||||
|
||||
|
||||
@@ -103,11 +103,11 @@ pub struct ChatMessage {
|
||||
/// 生成该消息的 model(仅 assistant 消息有,消息级 model 追溯)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// 消息状态(UX-09 编辑重生成):None/"active" 正常可见;
|
||||
/// 消息状态(UX-09 编辑重生成):None 正常可见;
|
||||
/// "truncated" 软删(编辑某条 user 消息后其后续消息标记,保留 DB 可追溯但不进 LLM 上下文、前端视图过滤)
|
||||
/// 默认 None(向前兼容老 JSON 反序列化)。落库随 messages JSON 序列化,无需独立列。
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<String>,
|
||||
pub status: Option<MessageStatus>,
|
||||
/// DeepSeek thinking 模式的推理内容(多轮需回传)
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_content: Option<String>,
|
||||
@@ -148,11 +148,91 @@ pub enum MessageRole {
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// 消息状态枚举(IPC 边界序列化为小写 snake_case 字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum MessageStatus {
|
||||
/// 正常可见
|
||||
Active,
|
||||
/// 软删(编辑某条 user 消息后其后续消息标记)
|
||||
Truncated,
|
||||
/// 已压缩
|
||||
Compressed,
|
||||
/// 已归档段
|
||||
ArchivedSegment,
|
||||
}
|
||||
|
||||
impl MessageStatus {
|
||||
/// DB 存储用的小写 snake_case 字符串
|
||||
pub fn as_db_str(&self) -> &'static str {
|
||||
match self {
|
||||
MessageStatus::Active => "active",
|
||||
MessageStatus::Truncated => "truncated",
|
||||
MessageStatus::Compressed => "compressed",
|
||||
MessageStatus::ArchivedSegment => "archived_segment",
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 DB 字符串解析
|
||||
pub fn from_db_str(s: &str) -> Option<Self> {
|
||||
Some(match s {
|
||||
"active" => MessageStatus::Active,
|
||||
"truncated" => MessageStatus::Truncated,
|
||||
"compressed" => MessageStatus::Compressed,
|
||||
"archived_segment" => MessageStatus::ArchivedSegment,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// ChatMessage 结构体
|
||||
/// 工具类型(IPC 边界透明序列化为字符串,如 "function")
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolType(String);
|
||||
|
||||
impl ToolType {
|
||||
/// 构造新工具类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for ToolType {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ToolType {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
/// 工具定义
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
#[serde(rename = "type")]
|
||||
pub tool_type: String,
|
||||
pub tool_type: ToolType,
|
||||
pub function: ToolFunction,
|
||||
}
|
||||
|
||||
@@ -169,7 +249,7 @@ pub struct ToolFunction {
|
||||
pub struct ToolCall {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub call_type: String,
|
||||
pub call_type: ToolType,
|
||||
pub function: ToolCallFunction,
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,10 @@ anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
|
||||
# HTTP + 流式
|
||||
reqwest = { version = "0.12", features = ["stream", "json"] }
|
||||
reqwest = { version = "0.12", features = ["stream", "json", "rustls-tls"] }
|
||||
bytes = "1"
|
||||
futures = "0.3"
|
||||
eventsource-stream = "0.2"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
76
crates/df-ai/examples/glm_stream_test.rs
Normal file
@@ -0,0 +1,76 @@
|
||||
//! 独立诊断:用 df-ai 真实调用 GLM anthropic 流式端点,验证
|
||||
//! sse_parser + apply_anthropic_event + provider.stream() 整条链路。
|
||||
//!
|
||||
//! 二分定位「发消息卡掉」: 若本例能正常吐 chunk → provider 层(df-ai)OK,
|
||||
//! 问题在 devflow 应用层(provider 配置/emit/前端); 若卡/空/Err → df-ai 有 bug。
|
||||
//!
|
||||
//! 运行: cd crates/df-ai && cargo run --example glm_stream_test
|
||||
use df_ai::build_provider;
|
||||
use df_ai_core::CompletionRequest;
|
||||
use futures::StreamExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let token = std::env::var("ANTHROPIC_AUTH_TOKEN")
|
||||
.or_else(|_| std::env::var("ANTHROPIC_API_KEY"))
|
||||
.expect("需要环境变量 ANTHROPIC_AUTH_TOKEN");
|
||||
eprintln!("[glm-test] token len={}", token.len());
|
||||
|
||||
let provider = build_provider(
|
||||
"anthropic",
|
||||
"https://open.bigmodel.cn/api/anthropic",
|
||||
&token,
|
||||
"glm-5.2",
|
||||
);
|
||||
|
||||
// 用 JSON 反序列化构造请求,绕开字段列表(devflow 实际用 glm-5.2)
|
||||
let req: CompletionRequest = serde_json::from_str(
|
||||
r#"{"model":"glm-5.2","stream":true,"max_tokens":16,"messages":[{"role":"user","content":"说你好"}]}"#,
|
||||
)
|
||||
.expect("parse CompletionRequest");
|
||||
|
||||
eprintln!("[glm-test] 调用 provider.stream() ...");
|
||||
let t0 = std::time::Instant::now();
|
||||
let mut s = match provider.stream(req).await {
|
||||
Ok(s) => {
|
||||
eprintln!("[glm-test] stream() Ok, 建连耗时 {:?}", t0.elapsed());
|
||||
s
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] stream() Err: {:#}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let mut n = 0;
|
||||
let mut got_text = false;
|
||||
while let Some(chunk_result) = s.next().await {
|
||||
n += 1;
|
||||
match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
got_text = true;
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] chunk#{} delta={:?} reasoning={:?} finished={} usage={:?} err={:?}",
|
||||
n, chunk.delta,
|
||||
chunk.reasoning_content.as_deref().map(|s| if s.len() > 30 { format!("{}..", &s[..30]) } else { s.to_string() }),
|
||||
chunk.finished, chunk.usage, chunk.error
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[glm-test] chunk#{} Err: {}", n, e);
|
||||
}
|
||||
}
|
||||
if n > 60 {
|
||||
eprintln!("[glm-test] 超 60 chunk 截断");
|
||||
break;
|
||||
}
|
||||
}
|
||||
eprintln!(
|
||||
"[glm-test] 流结束 共 {} chunk, 是否拿到文本={}, 总耗时 {:?}",
|
||||
n,
|
||||
got_text,
|
||||
t0.elapsed()
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,6 @@
|
||||
//! 本模块仅保留 Provider struct + impl(HTTP 调用),Rust impl 块不可跨文件故作此切分。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use std::time::Duration;
|
||||
@@ -590,17 +589,19 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
|
||||
debug!(model = %body.model, "Anthropic 流式调用");
|
||||
|
||||
let resp = match self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(实测 GLM 偶发建连后长时间不返回)。
|
||||
// 注意:不能用 reqwest 的 .timeout()——它是整个请求(含 body 读取)的总超时,
|
||||
// 流式长生成任务会被误砍(build_provider_client 注释已明确)。改用 tokio::time::timeout
|
||||
// 包裹 send().await,只管建连+首响应头,不管后续 body 读取(后续由 stream_llm idle timeout 兜底)。
|
||||
// 60s 选型:正常 send(建连+收 200 headers)<5s,60s 足够宽容。
|
||||
let send_future = self
|
||||
.auth_headers(self.client.post(self.messages_url()))
|
||||
.json(&body)
|
||||
.version(reqwest::Version::HTTP_11)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// B-260618-26: 记 reqwest 错误源因链。原 ? 转 anyhow 仅 Display
|
||||
// "error sending request for url" 无法定位 reset/TLS/超时/body 真因。
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(
|
||||
is_timeout = e.is_timeout(),
|
||||
is_connect = e.is_connect(),
|
||||
@@ -620,6 +621,14 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
std::error::Error::source(&e)
|
||||
);
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
// send 阶段超时(60s 未返回 HTTP 响应头):GLM 端点可能不可达或极慢
|
||||
tracing::error!(
|
||||
url = %self.messages_url(),
|
||||
"Anthropic 流式 send 超时(60s 未返回响应头)"
|
||||
);
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
@@ -629,36 +638,32 @@ impl LlmProvider for AnthropicCompatProvider {
|
||||
anyhow::bail!("Anthropic 流式 API 错误 {}: {}", status, text);
|
||||
}
|
||||
|
||||
// 流式解析:eventsource 逐事件处理,按 type 字段分发转 StreamChunk。
|
||||
// 事件解析/usage 累积逻辑抽到 apply_anthropic_event 纯函数,便于单测;此处闭包只负责传 data。
|
||||
// usage 累积:message_start 给 input_tokens,message_delta 给累计 output_tokens(非增量),message_stop 带出。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream(同 openai_compat)。
|
||||
let mut usage_accum: Option<TokenUsage> = None;
|
||||
// B-260618-28: MidStream error(如 GLM 1214 messages 非法)时附 messages 摘要定位哪条非法。
|
||||
// precheck(Init 路径,发送前)漏的 case,靠此在 SSE error 事件暴露实际 messages 结构到前端 raw。
|
||||
let messages_summary = Self::summarize_messages(&body.messages);
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(ev) => {
|
||||
let mut chunk = apply_anthropic_event(&ev.data, &mut usage_accum);
|
||||
// GLM 中途 error(如 1214)→ chunk.error 附 messages 摘要,经 stream_recv MidStream
|
||||
// 路径 emit AiError raw,前端直接看到实际 messages 结构定位非法字段。
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let mut chunk = apply_anthropic_event(&data, &mut usage_accum);
|
||||
if let Some(err) = chunk.error.as_mut() {
|
||||
*err = format!("{} | messages 摘要: {}", err, messages_summary);
|
||||
}
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
Ok(chunk)
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "Anthropic SSE 错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("Anthropic SSE 错误: {}", e);
|
||||
error!(error = %e, "Anthropic SSE 事件流错误");
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
error!("{}", ctx);
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
719
crates/df-ai/src/context/manager_tests.rs
Normal file
719
crates/df-ai/src/context/manager_tests.rs
Normal file
@@ -0,0 +1,719 @@
|
||||
//! `ContextManager` 方法级单测 — 从 `mod.rs` 抽出以控制 `mod.rs` 行数。
|
||||
//!
|
||||
//! 这些测试覆盖 `ContextManager` 各公开/私有方法(push / build_for_request / compress /
|
||||
//! topic marker / 溯源 id 等),需访问私有字段与方法,故仍置于 `crate::context` 模块树内
|
||||
//! (`mod manager_tests;` 由 `mod.rs` 通过 `#[cfg(test)] mod manager_tests;` 引入)。
|
||||
|
||||
#![cfg(test)]
|
||||
|
||||
use super::*;
|
||||
use crate::context_helpers::{ContextConfig, TokenEstimator, PROTECT_COUNT};
|
||||
use crate::provider::ToolCall;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn estimate_message_counts_parts_tokens() {
|
||||
// F-260614-05 多模态回归:含图消息的大段 base64 必须计入 token 预算,
|
||||
// 否则 history_tokens 严重低估 → build_for_request 不裁剪 → provider 超限。
|
||||
let est = TokenEstimator::default();
|
||||
|
||||
// 纯文本基线
|
||||
let text_msg = ChatMessage::user("短文本");
|
||||
let text_tokens = est.estimate_message(&text_msg);
|
||||
|
||||
// 同样 content + 含大段 base64 的 parts → token 应显著高于纯文本
|
||||
let big_base64 = "iVBORw0KGgoAAAANS".repeat(100); // ~1.7k 字符
|
||||
let multimodal = ChatMessage::user_parts(
|
||||
"短文本",
|
||||
vec![crate::provider::ContentPart::image_base64("image/png", big_base64.clone())],
|
||||
);
|
||||
let mm_tokens = est.estimate_message(&multimodal);
|
||||
|
||||
assert!(
|
||||
mm_tokens > text_tokens,
|
||||
"含图消息 token({}) 应高于纯文本({})",
|
||||
mm_tokens,
|
||||
text_tokens
|
||||
);
|
||||
// base64 字符按 0.35 粗估,约 1.7k * 0.35 ≈ 595 tokens 量级
|
||||
assert!(
|
||||
mm_tokens > 500,
|
||||
"大 base64 应贡献可观 token,实际 {}",
|
||||
mm_tokens
|
||||
);
|
||||
|
||||
// url 模式(无字节)也按 URL 长度估算,不爆
|
||||
let url_msg = ChatMessage::user_parts(
|
||||
"t",
|
||||
vec![crate::provider::ContentPart::image_url("https://example.com/x.png")],
|
||||
);
|
||||
let url_tokens = est.estimate_message(&url_msg);
|
||||
assert!(url_tokens > text_tokens, "url 片也应有少量 token 贡献");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_history_no_trim() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("你好"));
|
||||
mgr.push(ChatMessage::assistant("你好啊"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert_eq!(msgs.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn over_budget_trims_old() {
|
||||
// 小预算强制裁剪:20 条超预算,触发裁剪且保留保护区
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
// user/assistant 交替(真实对话序列;连续 user 会被 ensure_sequence_legal 合并,无法测条数裁剪)
|
||||
for i in 0..20 {
|
||||
if i % 2 == 0 {
|
||||
mgr.push(ChatMessage::user(&format!("这是第 {} 条较长的消息用于撑爆预算", i)));
|
||||
} else {
|
||||
mgr.push(ChatMessage::assistant(&format!("第 {} 条较长的回复用于撑爆预算", i)));
|
||||
}
|
||||
}
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed, "超预算应触发裁剪");
|
||||
assert!(msgs.len() < 20, "应裁掉部分旧消息, 实际 {}", msgs.len());
|
||||
|
||||
// 保护区:最新一条必保留(末条 i=19 是 assistant)
|
||||
assert_eq!(
|
||||
msgs.last().unwrap().content,
|
||||
"第 19 条较长的回复用于撑爆预算",
|
||||
"保护区最新消息被误裁"
|
||||
);
|
||||
|
||||
// 裁剪是视图:内存全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 20, "裁剪污染了内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_triplet_kept_atomic() {
|
||||
// 三元组不可分离:Head 与 Tail 同进同出,永不从中间切断
|
||||
// 布局:6 旧(淘汰区) + 三元组(裁剪边界) + 6 新(保护区) = 15 条
|
||||
let mut mgr = ContextManager::new(cfg(95));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("旧消息 {}", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调工具",
|
||||
vec![ToolCall::new("tc1", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "文件内容"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
for i in 0..6 {
|
||||
mgr.push(ChatMessage::user(&format!("新消息 {}", i)));
|
||||
}
|
||||
|
||||
// 分支一:预算宽松,三元组整体保留 → Head 在则 Tail 在
|
||||
let (msgs_keep, trimmed1) = mgr.build_for_request(0);
|
||||
assert!(trimmed1, "分支一应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep),
|
||||
"分支一三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_keep),
|
||||
has_tail(&msgs_keep)
|
||||
);
|
||||
|
||||
// 分支二:预算紧张,三元组整体丢弃 → Head 不在则 Tail 也不在
|
||||
let (msgs_drop, trimmed2) = mgr.build_for_request(40);
|
||||
assert!(trimmed2, "分支二应触发裁剪");
|
||||
assert_eq!(
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop),
|
||||
"分支二三元组被切断: head={} tail={}",
|
||||
has_head(&msgs_drop),
|
||||
has_tail(&msgs_drop)
|
||||
);
|
||||
|
||||
// 裁剪是视图:两次 build 都不应改变内存全量
|
||||
assert_eq!(
|
||||
mgr.all_messages_clone().len(),
|
||||
15,
|
||||
"裁剪污染了内存全量"
|
||||
);
|
||||
}
|
||||
|
||||
fn has_head(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
}
|
||||
|
||||
fn has_tail(msgs: &[ChatMessage]) -> bool {
|
||||
msgs.iter().any(|m| matches!(m.role, MessageRole::Tool))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn replace_tool_result_updates_tokens() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::tool_result("tc1", "短"));
|
||||
let before = mgr.history_tokens();
|
||||
assert!(mgr.replace_tool_result_content("tc1", "这是一个明显更长的替换内容用于验证 token 重估"));
|
||||
let after = mgr.history_tokens();
|
||||
assert!(after > before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rebuilds_token_cache() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let src = vec![
|
||||
ChatMessage::user("测试消息一"),
|
||||
ChatMessage::assistant("回复一"),
|
||||
ChatMessage::user("测试消息二"),
|
||||
];
|
||||
mgr.restore_from_messages(src);
|
||||
assert!(mgr.history_tokens() > 0);
|
||||
assert_eq!(mgr.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn push_token_only_active() {
|
||||
// F-15 §3.3:!active 消息(truncated / archived_segment / compressed)仍 push
|
||||
// 到 self.messages(全量保留,持久化/前端视图自管),但不计入 history_tokens,
|
||||
// 避免 build_for_request 误判超预算触发不必要裁剪。
|
||||
|
||||
// 1) 直接 push 路径
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let active_msg = ChatMessage::user("这条是 active 的");
|
||||
let active_tokens = TokenEstimator::default().estimate_message(&active_msg);
|
||||
let mut inactive_msg = ChatMessage::assistant("这条被截断了不该计 token");
|
||||
inactive_msg.status = Some(MessageStatus::Truncated);
|
||||
let inactive_tokens = TokenEstimator::default().estimate_message(&inactive_msg);
|
||||
|
||||
mgr.push(active_msg);
|
||||
mgr.push(inactive_msg);
|
||||
|
||||
// 全量保留(两条都在内存)
|
||||
assert_eq!(mgr.len(), 2, "active + !active 都应 push 到 self.messages");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "持久化全量不受 push 修正影响");
|
||||
// token 预算只含 active
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
active_tokens,
|
||||
"history_tokens 应只含 active,多算了 {}(inactive 应被忽略)",
|
||||
mgr.history_tokens().saturating_sub(active_tokens)
|
||||
);
|
||||
assert!(
|
||||
inactive_tokens > 0,
|
||||
"前提:inactive 消息本身确有 token,否则无法证明它被排除"
|
||||
);
|
||||
|
||||
// 2) restore_from_messages 路径(调 push,token 同步仅 active)
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
let mut a = ChatMessage::user("active 一");
|
||||
a.status = Some(MessageStatus::Active);
|
||||
let mut b = ChatMessage::user("archived 一");
|
||||
b.status = Some(MessageStatus::ArchivedSegment);
|
||||
let mut c = ChatMessage::user("compressed 一");
|
||||
c.status = Some(MessageStatus::Compressed);
|
||||
mgr2.restore_from_messages(vec![a, b, c]);
|
||||
|
||||
assert_eq!(mgr2.len(), 3, "restore 后全量保留三条");
|
||||
// 只 active 一条计 token(b/c 是白名单外状态,is_active 返回 false)
|
||||
let only_active_tokens = TokenEstimator::default()
|
||||
.estimate_message(&ChatMessage::user("active 一"));
|
||||
assert_eq!(
|
||||
mgr2.history_tokens(),
|
||||
only_active_tokens,
|
||||
"restore 后 history_tokens 应只含 active 一条,archived/compressed 不计"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_history_returns_empty() {
|
||||
let mgr = ContextManager::new(cfg(100_000));
|
||||
let (msgs, trimmed) = mgr.build_for_request(10);
|
||||
assert!(!trimmed);
|
||||
assert!(msgs.is_empty(), "空历史应返回空列表");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn protect_zone_returns_full_when_untrimmable() {
|
||||
// 消息全在保护区(< PROTECT_COUNT 条)且超预算 → 无可淘汰单元,走 trim_end==0 兜底返回全量
|
||||
let mut mgr = ContextManager::new(cfg(10));
|
||||
mgr.push(ChatMessage::user("撑爆小预算的长消息内容"));
|
||||
mgr.push(ChatMessage::assistant("第二条撑爆预算的长消息"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "无可淘汰单元应返回 false(兜底)");
|
||||
assert_eq!(msgs.len(), 2, "兜底 sanitize 后返回全部保护区消息(user/assistant 交替不合并)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn system_over_budget_trims_to_protect_zone() {
|
||||
// system prompt 吃光预算 → history 仍尝试裁剪到保护区,不 panic
|
||||
let mut mgr = ContextManager::new(cfg(200));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {} 撑量", i)));
|
||||
}
|
||||
let (msgs, _trimmed) = mgr.build_for_request(195);
|
||||
assert!(
|
||||
msgs.len() <= PROTECT_COUNT,
|
||||
"system 超预算时裁剪后至多保留保护区 {} 条,实际 {}",
|
||||
PROTECT_COUNT,
|
||||
msgs.len()
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-15 阶段1 辅助方法单测 ──
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_marks_compressed_and_returns_refs() {
|
||||
// F-15 §4.2/§4.3:compress_old_messages 把 [0, end) 内 active 消息标 compressed,
|
||||
// 同步扣 history_tokens,返回它们的克隆供 LLM 摘要。持久化全量保留。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("旧消息1"));
|
||||
mgr.push(ChatMessage::assistant("旧回复1"));
|
||||
mgr.push(ChatMessage::user("新消息2"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
assert!(tokens_before > 0);
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2, "应压缩前 2 条 active");
|
||||
assert_eq!(compressed[0].content, "旧消息1");
|
||||
assert_eq!(compressed[1].content, "旧回复1");
|
||||
|
||||
// status 已改 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(mgr.messages_mut()[1].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
// 保护区外(本例 index 2)仍 active
|
||||
assert!(mgr.messages_mut()[2].message.is_active(), "保护区外消息不应被动");
|
||||
|
||||
// 持久化全量不变
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "compress 不应删消息(单向,全量保留)");
|
||||
|
||||
// token 已扣(剩第 3 条的)
|
||||
let only_third_tokens = TokenEstimator::default().estimate_message(&ChatMessage::user("新消息2"));
|
||||
assert_eq!(mgr.history_tokens(), only_third_tokens, "history_tokens 应扣除前两条");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_is_idempotent() {
|
||||
// 幂等:已 compressed 不二次压缩,二次调用返回空 Vec 且 history_tokens 不再变。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
mgr.push(ChatMessage::user("b"));
|
||||
|
||||
let first = mgr.compress_old_messages(2);
|
||||
assert_eq!(first.len(), 2);
|
||||
let tokens_after_first = mgr.history_tokens();
|
||||
|
||||
let second = mgr.compress_old_messages(2);
|
||||
assert!(second.is_empty(), "二次压缩应返回空(已 compressed 不重压)");
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_after_first,
|
||||
"二次压缩 history_tokens 不应再变(幂等)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_clamps_oversized_end() {
|
||||
// compress_end 越界自动 clamp 到 len,不 panic。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("唯一"));
|
||||
let compressed = mgr.compress_old_messages(999);
|
||||
assert_eq!(compressed.len(), 1, "越界 end 应 clamp 到 len(1)");
|
||||
assert_eq!(mgr.history_tokens(), 0, "全量压缩后 history_tokens 归零");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_old_messages_skips_already_inactive() {
|
||||
// 范围内含 truncated(已 !active)的消息:跳过,不返,不重复扣 token。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut truncated = ChatMessage::user("被截断");
|
||||
truncated.status = Some(MessageStatus::Truncated);
|
||||
mgr.push(truncated);
|
||||
mgr.push(ChatMessage::user("active 一条"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
// truncated 已不计 token(见 push_token_only_active),所以 tokens_before 只含 active 一条
|
||||
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 1, "只压缩 active 那条,truncated 跳过");
|
||||
assert_eq!(mgr.history_tokens(), 0);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before.saturating_sub(tokens_before),
|
||||
"幂等扣除:truncated 本就没计 token,active 扣光"
|
||||
);
|
||||
// truncated 状态不被改成 compressed(保留原 truncated,语义不混淆)
|
||||
assert_eq!(
|
||||
mgr.messages_mut()[0].message.status.as_ref(),
|
||||
Some(&MessageStatus::Truncated),
|
||||
"已 truncated 不应被改写为 compressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_compressible_messages_respects_protect_zone() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
for i in 0..8 {
|
||||
mgr.push(ChatMessage::user(&format!("消息 {}", i)));
|
||||
}
|
||||
// protect_start=6 → [0,6) 内有 active → true
|
||||
assert!(mgr.has_compressible_messages(6));
|
||||
// protect_start=0 → 空范围 → false
|
||||
assert!(!mgr.has_compressible_messages(0));
|
||||
// 全部压缩后 → false
|
||||
mgr.compress_old_messages(6);
|
||||
assert!(!mgr.has_compressible_messages(6), "全 compressed 后不应有可压缩消息");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_compressing_flag_round_trip() {
|
||||
// 标志位读写 round-trip;clear() 复位。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
assert!(!mgr.is_compressing(), "默认 false");
|
||||
mgr.set_compressing(true);
|
||||
assert!(mgr.is_compressing(), "set true 后应读到 true");
|
||||
mgr.set_compressing(false);
|
||||
assert!(!mgr.is_compressing(), "set false 后复位");
|
||||
// clear 复位
|
||||
mgr.set_compressing(true);
|
||||
mgr.clear();
|
||||
assert!(!mgr.is_compressing(), "clear() 应复位 is_compressing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_at_adds_to_budget_when_active() {
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("a"));
|
||||
let tokens_before = mgr.history_tokens();
|
||||
|
||||
// 插入 active system 消息 → 计入 token
|
||||
mgr.insert_at(0, ChatMessage::system("## 摘要"));
|
||||
assert!(mgr.history_tokens() > tokens_before, "active 消息应计入 token");
|
||||
assert_eq!(mgr.len(), 2);
|
||||
assert_eq!(mgr.messages_mut()[0].message.content, "## 摘要");
|
||||
|
||||
// 插入 !active 消息 → 不计入 token
|
||||
let tokens_before_inactive = mgr.history_tokens();
|
||||
let mut inactive = ChatMessage::user("x");
|
||||
inactive.status = Some(MessageStatus::Truncated);
|
||||
mgr.insert_at(0, inactive);
|
||||
assert_eq!(
|
||||
mgr.history_tokens(),
|
||||
tokens_before_inactive,
|
||||
"!active 消息插入不应计 token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_eviction_units_keeps_triplet_atomic_public() {
|
||||
// 公开的 build_eviction_units:三元组(Head + Tail + Standalone Assistant)应落同一单元。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("前置"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("c1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("c1", "结果"));
|
||||
mgr.push(ChatMessage::assistant("完成"));
|
||||
mgr.push(ChatMessage::user("后置"));
|
||||
// protect_start=5(全部纳入)
|
||||
let units = mgr.build_eviction_units(5);
|
||||
// 第一个单元是前置 Standalone(end=1);第二个单元应包含三元组三件套 + 后置应分开
|
||||
// 确认三元组的 Head+Tail+Assistant 在同一单元(end 跳过 3)
|
||||
let unit2 = units.iter().find(|u| u.end >= 4).expect("应有跨三元组的单元");
|
||||
assert!(
|
||||
unit2.end >= 4,
|
||||
"三元组三件套应在同一淘汰单元, end={}",
|
||||
unit2.end
|
||||
);
|
||||
}
|
||||
|
||||
// ── F-260619-04 P1 消息级溯源:last_assistant/last_user message_id ──
|
||||
|
||||
#[test]
|
||||
fn last_assistant_message_id_returns_latest() {
|
||||
// 多条 assistant,反向扫描取末条 id(本轮 AI 产出的载体)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
let first = push_and_get_id(&mut mgr, ChatMessage::assistant("答1"));
|
||||
mgr.push(ChatMessage::user("问2"));
|
||||
let last = push_and_get_id(&mut mgr, ChatMessage::assistant("答2"));
|
||||
// 末条 assistant id 应是 last(非 first)
|
||||
assert_eq!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(last.as_str()),
|
||||
"应取末条 assistant id, 而非首条"
|
||||
);
|
||||
assert_ne!(
|
||||
mgr.last_assistant_message_id().as_deref(),
|
||||
Some(first.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_user_message_id_returns_latest() {
|
||||
// 多条 user,反向扫描取末条 id(触发本轮检索的 user)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问1"));
|
||||
mgr.push(ChatMessage::assistant("答1"));
|
||||
let last_user = push_and_get_id(&mut mgr, ChatMessage::user("问2"));
|
||||
assert_eq!(
|
||||
mgr.last_user_message_id().as_deref(),
|
||||
Some(last_user.as_str())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_when_no_such_role() {
|
||||
// 无 assistant → None;无 user → None
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("只有 user"));
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"无 assistant 消息应返 None"
|
||||
);
|
||||
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::assistant("只有 assistant"));
|
||||
assert!(
|
||||
mgr2.last_user_message_id().is_none(),
|
||||
"无 user 消息应返 None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_message_id_none_for_legacy_no_id() {
|
||||
// 老数据反序列化消息 id=None → 返 None(向前兼容,溯源降级 conv: 旧格式)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
let mut legacy = ChatMessage::assistant("老消息无 id");
|
||||
legacy.id = None;
|
||||
mgr.push(legacy);
|
||||
assert!(
|
||||
mgr.last_assistant_message_id().is_none(),
|
||||
"老消息无 id 应返 None(向前兼容)"
|
||||
);
|
||||
}
|
||||
|
||||
/// helper:push 一条消息并返回其 id(测试用,确认取到的是该消息自身 id)
|
||||
fn push_and_get_id(mgr: &mut ContextManager, message: ChatMessage) -> String {
|
||||
let id = message.id.clone();
|
||||
mgr.push(message);
|
||||
id.expect("新构造消息必有 id")
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
#[test]
|
||||
fn topic_marker_triggers_on_two_different_high_conf_topics() {
|
||||
// 双高置信:两条 user 消息各自 intent 置信 >= 0.7 且不同 topic → 置位 marker。
|
||||
// "帮我重构这段代码"(Code, conf=1.0) + "创建项目并绑定目录"(Project, conf=1.0)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
// 此时只有一条 user,无 marker
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条 user 不触发 marker");
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let marker = mgr.take_topic_marker()
|
||||
.expect("双不同 topic 应置位 marker");
|
||||
assert!(marker.contains("code"), "old topic 应为 code, 实际: {}", marker);
|
||||
assert!(marker.contains("project"), "new topic 应为 project, 实际: {}", marker);
|
||||
// take 后清空(幂等)
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后应清空");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_on_same_topic() {
|
||||
// 同 topic(code/code)→ 不置位(非切换)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("再重构另一段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic 不应触发 marker");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_when_either_topic_none() {
|
||||
// 保守:任一 topic 为 None(低置信未标)不置位。
|
||||
// "今天的天气不错"(Unknown, conf=0.0 → topic=None) + "重构代码"(Code, conf 高)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("今天的天气不错啊"));
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "前一条 topic None 不应触发 marker(保守)");
|
||||
|
||||
// 反向:前一条高置信 + 后一条低置信
|
||||
let mut mgr2 = ContextManager::new(cfg(100_000));
|
||||
mgr2.push(ChatMessage::user("帮我重构这段代码"));
|
||||
mgr2.push(ChatMessage::user("嗯嗯好的"));
|
||||
assert!(mgr2.take_topic_marker().is_none(), "后一条 topic None 不应触发 marker(保守)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_polluting_compression_or_trim() {
|
||||
// topic 字段不参与裁剪/压缩(只检测):超预算裁剪 + 压缩后消息仍正常,
|
||||
// topic 标记独立工作。push 足量消息撑爆小预算触发裁剪。
|
||||
let mut mgr = ContextManager::new(cfg(80));
|
||||
for i in 0..10 {
|
||||
mgr.push(ChatMessage::user(&format!("重构代码第 {} 条长消息撑爆预算", i)));
|
||||
}
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // Project topic → marker
|
||||
let _ = mgr.take_topic_marker(); // 消费 marker(topic 不影响裁剪)
|
||||
// 裁剪仍正常工作(topic 字段不参与裁剪逻辑)
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(trimmed);
|
||||
assert!(!msgs.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_default_none_in_constructors() {
|
||||
// TrackedMessage.topic 在 push/insert_at 构造时默认 None(push 会按 intent 推断覆盖,
|
||||
// insert_at 始终 None——insert_at 不做主题推断,保守)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.insert_at(0, ChatMessage::system("摘要"));
|
||||
// insert_at 的 system 消息 topic=None(role 非 User)
|
||||
assert_eq!(mgr.messages_mut()[0].topic, None);
|
||||
}
|
||||
|
||||
// ===== 苁刻测:主题切换检测对抗/边界/链式 =====
|
||||
|
||||
#[test]
|
||||
fn topic_marker_chained_three_switches_each_triggers() {
|
||||
// 对抗(链式):A→B→C 三次连续主题切换,每次 push 不同 topic 都应置位 marker。
|
||||
// 验证 last_user_topic 反向扫描取最近 user,链式切换逐次触发不漏。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
|
||||
// 1. Code
|
||||
mgr.push(ChatMessage::user("帮我重构这段代码")); // topic=code
|
||||
assert!(mgr.take_topic_marker().is_none(), "首条不触发");
|
||||
|
||||
// 2. Code → Project(切换)
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // topic=project
|
||||
let m1 = mgr.take_topic_marker().expect("code→project 应触发");
|
||||
assert!(m1.contains("code") && m1.contains("project"), "marker1: {}", m1);
|
||||
assert!(mgr.take_topic_marker().is_none(), "take 后清空");
|
||||
|
||||
// 3. Project → Task(切换)
|
||||
mgr.push(ChatMessage::user("推进这个任务到下一状态")); // topic=task
|
||||
let m2 = mgr.take_topic_marker().expect("project→task 应触发");
|
||||
assert!(m2.contains("project") && m2.contains("task"), "marker2: {}", m2);
|
||||
|
||||
// 4. Task → Http(切换,证链式不因中间穿插断)
|
||||
mgr.push(ChatMessage::user("调用接口请求这个 api")); // topic=http
|
||||
let m3 = mgr.take_topic_marker().expect("task→http 应触发");
|
||||
assert!(m3.contains("task") && m3.contains("http"), "marker3: {}", m3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_low_confidence_chain_never_triggers() {
|
||||
// 对抗(低置信链):连续 push 低置信(Unknown)消息 → topic 全 None → 永不置位
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("啊这样啊")); // Unknown/0.0 → None
|
||||
assert!(mgr.take_topic_marker().is_none(), "双 None 链不触发");
|
||||
// 再接一条高置信,但前一条 None → 仍不触发(保守:任一 None 即不标)
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"前一条 topic None,即使本条高置信也不触发(双高置信约束)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_high_then_low_confidence_never_triggers() {
|
||||
// 对抗(反向低置信链):高置信 → 低置信,后一条 None → 不触发
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // Code/1.0
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
mgr.push(ChatMessage::user("嗯嗯好的")); // Unknown/0.0 → None
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"后一条 topic None,即使前一条高置信也不触发"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_switch_back_to_same_after_different_not_triggered() {
|
||||
// 边界:A→B→A:第二次 A 时,末两条 user 是 B(高)→ A(高),不同 → 应触发。
|
||||
// 验证 last_user_topic 只看最近一条 user,不缓存历史(不会因"曾标过 code"漏判)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → 触发 code|project
|
||||
let _ = mgr.take_topic_marker();
|
||||
mgr.push(ChatMessage::user("再重构另一段代码")); // code(末两条:project→code 不同)
|
||||
let m = mgr.take_topic_marker().expect("project→code 切回应触发");
|
||||
assert!(m.contains("project") && m.contains("code"), "切回原 topic 也应触发: {}", m);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_field_survives_compress_old_messages() {
|
||||
// 对抗(topic 跨 compress):compress_old_messages 只改 status,不动 topic 字段。
|
||||
// 压缩后 compressed 消息的 topic 标签保留(向前兼容,DB/内存一致性)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // idx 0, topic=code
|
||||
mgr.push(ChatMessage::user("读取这个文件")); // idx 1, topic=file
|
||||
mgr.push(ChatMessage::assistant("ok")); // idx 2
|
||||
|
||||
// 压缩 [0,2):idx 0/1 标 compressed
|
||||
let compressed = mgr.compress_old_messages(2);
|
||||
assert_eq!(compressed.len(), 2);
|
||||
// topic 字段保留(compress 不触碰)
|
||||
assert_eq!(mgr.messages_mut()[0].topic.as_deref(), Some("code"), "compressed 消息 topic 应保留");
|
||||
assert_eq!(mgr.messages_mut()[1].topic.as_deref(), Some("file"), "compressed 消息 topic 应保留");
|
||||
// status 改为 compressed
|
||||
assert_eq!(mgr.messages_mut()[0].message.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_system_message_in_send_view_under_adequate_budget() {
|
||||
// 边界(marker 端到端):取 marker → 调用方 insert_at 顶 system 标记 →
|
||||
// build_for_request 在充足预算下 system 正常进发送视图(不丢,不 panic)。
|
||||
// 注:build_for_request 裁剪策略裁前部(保护最近 PROTECT_COUNT 条),顶置 system
|
||||
// 在极小预算撑爆时会被裁——这是策略既定行为,非 bug。本测验证充足预算下 marker
|
||||
// 流程端到端正常:marker 取出 + insert system + 发送视图含该 system。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录")); // project → marker
|
||||
let marker = mgr.take_topic_marker().expect("应触发 marker");
|
||||
assert!(marker.contains("code") && marker.contains("project"));
|
||||
|
||||
// 调用方据 marker insert system 标记(agentic loop 实际行为)
|
||||
mgr.insert_at(0, ChatMessage::system("[主题切换标记]"));
|
||||
let (msgs, trimmed) = mgr.build_for_request(0);
|
||||
assert!(!trimmed, "充足预算不应裁剪");
|
||||
assert!(
|
||||
msgs.iter().any(|m| m.content.contains("[主题切换标记]")),
|
||||
"marker system 应进发送视图: {:?}",
|
||||
msgs.iter().map(|m| m.content.chars().take(15).collect::<String>()).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_not_triggered_by_assistant_or_tool_messages() {
|
||||
// 边界:只有 user 消息参与主题推断;assistant/tool push 不触发 marker(topic=None)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码")); // code
|
||||
assert!(mgr.take_topic_marker().is_none());
|
||||
// assistant 消息(含 error 关键词但不影响 topic 推断,因 role 非 User)
|
||||
mgr.push(ChatMessage::assistant("编译 error 出现了"));
|
||||
assert!(
|
||||
mgr.take_topic_marker().is_none(),
|
||||
"assistant 消息不参与主题推断,不触发 marker"
|
||||
);
|
||||
// 再 push user(同 code topic)→ 末两条 user 都是 code,不切换
|
||||
mgr.push(ChatMessage::user("继续重构"));
|
||||
assert!(mgr.take_topic_marker().is_none(), "同 topic user 间不切换(assistant 不算)");
|
||||
// 此时末条 user 是 code,切到 project 应触发
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
assert!(mgr.take_topic_marker().is_some(), "code→project 切换应触发(assistant 不打断)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn topic_marker_take_is_idempotent_and_clears() {
|
||||
// 边界:take_topic_marker 一次性消费,take 两次第二次必 None(防重复 insert)
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("重构这段代码"));
|
||||
mgr.push(ChatMessage::user("创建项目并绑定目录"));
|
||||
let _ = mgr.take_topic_marker().expect("应有 marker");
|
||||
// 第二次 take 必 None
|
||||
assert!(mgr.take_topic_marker().is_none(), "marker 一次性消费, 二次 take 必空");
|
||||
}
|
||||
538
crates/df-ai/src/context/mod.rs
Normal file
538
crates/df-ai/src/context/mod.rs
Normal file
@@ -0,0 +1,538 @@
|
||||
//! 上下文管理器 — 管理对话上下文和 token 预算
|
||||
//!
|
||||
//! 职责:
|
||||
//! - 维护消息历史及其 token 计数缓存
|
||||
//! - 提供预算感知的消息裁剪(保护工具调用三元组)
|
||||
//! - 为 run_agentic_loop 提供受控的消息视图
|
||||
//!
|
||||
//! 裁剪策略与模型选择是正交维度:本模块只管「窗口多大、怎么裁」,
|
||||
//! 用哪个 model / 是否启用 reasoning 由调用方在 CompletionRequest 层决定。
|
||||
//!
|
||||
//! 纯函数 / 数据类型 / 常量(TokenEstimator / ContextConfig / MessageGroup /
|
||||
//! TrackedMessage / EvictionUnit / classify_group / PROTECT_COUNT /
|
||||
//! TOOL_MISSING_PREFIX)已抽至 [`crate::context_helpers`],本模块 `use` 复用,
|
||||
//! 并 `pub use` 重导出以保持 `df_ai::context::*` 历史路径对外可见(零调用方变更)。
|
||||
//!
|
||||
//! # 子模块
|
||||
//! - [`sanitize`]:畸形配对自愈(`sanitize_messages` / `drop_reverse_orphans` /
|
||||
//! `assert_placeholder_pairing` / `ensure_sequence_legal`)及其单测。
|
||||
//! 实现为模块级 `pub fn`,本模块通过 `ContextManager` 上的关联函数薄包装转发,
|
||||
//! 保持 `ContextManager::sanitize_messages(...)` 调用路径不变(零调用方变更)。
|
||||
|
||||
mod sanitize;
|
||||
|
||||
use crate::context_helpers::{
|
||||
classify_group, PLACEHOLDER_INTEGRITY_ENABLED, PROTECT_COUNT,
|
||||
};
|
||||
// 重导出:保持 `df_ai::context::TokenEstimator` / `df_ai::context::ContextConfig` 等
|
||||
// 历史路径对外可见(agentic.rs / commands/ai/mod.rs 等调用方零变更)。
|
||||
// `pub use` 同时把类型带入本模块命名空间,供 ContextManager 结构体字段与 impl 直接引用。
|
||||
pub use crate::context_helpers::{
|
||||
EvictionUnit, ContextConfig, MessageGroup, TokenEstimator, TrackedMessage,
|
||||
};
|
||||
|
||||
use crate::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
|
||||
// ============================================================
|
||||
// 上下文管理器
|
||||
// ============================================================
|
||||
|
||||
/// 上下文管理器
|
||||
///
|
||||
/// 唯一的消息真相来源(替代原来的 `Vec<ChatMessage>`)。
|
||||
/// 裁剪仅影响发送视图(`build_for_request`),不影响持久化(`all_messages_clone`)。
|
||||
pub struct ContextManager {
|
||||
messages: Vec<TrackedMessage>,
|
||||
/// 当前历史总 token 数(不含 system prompt)
|
||||
history_tokens: u32,
|
||||
config: ContextConfig,
|
||||
estimator: TokenEstimator,
|
||||
/// 压缩重入标志(F-15 §4.3):true 表示一次 LLM 压缩正在进行中。
|
||||
/// agentic loop 顶部检测,防同一轮内多次触发压缩互相覆盖。纯内存态,不落库。
|
||||
is_compressing: bool,
|
||||
/// [P2 改进5] 主题切换检测标记。push user 消息时若发现末两条 user 消息的 topic
|
||||
/// 都非 None 且不同(双高置信),置位本字段,格式 "old|new"。agentic loop 顶部读并
|
||||
/// 消费(insert 系统标记后清空)。纯内存态,不落库。保守:任一 topic 为 None 不置位(宁可漏报)。
|
||||
pending_topic_marker: Option<String>,
|
||||
}
|
||||
|
||||
impl ContextManager {
|
||||
pub fn new(config: ContextConfig) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
history_tokens: 0,
|
||||
config,
|
||||
estimator: TokenEstimator::default(),
|
||||
is_compressing: false,
|
||||
pending_topic_marker: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 追加消息(自动计算 token 并更新缓存)
|
||||
///
|
||||
/// 不在此处淘汰——push 可能发生在 agentic loop 中间(追加 tool_result),
|
||||
/// 此时不应裁剪正在使用的活跃消息。裁剪在 `build_for_request` 时统一处理。
|
||||
pub fn push(&mut self, message: ChatMessage) {
|
||||
let tokens = self.estimator.estimate_message(&message);
|
||||
let group = classify_group(&message);
|
||||
// 仅 active 消息计入 token 预算(F-15 §3.3):truncated / archived_segment /
|
||||
// compressed 不进 LLM 上下文,token 虚高会致 build_for_request 误判超预算
|
||||
// 触发不必要裁剪。!active 消息仍 push 到 self.messages 全量保留(持久化不受影响),
|
||||
// sanitize_messages step0(is_active 过滤)在发送视图统一剔除。
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
// [P2 改进5] 主题推断(仅 user 消息):IntentRecognizer 识别意图,置信 >= 0.7 且
|
||||
// 非 Unknown 则把 Intent 标签存入 TrackedMessage.topic 供主题切换检测。
|
||||
// 保守:低置信(None)不标,避免误报。topic 不参与裁剪/压缩(只供 marker 检测)。
|
||||
let topic: Option<String> = if matches!(message.role, MessageRole::User) {
|
||||
let (intent, conf) = crate::intent::IntentRecognizer::recognize(&message.content);
|
||||
if conf >= 0.7 && !matches!(intent, crate::intent::Intent::Unknown) {
|
||||
Some(intent.as_str().to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// [P2 改进5] 主题切换检测:push 前(本消息即将成末条 user),若已存在一条更早 user
|
||||
// 且本消息 topic 与之都非 None 且不同 → 置位 pending_topic_marker("old|new")。
|
||||
// 双高置信(两条 topic 都非 None)才标,任一 None 不标(宁可漏报不误报)。
|
||||
if matches!(message.role, MessageRole::User) {
|
||||
if let Some(prev_topic) = self.last_user_topic() {
|
||||
if let Some(this_topic) = &topic {
|
||||
if prev_topic != *this_topic {
|
||||
self.pending_topic_marker =
|
||||
Some(format!("{}|{}", prev_topic, this_topic));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.messages.push(TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic,
|
||||
});
|
||||
}
|
||||
|
||||
/// 清空所有消息
|
||||
pub fn clear(&mut self) {
|
||||
self.messages.clear();
|
||||
self.history_tokens = 0;
|
||||
self.is_compressing = false;
|
||||
self.pending_topic_marker = None;
|
||||
}
|
||||
|
||||
/// 消息数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
|
||||
/// 当前历史占用的 token 数(不含 system prompt)
|
||||
pub fn history_tokens(&self) -> u32 {
|
||||
self.history_tokens
|
||||
}
|
||||
|
||||
/// 预算上限
|
||||
pub fn budget_limit(&self) -> u32 {
|
||||
self.config.budget_limit()
|
||||
}
|
||||
|
||||
// ── 核心:构建请求消息(受控裁剪版本)──
|
||||
|
||||
/// 构建发送给 LLM 的消息列表
|
||||
///
|
||||
/// `sys_tokens` 为调用方已估算好的 system prompt token 数。
|
||||
/// 超预算时自动裁剪旧消息(保护工具调用三元组 + 最近 PROTECT_COUNT 条)。
|
||||
/// 返回 (消息列表, 是否发生了裁剪)。
|
||||
pub fn build_for_request(&self, sys_tokens: u32) -> (Vec<ChatMessage>, bool) {
|
||||
let budget = self.budget_limit();
|
||||
let available = budget.saturating_sub(sys_tokens);
|
||||
|
||||
// system prompt 自身超预算:裁剪无法缓解(仍返回保护区兜底),warn 便于诊断
|
||||
if sys_tokens > budget {
|
||||
tracing::warn!(
|
||||
"system prompt (~{} tokens) 超过上下文预算 ({}),裁剪无法缓解",
|
||||
sys_tokens, budget
|
||||
);
|
||||
}
|
||||
|
||||
// 未超预算 → 直接返回全量(仍做畸形配对自愈,防历史中毒触发 provider 500 死循环)
|
||||
if self.history_tokens <= available {
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
// 超预算 → 视图裁剪(不修改 self.messages,保证 all_messages_clone 仍返回全量)
|
||||
let protect_start = self.messages.len().saturating_sub(PROTECT_COUNT);
|
||||
let units = self.build_eviction_units(protect_start);
|
||||
|
||||
let mut removed: u64 = 0;
|
||||
let mut trim_end = 0;
|
||||
for unit in &units {
|
||||
if self.history_tokens.saturating_sub(removed as u32) <= available {
|
||||
break;
|
||||
}
|
||||
removed += unit.token_sum as u64;
|
||||
trim_end = unit.end;
|
||||
}
|
||||
|
||||
if trim_end == 0 {
|
||||
tracing::warn!(
|
||||
"history (~{} tokens) 超预算 ({}) 但无可淘汰单元(全在保护区 {} 条),发送兜底可能触发 provider 超限",
|
||||
self.history_tokens, available, PROTECT_COUNT
|
||||
);
|
||||
// B-260626-01: 兜底全量也过 sanitize(对齐分支 1/3),防绕过序列修复直送 provider
|
||||
// 触发"首条 assistant 非法"/orphan/连续 role。原裸返 all_messages_clone 不过滤
|
||||
// truncated/中毒三元组/首条非法——是主 loop 唯一的 sanitize 漏洞(大体量 tool_result
|
||||
// 致超预算且保护区满时命中)。异常会话(开头连续 assistant/tool 无 user)经
|
||||
// ensure_sequence_legal 清空后,由协议层 ensure_leading_user 补 user 占位降级,不阻塞。
|
||||
// view-only:不改 self.messages 持久化(与分支 1/3 一致)。
|
||||
let sanitized = Self::sanitize_messages(self.all_messages_clone());
|
||||
return (
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
let msgs: Vec<ChatMessage> = self.messages[trim_end..]
|
||||
.iter()
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
|
||||
tracing::info!(
|
||||
"context_trimmed: skip {} messages, ~{} tokens (view-only, full history retained)",
|
||||
trim_end, removed
|
||||
);
|
||||
let sanitized = Self::sanitize_messages(msgs);
|
||||
// 阶段2 出口断言:占位配对完整性,失败降级 TOOL_MISSING_PREFIX 自愈(防 400 orphan)
|
||||
(
|
||||
Self::assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED),
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 畸形配对自愈(转发至 [`sanitize`] 子模块,保持 ContextManager::xxx 调用路径)──
|
||||
|
||||
/// 畸形配对自愈 — 转发到 [`sanitize::sanitize_messages`]。
|
||||
///
|
||||
/// 保留为 `ContextManager` 关联函数以兼容历史调用路径(`Self::sanitize_messages` /
|
||||
/// `ContextManager::sanitize_messages`),实现见子模块文档。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
sanitize::sanitize_messages(messages)
|
||||
}
|
||||
|
||||
/// 发送视图出口断言 — 转发到 [`sanitize::assert_placeholder_pairing`]。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
sanitize::assert_placeholder_pairing(messages, enabled)
|
||||
}
|
||||
|
||||
/// 全量克隆(持久化 save_conversation / build_for_request 未裁剪分支,不受裁剪影响)
|
||||
pub fn all_messages_clone(&self) -> Vec<ChatMessage> {
|
||||
self.messages.iter().map(|t| t.message.clone()).collect()
|
||||
}
|
||||
|
||||
/// 从 Vec 恢复(兼容从 DB 加载)
|
||||
pub fn restore_from_messages(&mut self, messages: Vec<ChatMessage>) {
|
||||
self.clear();
|
||||
for msg in messages {
|
||||
self.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
/// 就地替换某条 tool_result 的内容(兼容审批 replace_tool_result)
|
||||
/// 返回 true 如果找到并替换了
|
||||
///
|
||||
/// 反向遍历:tool_result 由 append 进入历史,被替换的通常是最近的审批占位,
|
||||
/// 从尾部查找命中即停,避免对长历史做正向 O(n) 累积扫描。
|
||||
pub fn replace_tool_result_content(&mut self, tool_call_id: &str, new_content: &str) -> bool {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::Tool)
|
||||
&& t.message.tool_call_id.as_deref() == Some(tool_call_id)
|
||||
});
|
||||
|
||||
let Some(i) = pos else { return false };
|
||||
|
||||
// 先更新 content,再重估 token 并校正总量
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
true
|
||||
}
|
||||
|
||||
/// 弹出末尾连续的 assistant 消息(含其 tool_calls 三元组尾随 tool_result)
|
||||
///
|
||||
/// 用于「重新生成」(UX-02):删掉最后一条 AI 回复(可能跨多轮 tool_calls + tool_results
|
||||
/// 紧随其后),保留触发它的 user 消息,以便重跑 agentic loop 再生成。
|
||||
///
|
||||
/// 语义:从末尾向前弹出,直到弹出至少一条 assistant 消息;若弹出 assistant 后紧邻的更早
|
||||
/// 消息仍是 assistant/tool(同一轮多块),继续一并弹出,确保不留半截三元组污染下轮。
|
||||
/// user 消息作为停止边界(不弹出 user),保证重生成时历史末尾是 user 消息。
|
||||
pub fn pop_last_assistant_round(&mut self) -> bool {
|
||||
if self.messages.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut popped_any = false;
|
||||
// 从尾向前:先弹掉末尾非 user 的消息(assistant / tool),直到遇到 user 或空
|
||||
while let Some(last) = self.messages.last() {
|
||||
if matches!(last.message.role, MessageRole::User) {
|
||||
break;
|
||||
}
|
||||
let removed = self.messages.pop().expect("just checked non-empty");
|
||||
self.history_tokens = self.history_tokens.saturating_sub(removed.token_count);
|
||||
if matches!(removed.message.role, MessageRole::Assistant) {
|
||||
popped_any = true;
|
||||
}
|
||||
}
|
||||
popped_any
|
||||
}
|
||||
|
||||
/// 编辑某条 user 消息后,将其后所有消息标记为 truncated(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 软删语义:保留在内存真相源 + DB(可追溯),但 sanitize_messages 过滤后不进 LLM 上下文,
|
||||
/// 前端按 is_active 过滤从视图移除。返回被标 truncated 的条数(0 表示该 user 消息已是末尾,无后续)。
|
||||
///
|
||||
/// `target_content` 为该 user 消息的预期内容(用于反向唯一定位:末条 user 消息可能内容相同,
|
||||
/// 故从尾部向前找第一条 role=User 且 content 匹配且仍 active 的消息)。
|
||||
/// 找不到返回 Err(()),调用方据此报错。
|
||||
pub fn truncate_after_user_message(&mut self, target_content: &str) -> Result<usize, ()> {
|
||||
// 反向找末条 active user 消息且 content 匹配
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User)
|
||||
&& t.message.content == target_content
|
||||
&& t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
// i 之后的全部标 truncated(已 truncated 的跳过,只统计本次新标的)
|
||||
let mut count = 0usize;
|
||||
for t in self.messages[i + 1..].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some(MessageStatus::Truncated);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 替换末条 active user 消息的 content(UX-09 编辑重生成)。
|
||||
///
|
||||
/// 编辑语义:只能编辑最后一条 user 消息(中间编辑语义复杂,拒绝)。返回 Err(()) 表示无 active user 消息。
|
||||
/// 成功后调用方应紧接着 truncate_after_user_message(new_content) 软删其后续消息。
|
||||
pub fn replace_last_active_user_content(&mut self, new_content: &str) -> Result<(), ()> {
|
||||
let pos = self.messages.iter().rposition(|t| {
|
||||
matches!(t.message.role, MessageRole::User) && t.message.is_active()
|
||||
});
|
||||
let Some(i) = pos else { return Err(()) };
|
||||
let old_tokens = self.messages[i].token_count;
|
||||
self.messages[i].message.content = new_content.to_string();
|
||||
let new_tokens = self.estimator.estimate_message(&self.messages[i].message);
|
||||
self.messages[i].token_count = new_tokens;
|
||||
self.history_tokens = self.history_tokens.saturating_sub(old_tokens).saturating_add(new_tokens);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 只读迭代(兼容 ensure_conversation_title 的 .iter().filter() 等)
|
||||
pub fn iter(&self) -> impl Iterator<Item = &ChatMessage> {
|
||||
self.messages.iter().map(|t| &t.message)
|
||||
}
|
||||
|
||||
/// F-260619-04 P1 消息级溯源:取末条指定 role 消息的 id(ULID)。
|
||||
///
|
||||
/// 从尾部反向扫描(末条消息命中即停,避免全量 O(n) 正扫累积),返回最近一条
|
||||
/// `role` 匹配且 `id` 非空消息的 id。无匹配或老消息无 id → None(向前兼容:
|
||||
/// 老反序列化消息 id=None,溯源写入降级为 None,展示侧兼容 `conv:` 旧格式)。
|
||||
///
|
||||
/// 用途:
|
||||
/// - `MessageRole::Assistant`:audit/知识提炼写入时取当前 assistant 消息 id
|
||||
/// (LLM 返回带 tool_calls 的 assistant 已 push,process_tool_calls 入口取)
|
||||
/// - `MessageRole::User`:知识注入 referenced 事件溯源取触发检索的 user 消息 id
|
||||
fn last_message_id_by_role(&self, role: MessageRole) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| {
|
||||
std::mem::discriminant(&t.message.role) == std::mem::discriminant(&role)
|
||||
})
|
||||
.and_then(|t| t.message.id.clone())
|
||||
}
|
||||
|
||||
/// 末条 assistant 消息的 id(消息级溯源用)。
|
||||
pub fn last_assistant_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::Assistant)
|
||||
}
|
||||
|
||||
/// 末条 user 消息的 id(消息级溯源用)。
|
||||
pub fn last_user_message_id(&self) -> Option<String> {
|
||||
self.last_message_id_by_role(MessageRole::User)
|
||||
}
|
||||
|
||||
// ── [P2 改进5] 主题切换检测(保守,双高置信才标) ──
|
||||
|
||||
/// 取末条 user 消息的 topic 标签(供 push 时主题切换检测)。
|
||||
///
|
||||
/// 从尾部反向扫描 user 消息,取最近一条 role=User 的 TrackedMessage.topic。
|
||||
/// 老消息(未接改进5 推断)topic=None → 返 None(向前兼容)。无 user 消息 → None。
|
||||
fn last_user_topic(&self) -> Option<String> {
|
||||
self.messages
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|t| matches!(t.message.role, MessageRole::User))
|
||||
.and_then(|t| t.topic.clone())
|
||||
}
|
||||
|
||||
/// 取并消费 pending_topic_marker(供 agentic loop 顶部读 → insert 系统标记 → 清空)。
|
||||
///
|
||||
/// 返回 "old|new" 格式字符串(push 时末两条 user topic 都非 None 且不同置位)。
|
||||
/// 取出即清空(一次性消费,防同 marker 重复 insert)。无 marker → None。
|
||||
pub fn take_topic_marker(&mut self) -> Option<String> {
|
||||
self.pending_topic_marker.take()
|
||||
}
|
||||
|
||||
// ── F-15 上下文管理增强辅助方法(压缩链路核心 API)──
|
||||
//
|
||||
// 已全量接入压缩链路:
|
||||
// - agentic/mod.rs 自动压缩(trigger 判定 + LLM/关键词兜底 + 重入保护)
|
||||
// set_compressing/is_compressing(重入标志) / has_compressible_messages(触发判定)
|
||||
// messages_mut(取 active 喂 LLM) / compress_old_messages(标 compressed 扣 token)
|
||||
// insert_at(摘要/续接锚点 system 消息插入首位)
|
||||
// - commands/ai/compress.rs 与 commands/ai/commands/chat.rs 走 IPC 压缩入口
|
||||
// - build_eviction_units(下方)供会话分段与压缩定位共用同一分组逻辑。
|
||||
|
||||
/// 配置(只读视图,供 agentic.rs 计算压缩触发阈值 `config().budget_limit()`)
|
||||
pub fn config(&self) -> &ContextConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
/// 可变消息切片(供阶段2 标记 status="compressed"/"archived_segment" + 调整 token)
|
||||
///
|
||||
/// 调用方约定:仅改 `message.status` / `message.content`,不增删条目(增删走
|
||||
/// [`push`] / [`insert_at`]),否则 `history_tokens` 会与实际脱钩。
|
||||
pub fn messages_mut(&mut self) -> &mut [TrackedMessage] {
|
||||
&mut self.messages
|
||||
}
|
||||
|
||||
/// 在给定位置插入一条消息(其余向后移),并把它计入 token 预算(active 才计)。
|
||||
///
|
||||
/// 供阶段2 在压缩点插入摘要 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);
|
||||
let group = classify_group(&message);
|
||||
if message.is_active() {
|
||||
self.history_tokens += tokens;
|
||||
}
|
||||
self.messages.insert(index, TrackedMessage {
|
||||
message,
|
||||
token_count: tokens,
|
||||
group,
|
||||
topic: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// 按淘汰单元分组消息范围(三元组原子性),供压缩定位/分段标记复用同一分组逻辑。
|
||||
///
|
||||
/// 返回每个单元的右开区间 end + token 总和,保证:
|
||||
/// - 工具调用三元组(Head + Tail* + 紧随的 Standalone Assistant)在同一单元
|
||||
/// - 保护区 `[protect_start, len)` 内的消息不纳入任何单元
|
||||
///
|
||||
/// 公开供阶段2 会话分段(`archived_segment` 按组原子标记)与压缩定位共用。
|
||||
pub fn build_eviction_units(&self, protect_start: usize) -> Vec<EvictionUnit> {
|
||||
let mut units = Vec::new();
|
||||
let mut i = 0usize;
|
||||
|
||||
while i < protect_start {
|
||||
let mut token_sum = 0u32;
|
||||
|
||||
if self.messages[i].group == MessageGroup::ToolCallHead {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
while i < protect_start && self.messages[i].group == MessageGroup::ToolResultTail {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
if i < protect_start
|
||||
&& self.messages[i].group == MessageGroup::Standalone
|
||||
&& matches!(self.messages[i].message.role, MessageRole::Assistant)
|
||||
{
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
token_sum += self.messages[i].token_count;
|
||||
i += 1;
|
||||
}
|
||||
|
||||
units.push(EvictionUnit { end: i, token_sum });
|
||||
}
|
||||
|
||||
units
|
||||
}
|
||||
|
||||
/// 保护区外是否存在可压缩消息(供 agentic loop 顶部触发判断)。
|
||||
///
|
||||
/// "可压缩"= status 为 None/active 的消息(已 compressed/archived_segment/truncated
|
||||
/// 不参与二次压缩,幂等)。`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 角色(压缩摘要 / 话题切换锚点)。这些是上下文锚点非压缩目标——
|
||||
// 若计入,压缩摘要 insert_at(0) 落在可压缩区 [0..protect_start) 且 is_active(status=None),
|
||||
// 致每轮 has_compressible 恒 true → 无限循环压缩(用户报"压缩后每轮提示已压缩并停止")。
|
||||
// compress_old_messages 不改:被调用时仍标旧 system 摘要 compressed(被新摘要替代,防堆积)。
|
||||
self.messages[..end]
|
||||
.iter()
|
||||
.any(|t| t.message.is_active() && !matches!(t.message.role, MessageRole::System))
|
||||
}
|
||||
|
||||
/// 把保护区 `[0, compress_end)` 范围内的 active 消息标记为 `status="compressed"`,
|
||||
/// 同步从 `history_tokens` 扣除其 token,返回被压缩消息的克隆(供阶段2 喂 LLM 摘要)。
|
||||
///
|
||||
/// **幂等**:已 compressed(或任何 !active)的消息跳过,不会被二次压缩;`history_tokens`
|
||||
/// 也只扣首次标记的 token。返回的 Vec 仅含**本次新标记**的消息(已 compressed 的不返)。
|
||||
///
|
||||
/// **单向不可逆**:压缩后 DB 原始消息保留,但 LLM 上下文里被 is_active 白名单隔离
|
||||
/// (sanitize step0 过滤)。`compress_end` 越界自动 clamp 到 `messages.len()`。
|
||||
///
|
||||
/// 返回空 Vec 表示本批次无可压缩消息(全部已 compressed 或范围空),调用方据此跳过 LLM 调用。
|
||||
pub fn compress_old_messages(&mut self, compress_end: usize) -> Vec<ChatMessage> {
|
||||
let end = compress_end.min(self.messages.len());
|
||||
let mut newly_compressed = Vec::new();
|
||||
for t in self.messages[..end].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
newly_compressed.push(t.message.clone());
|
||||
t.message.status = Some(MessageStatus::Compressed);
|
||||
self.history_tokens = self.history_tokens.saturating_sub(t.token_count);
|
||||
}
|
||||
}
|
||||
newly_compressed
|
||||
}
|
||||
|
||||
/// 压缩重入标志(读)。true 表示一次 LLM 压缩正在进行中,触发方应跳过本轮压缩。
|
||||
pub fn is_compressing(&self) -> bool {
|
||||
self.is_compressing
|
||||
}
|
||||
|
||||
/// 压缩重入标志(写)。`true`=开始压缩(进入 agentic loop 顶部前置置位),
|
||||
/// `false`=压缩结束(无论成功或降级)。调用方必须成对调用,防止永久卡死。
|
||||
pub fn set_compressing(&mut self, v: bool) {
|
||||
self.is_compressing = v;
|
||||
}
|
||||
|
||||
// build_eviction_units / classify_group 等已在上方公开或文件级定义。
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod manager_tests;
|
||||
761
crates/df-ai/src/context/sanitize.rs
Normal file
761
crates/df-ai/src/context/sanitize.rs
Normal file
@@ -0,0 +1,761 @@
|
||||
//! 畸形配对自愈 — 过滤掉会导致 provider 400/500 的中毒历史。
|
||||
//!
|
||||
//! 本模块为 `context::ContextManager` 的 sanitize 链路实现层,由 `mod.rs` 中
|
||||
//! `ContextManager` 的关联函数薄包装转发调用(保持 `ContextManager::sanitize_messages`
|
||||
//! 等历史调用路径不变)。
|
||||
//!
|
||||
//! # 函数概览
|
||||
//! - [`sanitize_messages`]:主入口(step0 active 过滤 → step1/2/3 正向 orphan →
|
||||
//! step3.5 反向 orphan → step4 序列合法性)。
|
||||
//! - [`drop_reverse_orphans`]:step3.5 反向 orphan 检测。
|
||||
//! - [`assert_placeholder_pairing`]:发送视图出口断言(补占位头自愈)。
|
||||
//! - [`ensure_sequence_legal`]:step4 序列合法性修复(首条 user + 连续同 role 合并)。
|
||||
|
||||
use crate::context_helpers::{is_pending_placeholder, PLACEHOLDER_INTEGRITY_ENABLED, TOOL_MISSING_PREFIX};
|
||||
use crate::provider::{ChatMessage, MessageRole, ToolCall};
|
||||
|
||||
/// 畸形配对自愈 — 过滤掉会导致 provider 500 的中毒历史
|
||||
///
|
||||
/// 根因:Anthropic 流式 `tool_use` 块缺 id 时(anthropic_compat.rs 170-176)
|
||||
/// 生成 `tool_missing_{idx}` 占位 id;对应的 `tool_result` 一旦推入历史,
|
||||
/// 下次 `build_for_request` 把畸形三元组原样回传 → 服务端 500 → stream_recv
|
||||
/// 不清历史 → agentic 重发 → 永久卡死。此处仅在「发送视图」剔除畸形配对,
|
||||
/// 不改持久化(self.messages 全量保留),让卡死的会话能自愈继续。
|
||||
///
|
||||
/// provider 协议铁律:assistant 头里每个 tool_call.id 都必须在后续有对应的
|
||||
/// tool_result,否则服务端 400/500。本函数按此自愈,分三档处理每个头:
|
||||
///
|
||||
/// - 全闭合:所有 tool_call.id 都有匹配 tool_result → 原样保留。
|
||||
/// - 全未闭合(含占位 id 必然无匹配):整头丢弃,其无主 tool_result 一并丢弃。
|
||||
/// - 部分闭合:保留头但只留已闭合的 tool_call,丢弃未闭合的 tool_call 及其
|
||||
/// 无主 tool_result(「不能整条删」——保住已发生的合法工具交互历史)。
|
||||
///
|
||||
/// 单次遍历、保序过滤、不重排(user/assistant/tool 角色交替不被打乱)。
|
||||
pub fn sanitize_messages(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
use std::collections::HashSet;
|
||||
|
||||
// step 0(UX-09):过滤 truncated 软删消息,不进 LLM 上下文。
|
||||
// 编辑某条 user 消息后其后续消息标 truncated(保留 DB 可追溯),发送视图必须剔除,
|
||||
// 否则被编辑前的旧回复仍进入 LLM 历史,污染重生成语义。落库全量保留不受影响。
|
||||
let messages: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| m.is_active())
|
||||
.collect();
|
||||
|
||||
// step 1:已闭合的 tool_call_id 集合(role=Tool 消息全部提供过的 id)
|
||||
let resolved_ids: HashSet<&str> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.as_deref())
|
||||
.collect();
|
||||
|
||||
// step 2:判定每个 assistant 头的闭合状态,产出需丢弃的 id 集合
|
||||
// orphaned_ids:未闭合的 tool_call.id(含占位 id),其 tool_result 要丢
|
||||
// partial_heads:部分闭合的头——保留但需重写 tool_calls(只留已闭合的)
|
||||
// full_drop_heads:全未闭合的头——整条丢弃
|
||||
let mut orphaned_ids: HashSet<String> = HashSet::new();
|
||||
let mut full_drop_heads = 0u32;
|
||||
let mut partial_heads = 0u32;
|
||||
// 记录部分闭合头中「应保留的 id」,用于 step3 精确重写
|
||||
let mut partial_keep: std::collections::HashMap<usize, Vec<ToolCall>> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
for (i, m) in messages.iter().enumerate() {
|
||||
if !matches!(m.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(calls) = m.tool_calls.as_ref() else { continue };
|
||||
if calls.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (resolved, unresolved): (Vec<&ToolCall>, Vec<&ToolCall>) = calls
|
||||
.iter()
|
||||
.partition(|c| {
|
||||
!c.id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& resolved_ids.contains(c.id.as_str())
|
||||
});
|
||||
|
||||
match (resolved.is_empty(), unresolved.is_empty()) {
|
||||
// 全未闭合 → 整头丢弃,未闭合 id 入 orphaned_ids
|
||||
(true, false) => {
|
||||
full_drop_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
}
|
||||
// 全闭合 → 原样保留
|
||||
(false, true) => {}
|
||||
// 部分闭合 → 保留头,只留 resolved 的 tool_call,unresolved 入 orphaned_ids
|
||||
(false, false) => {
|
||||
partial_heads += 1;
|
||||
for c in &unresolved {
|
||||
orphaned_ids.insert(c.id.clone());
|
||||
}
|
||||
partial_keep.insert(i, resolved.into_iter().cloned().collect());
|
||||
}
|
||||
// resolved/unresolved 都空不可能(calls 非空已保证)
|
||||
(true, true) => {}
|
||||
}
|
||||
}
|
||||
|
||||
let after_triplet: Vec<ChatMessage> = if orphaned_ids.is_empty() && partial_keep.is_empty() {
|
||||
messages
|
||||
} else {
|
||||
// step 3:保序过滤 + 部分闭合头重写 tool_calls
|
||||
let sanitized: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, mut m)| match m.role {
|
||||
MessageRole::Assistant => {
|
||||
let Some(calls) = m.tool_calls.as_ref() else { return Some(m) };
|
||||
if calls.is_empty() {
|
||||
return Some(m);
|
||||
}
|
||||
if let Some(keep) = partial_keep.get(&i) {
|
||||
// 部分闭合:重写 tool_calls 为仅已闭合子集
|
||||
m.tool_calls = Some(keep.clone());
|
||||
Some(m)
|
||||
} else {
|
||||
// 全闭合:保留;全未闭合(id 全在 orphaned_ids):丢弃
|
||||
let all_orphaned =
|
||||
calls.iter().all(|c| orphaned_ids.contains(&c.id));
|
||||
if all_orphaned {
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
MessageRole::Tool => {
|
||||
// 无主 tool_result(其 id 命中 orphaned_ids)丢弃,其余保留
|
||||
if m
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.is_some_and(|id| orphaned_ids.contains(id))
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(m)
|
||||
}
|
||||
}
|
||||
_ => Some(m),
|
||||
})
|
||||
.collect();
|
||||
|
||||
tracing::warn!(
|
||||
full_drop_heads,
|
||||
partial_rewrite_heads = partial_heads,
|
||||
orphaned_tool_results = orphaned_ids.len(),
|
||||
"history sanitized: dropped/rewrote malformed tool_call triplets (view-only, persisted history untouched)"
|
||||
);
|
||||
sanitized
|
||||
};
|
||||
|
||||
// step 3.5(阶段2 占位配对完整性):反向 orphan 检测 —— tool_result 无对应 tool_call 头 → 丢。
|
||||
//
|
||||
// 根因(解 400 orphan):审批挂起占位 tool_result(内容 audit/cache.rs:PENDING_APPROVAL_PLACEHOLDER)
|
||||
// 经 step3(正向 orphan:头无 result→丢头 + 其 result)或 build_eviction_units(预算裁剪从三元组
|
||||
// 边界 trim)后,可能出现 tool_result 残留但其 tool_call 头已被丢弃 → orphan tool_result(无头)。
|
||||
// deepseek-v4-pro 等端点对此严格校验 → 400。
|
||||
//
|
||||
// 检测:收集所有保留的 assistant 头的 tool_call.id 集合(head_ids),tool_result 的 id 不在
|
||||
// head_ids 内即 orphan → 丢。与 step2/3 互补:step2/3 管"头丢 result",step3.5 管"result 丢头"。
|
||||
//
|
||||
// **占位保护**:带 PENDING_MARKER_PREFIX 标记的审批占位 tool_result,虽其头被丢,仍需保留——
|
||||
// 占位语义是"等用户审批",LLM 需看到它才知道在等审批。故对占位 result 不做反向 orphan 丢弃,
|
||||
// 改由出口断言(build_for_request 出口)自愈补头(见 assert_placeholder_pairing)。
|
||||
// 老占位(无 __PENDING__ 标记,纯文本 LEGACY_PENDING_PLACEHOLDER_TEXT)同样豁免保留
|
||||
// (is_pending_placeholder 精确等值匹配老占位全文),虽无 tc_id 无法强绑定补头,但保留后
|
||||
// 出口断言仍能据其 tool_call_id 补占位头闭合三元组(向前兼容迁移期老数据)。
|
||||
let after_reverse_orphan = if PLACEHOLDER_INTEGRITY_ENABLED {
|
||||
drop_reverse_orphans(after_triplet)
|
||||
} else {
|
||||
after_triplet
|
||||
};
|
||||
|
||||
// step 4:序列合法性修复(首条 user + 连续同 role 合并),防 Anthropic/GLM 1214。
|
||||
ensure_sequence_legal(after_reverse_orphan)
|
||||
}
|
||||
|
||||
/// step 3.5:反向 orphan 检测(view-only)—— 丢弃无对应 tool_call 头的 tool_result。
|
||||
///
|
||||
/// 详见 [`sanitize_messages`] step3.5 注释。占位 result(带 PENDING_MARKER_PREFIX 标记)豁免
|
||||
/// (保留,出口断言自愈补头),其余 tool_result 的 id 不在任何保留头 tool_calls 内 → 丢。
|
||||
pub fn drop_reverse_orphans(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
// 收集所有保留 assistant 头的 tool_call.id(正向 orphan 处理后残留的头里的 id)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
let mut dropped = 0u32;
|
||||
let mut placeholder_kept = 0u32;
|
||||
let filtered: Vec<ChatMessage> = messages
|
||||
.into_iter()
|
||||
.filter(|m| {
|
||||
if !matches!(m.role, MessageRole::Tool) {
|
||||
return true;
|
||||
}
|
||||
let Some(id) = m.tool_call_id.as_deref() else {
|
||||
return true; // 无 id 的 tool_result(异常数据),不在此处处理
|
||||
};
|
||||
if head_ids.contains(id) {
|
||||
return true; // 有对应头 → 保留
|
||||
}
|
||||
// 无对应头:占位(带标记)豁免保留(出口断言自愈补头);非占位 → 丢
|
||||
if is_pending_placeholder(&m.content) {
|
||||
placeholder_kept += 1;
|
||||
true
|
||||
} else {
|
||||
dropped += 1;
|
||||
false
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
if dropped > 0 || placeholder_kept > 0 {
|
||||
tracing::warn!(
|
||||
dropped_reverse_orphan_tool_results = dropped,
|
||||
placeholder_kept_without_head = placeholder_kept,
|
||||
"history sanitized: dropped orphan tool_results without matching tool_call head (view-only, persisted history untouched)"
|
||||
);
|
||||
}
|
||||
filtered
|
||||
}
|
||||
|
||||
/// 发送视图出口断言(阶段2 占位配对完整性):确保所有 tool_result(含审批占位)都有
|
||||
/// 对应 tool_call 头,失败降级 TOOL_MISSING_PREFIX 自愈。
|
||||
///
|
||||
/// **职责**:在消息即将发给 LLM 前(`build_for_request` 出口)最后一道防线:若仍有
|
||||
/// orphan tool_result(sanitize step3.5 豁免保留的占位 result,其头在 step3/裁剪中被丢),
|
||||
/// 则**就地补一个 tool_missing_ 占位头**塞在 tool_result 前,使其配对闭合。
|
||||
///
|
||||
/// **未接入出口**:`compress_via_llm`(src-tauri 侧 F-15 压缩)与 agentic loop 主循环也属
|
||||
/// "发给 LLM 前"语义,但二者均在 src-tauri crate(非 df-ai),当前未调本函数——如需对压缩
|
||||
/// 后消息同样补头,由调用方在拿到压缩结果后自行调本函数闭合(本 crate 不强加跨 crate 依赖)。
|
||||
///
|
||||
/// 自愈原理:占位头带 TOOL_MISSING_PREFIX id,该 id 必然无"真"匹配,但与紧跟的 orphan
|
||||
/// tool_result(同 id)构成闭合三元组(头 + result 都在,id 一致)→ 满足 provider 协议
|
||||
/// "每个 tool_call.id 必须有 tool_result"的铁律,不再 400。占位头的 id 用 tool_result 的
|
||||
/// tool_call_id 复用(而非 tool_missing_{idx} 自增),保证与 result 精确配对。
|
||||
///
|
||||
/// **view-only**:本函数不改持久化,仅修改传入 Vec(clone),持久化全量保留。
|
||||
/// 占位头补在 orphan result **正前方**(保序,角色交替 assistant→tool 合法),content 给空串
|
||||
/// (LLM 看 assistant 头无文本内容但有 tool_calls 即理解"调了工具")。
|
||||
///
|
||||
/// `enabled` 参数由调用方传入(常量 flag),false 时直接原样返回(旧行为,flag 关回退)。
|
||||
pub fn assert_placeholder_pairing(
|
||||
messages: Vec<ChatMessage>,
|
||||
enabled: bool,
|
||||
) -> Vec<ChatMessage> {
|
||||
if !enabled {
|
||||
return messages;
|
||||
}
|
||||
// 收集现有头的 tool_call.id(已配对的)
|
||||
use std::collections::HashSet;
|
||||
let head_ids: HashSet<String> = messages
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.map(|c| c.id.clone())
|
||||
.collect();
|
||||
|
||||
// 找 orphan tool_result(id 不在 head_ids)。占位/非占位都需补头(否则 400)。
|
||||
// 单次遍历产出新 Vec:orphan result 前插一个占位头(同 id),其余原样保留保序。
|
||||
let mut healed = 0u32;
|
||||
let mut out: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages.into_iter() {
|
||||
if matches!(m.role, MessageRole::Tool) {
|
||||
if let Some(id) = m.tool_call_id.as_deref() {
|
||||
if !head_ids.contains(id) && !id.starts_with(TOOL_MISSING_PREFIX) {
|
||||
// orphan result → 前补占位头(TOOL_MISSING_PREFIX + 原 id,确保唯一且与 result 配对)
|
||||
let head_id = format!("{}{}", TOOL_MISSING_PREFIX, id);
|
||||
let head = ChatMessage::assistant_with_tools(
|
||||
String::new(),
|
||||
vec![ToolCall::new(head_id.clone(), "pending_approval_placeholder", "{}")],
|
||||
);
|
||||
out.push(head);
|
||||
// 把 result 的 tool_call_id 也改写成占位头 id,使三者精确配对
|
||||
let mut result = m;
|
||||
result.tool_call_id = Some(head_id);
|
||||
out.push(result);
|
||||
healed += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(m);
|
||||
}
|
||||
if healed > 0 {
|
||||
tracing::warn!(
|
||||
healed_orphan_tool_results = healed,
|
||||
"send-view exit: healed orphan tool_results by inserting TOOL_MISSING_PREFIX heads (view-only, persisted history untouched)"
|
||||
);
|
||||
// 加固(连续 assistant→400):补占位头可能插在普通 assistant 之后,产生连续 assistant
|
||||
// (普通 assistant + 占位 assistant + tool_result)。Anthropic/GLM 协议对连续同 role
|
||||
// 敏感,会 400/1214。ensure_sequence_legal 在 sanitize 末尾已跑过(早于本函数),无法捕获
|
||||
// 本函数新插入的头。故补头后再过一次 ensure_sequence_legal:合并连续 assistant(其
|
||||
// tool_calls 合并入前一头,占位头 id 与 result id 一致,合并后配对仍闭合) + 防补头
|
||||
// 致首条变 assistant(理论上 orphan result 前必有 user/assistant,但防御性兜底)。
|
||||
//
|
||||
// view-only:out 是 clone,ensure_sequence_legal 仅改本 Vec,不改持久化。
|
||||
return ensure_sequence_legal(out);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// step 4:序列合法性修复(Anthropic/GLM Messages API 协议铁律,view-only 不改持久化)。
|
||||
///
|
||||
/// 协议要求首条必须是 user(system 由 convert_request 抽顶层)。sanitize step0(is_active 过滤)
|
||||
/// 与超预算裁剪(build_eviction_units 从三元组边界 trim)可能使首条变成 assistant/tool_result
|
||||
/// (开头 user 被裁/滤)→ 触发端点 1214「messages 参数非法」。
|
||||
///
|
||||
/// 修复:丢弃开头的 assistant/tool 消息(无前置 user 的孤儿,发也非法),直到首个 user/system。
|
||||
/// 注:连续同 role(user/user、assistant/assistant)现实极少——裁剪按三元组原子保护不产生连续 user,
|
||||
/// archived/compressed 过滤后由摘要 system 占位——故本轮不合并(合并会破坏裁剪保护区语义 + 改变条数,
|
||||
/// 致 over_budget_trims_old 等测试失败)。若运行时日志显示连续 role 也是 1214 来源,再补合并。
|
||||
pub fn ensure_sequence_legal(messages: Vec<ChatMessage>) -> Vec<ChatMessage> {
|
||||
let mut skipped = 0u32;
|
||||
let mut merged = 0u32;
|
||||
let mut fixed: Vec<ChatMessage> = Vec::with_capacity(messages.len());
|
||||
for m in messages {
|
||||
// 首条必须 user:skip 开头 assistant/tool(无前置 user 的孤儿)
|
||||
if fixed.is_empty() && matches!(m.role, MessageRole::Assistant | MessageRole::Tool) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
// 连续同 role 合并(user content;assistant content+tool_calls;Tool 不合并——
|
||||
// 连续 tool_result 由 anthropic_compat flush_tool_results 合并成 user blocks,此处合会丢 id)
|
||||
if let Some(last) = fixed.last_mut() {
|
||||
let same_role = std::mem::discriminant(&last.role) == std::mem::discriminant(&m.role);
|
||||
if same_role && matches!(m.role, MessageRole::User | MessageRole::Assistant) {
|
||||
if !m.content.is_empty() {
|
||||
if !last.content.is_empty() {
|
||||
last.content.push('\n');
|
||||
}
|
||||
last.content.push_str(&m.content);
|
||||
}
|
||||
if matches!(m.role, MessageRole::Assistant) {
|
||||
if let Some(calls) = m.tool_calls {
|
||||
last.tool_calls.get_or_insert_with(Vec::new).extend(calls);
|
||||
}
|
||||
}
|
||||
merged += 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
fixed.push(m);
|
||||
}
|
||||
if skipped > 0 || merged > 0 {
|
||||
tracing::warn!(
|
||||
skipped,
|
||||
merged,
|
||||
"序列修复:skip 开头非 user + 合并连续同 role(view-only,避免 Anthropic/GLM 1214)"
|
||||
);
|
||||
}
|
||||
fixed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::context::ContextManager;
|
||||
use crate::context_helpers::ContextConfig;
|
||||
|
||||
fn cfg(max_tokens: u32) -> ContextConfig {
|
||||
ContextConfig {
|
||||
max_tokens,
|
||||
output_reserve: 0,
|
||||
safety_ratio: 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_placeholder_id_triplet() {
|
||||
// 中毒场景:assistant 头带 tool_missing_ 占位 id + 无主 tool_result
|
||||
// → build_for_request 必须剔除两者,否则下次回传触发 provider 500 死循环
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("调用工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("tool_missing_0", "read_file", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 中毒的 head + 无主 tool_result 全部剔除,只剩首条 user
|
||||
assert_eq!(msgs.len(), 1, "占位 id 三元组应被 sanitize 剔除");
|
||||
assert_eq!(msgs[0].content, "调用工具");
|
||||
// 持久化全量保留(自愈只改发送视图)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_unresolved_tool_call_head() {
|
||||
// 真实 id 但全无 tool_result(流式中断/异常)→ 全未闭合档,整头丢弃
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 两个 call 都无 result → 整头丢弃
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert!(msgs.iter().all(|m| !matches!(m.role, MessageRole::Assistant)
|
||||
|| m.tool_calls.as_ref().is_none_or(|c| c.is_empty())),
|
||||
"全未闭合的 assistant 头应被剔除");
|
||||
assert!(msgs.is_empty(), "全未闭合应整条删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 1, "全量保留");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_partial_resolve_rewrites_head() {
|
||||
// 多 tool_call,部分有匹配部分无 → 保留头但只留已闭合的,丢弃无主 result
|
||||
// provider 协议铁律:head 的每个 tool_call.id 必须都有 tool_result。
|
||||
// 「不能整条删」——保住已发生的合法工具交互(call_b)历史。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("触发工具"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}"), ToolCall::new("call_b", "fn_b", "{}")],
|
||||
));
|
||||
// 只有 call_b 有 result → call_a 未闭合
|
||||
mgr.push(ChatMessage::tool_result("call_b", "b 结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 头被保留(不是整条删),但 tool_calls 重写为只剩已闭合的 call_b
|
||||
let assistant_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.collect();
|
||||
assert_eq!(assistant_heads.len(), 1, "部分闭合头应保留(重写不删)");
|
||||
let head_calls = assistant_heads[0].tool_calls.as_ref().unwrap();
|
||||
assert_eq!(head_calls.len(), 1, "重写后只保留 1 个已闭合 tool_call");
|
||||
assert_eq!(head_calls[0].id, "call_b", "保留的应是已闭合的 call_b");
|
||||
assert!(
|
||||
head_calls.iter().all(|c| c.id != "call_a"),
|
||||
"未闭合的 call_a 应从重写后的 tool_calls 中移除"
|
||||
);
|
||||
|
||||
// tool_result 只剩 call_b 的,user 前置保留
|
||||
let tool_msgs: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(tool_msgs.len(), 1, "只保留 call_b 的 tool_result");
|
||||
assert_eq!(tool_msgs[0].tool_call_id.as_deref(), Some("call_b"));
|
||||
assert!(
|
||||
msgs.iter()
|
||||
.any(|m| matches!(m.role, MessageRole::User) && m.content == "触发工具"),
|
||||
"无关 user 消息不应被误删"
|
||||
);
|
||||
assert_eq!(mgr.all_messages_clone().len(), 3, "sanitize 不应污染内存全量");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_well_formed_triplet() {
|
||||
// 正常三元组:user → head(tool_call 有匹配 tool_result) → tool_result,不被剔除。
|
||||
// 首条必须是 user(step4 Anthropic 协议修复),故前置一条 user 消息。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_a", "fn_a", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_a", "结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
assert_eq!(msgs.len(), 3, "正常三元组不应被 sanitize 剔除");
|
||||
}
|
||||
|
||||
// ── 阶段2 占位配对完整性(解 400 orphan):反向 orphan 检测 + 出口自愈 ──
|
||||
|
||||
#[test]
|
||||
fn sanitize_drops_reverse_orphan_tool_result() {
|
||||
// 反向 orphan:tool_result 无对应 tool_call 头(头被裁/丢)→ sanitize step3.5 丢弃。
|
||||
// 非 pending 占位(普通 tool_result)直接丢,防 provider 400 orphan。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("orphan_id", "孤儿结果无头"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
assert!(
|
||||
sanitized.iter().all(|m| !matches!(m.role, MessageRole::Tool)),
|
||||
"无头的普通 tool_result 应被反向 orphan 检测丢弃, 实际 {:?}",
|
||||
sanitized
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_keeps_reverse_orphan_pending_placeholder() {
|
||||
// 反向 orphan 但内容是 pending 占位(带 __PENDING__ 标记)→ step3.5 豁免保留
|
||||
// (占位语义"等审批",出口断言自愈补头)。验证占位保护不误丢。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_1";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("call_pending_1", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept: Vec<_> = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.collect();
|
||||
assert_eq!(kept.len(), 1, "pending 占位 tool_result 应豁免保留,不丢");
|
||||
assert_eq!(kept[0].tool_call_id.as_deref(), Some("call_pending_1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_heals_orphan_pending_placeholder_via_exit_assert() {
|
||||
// 端到端:pending 占位 tool_result(无头)经 sanitize 保留 → 出口断言补 TOOL_MISSING_PREFIX
|
||||
// 头自愈 → 发送视图含闭合三元组(占位头 + 占位 result,id 配对)。防 400 orphan。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_2";
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::tool_result("call_pending_2", placeholder_content));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 出口断言补了占位头 → 必有 assistant 头含 tool_missing_ 前缀 id
|
||||
let healed_heads: Vec<_> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.collect();
|
||||
assert_eq!(healed_heads.len(), 1, "出口断言应补 1 个 TOOL_MISSING_PREFIX 占位头");
|
||||
// 占位头 id 应含原 tool_call_id(精确配对)
|
||||
assert!(
|
||||
healed_heads[0].id.contains("call_pending_2"),
|
||||
"补的头 id 应含原 tool_call_id, 实际 {}",
|
||||
healed_heads[0].id
|
||||
);
|
||||
// result 的 tool_call_id 也被改写为占位头 id(三者精确配对)
|
||||
let result_id = msgs
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert_eq!(result_id, healed_heads[0].id, "result id 应与补的头 id 一致(闭合配对)");
|
||||
// 持久化全量保留(view-only)
|
||||
assert_eq!(mgr.all_messages_clone().len(), 2, "自愈不应污染持久化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_for_request_keeps_well_formed_triplet_under_placeholder_integrity() {
|
||||
// 回归:启用占位配对完整性后,正常闭合三元组不被误补头(无 orphan)。
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("问题"));
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调",
|
||||
vec![ToolCall::new("call_ok", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("call_ok", "正常结果"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
// 无 tool_missing_ 头(正常三元组不需自愈)
|
||||
let missing_heads = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with("tool_missing_"))
|
||||
.count();
|
||||
assert_eq!(missing_heads, 0, "正常闭合三元组不应被补占位头");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn assert_placeholder_pairing_no_consecutive_assistant_after_head_insert() {
|
||||
// 加固(连续 assistant→400):补占位头插在 orphan tool_result 正前方,若 orphan result 的
|
||||
// 前一条恰是 assistant(无 tool_result 隔开),则补头后产生**连续 assistant**
|
||||
// (前 assistant + 占位 assistant + tool_result)→ Anthropic/GLM 协议 400/1214。
|
||||
// 出口断言补头后必须再过一次 ensure_sequence_legal 合并连续 assistant,使其不触发 provider 拒绝。
|
||||
//
|
||||
// 构造真正触发连续 assistant 的序列:user → 纯文本 assistant(无 tool_calls) →
|
||||
// orphan pending 占位 tool_result(call_pending,无头)。占位 result 经 sanitize step3.5
|
||||
// 豁免保留(带 __PENDING__),出口断言在 result 正前方补占位头(其前驱正是 assistant)
|
||||
// → 补后序列 user → assistant(纯文本) → assistant(占位头) → tool_result = 连续 assistant。
|
||||
//
|
||||
// 注:前版本用「带 tool_calls 的 assistant + tool_result(闭合) + orphan 占位 result」,
|
||||
// 补头后前驱是 tool_result 而非 assistant,根本不产生连续 assistant,断言恒真(未真测)。
|
||||
// 本版把前驱改成纯文本 assistant(无 tool_result 隔开),才能真正触发合并路径。
|
||||
let placeholder_content = "需要用户审批,等待确认__PENDING__:call_pending_cc";
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::assistant("纯文本回复(无 tool_calls)"),
|
||||
ChatMessage::tool_result("call_pending_cc", placeholder_content),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs.clone());
|
||||
// 前置确认:sanitize 后占位 result 仍豁免保留(step3.5),序列保留 assistant→tool_result
|
||||
assert_eq!(sanitized.len(), 3, "占位 result 应豁免保留, 实际 {:?}", sanitized);
|
||||
assert!(sanitized.iter().any(|m|
|
||||
matches!(m.role, MessageRole::Tool)
|
||||
&& m.tool_call_id.as_deref() == Some("call_pending_cc")
|
||||
), "占位 tool_result 应保留");
|
||||
|
||||
let healed = assert_placeholder_pairing(sanitized, PLACEHOLDER_INTEGRITY_ENABLED);
|
||||
|
||||
// 断言:补了占位头(tool_missing_ 前缀 id)
|
||||
let missing_heads: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.filter_map(|m| m.tool_calls.as_ref())
|
||||
.flatten()
|
||||
.filter(|c| c.id.starts_with(TOOL_MISSING_PREFIX))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
missing_heads.len(),
|
||||
1,
|
||||
"应补 1 个占位头, 实际 {:?}",
|
||||
missing_heads
|
||||
);
|
||||
|
||||
// 核心断言:不应存在连续 assistant(任一 assistant 紧邻前一 assistant 视为连续)。
|
||||
// 若出口断言补头后未过 ensure_sequence_legal,会留连续 assistant(纯文本 assistant +
|
||||
// 占位 assistant)→ consecutive >= 1,本断言失败。合并后 consecutive 必须 0。
|
||||
let mut prev_is_assistant = false;
|
||||
let mut consecutive = 0u32;
|
||||
for m in &healed {
|
||||
let is_assistant = matches!(m.role, MessageRole::Assistant);
|
||||
if is_assistant && prev_is_assistant {
|
||||
consecutive += 1;
|
||||
}
|
||||
prev_is_assistant = is_assistant;
|
||||
}
|
||||
assert_eq!(
|
||||
consecutive, 0,
|
||||
"补占位头后不应有连续 assistant(应被合并), 实际序列 {:?}",
|
||||
healed
|
||||
.iter()
|
||||
.map(|m| format!("{:?}", m.role))
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// 合并验证:补的占位头应被并入前一个纯文本 assistant(其 tool_calls 从 None→Some[占位头]),
|
||||
// 而非独立成条(独立成条才会连续 assistant)。即 healed 里 assistant 消息数 == 1(原纯文本头
|
||||
// 吸收了占位头的 tool_calls),且该 assistant 同时含纯文本 content 与占位头 tool_call。
|
||||
let assistant_msgs: Vec<_> = healed
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
assistant_msgs.len(),
|
||||
1,
|
||||
"连续 assistant 应被合并为 1 条(占位头 tool_calls 并入前驱), 实际 {} 条: {:?}",
|
||||
assistant_msgs.len(),
|
||||
healed.iter().map(|m| format!("{:?}", m.role)).collect::<Vec<_>>()
|
||||
);
|
||||
// 合并后唯一 assistant 既保留原纯文本 content,又含占位头 tool_calls
|
||||
let merged = &assistant_msgs[0];
|
||||
assert_eq!(merged.content, "纯文本回复(无 tool_calls)", "合并后应保留前驱纯文本 content");
|
||||
let merged_call_ids: Vec<&str> = merged
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|cs| cs.iter().map(|c| c.id.as_str()).collect())
|
||||
.unwrap_or_default();
|
||||
assert!(
|
||||
merged_call_ids.iter().any(|id| id.starts_with(TOOL_MISSING_PREFIX)
|
||||
&& id.contains("call_pending_cc")),
|
||||
"合并后前驱 assistant 应吸收占位头 tool_call, 实际 calls: {:?}",
|
||||
merged_call_ids
|
||||
);
|
||||
|
||||
// 占位头 id 应在(合并后的)assistant 头 tool_calls 内,result 与之配对
|
||||
let result_id = healed
|
||||
.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::Tool))
|
||||
.and_then(|m| m.tool_call_id.clone())
|
||||
.expect("应有 tool_result");
|
||||
assert!(
|
||||
merged_call_ids.contains(&result_id.as_str()),
|
||||
"result id 应与合并后的占位头 id 一致(闭合配对), result={} heads={:?}",
|
||||
result_id,
|
||||
merged_call_ids
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_reverse_orphans_preserves_pending_with_legacy_text() {
|
||||
// 边界:老占位(纯文本"需要用户审批..."无 __PENDING__ 标记)→ is_pending_placeholder 仍识别
|
||||
// (starts_with "需要用户审批") → 豁免保留。验证向前兼容迁移期老数据。
|
||||
let msgs = vec![
|
||||
ChatMessage::user("问题"),
|
||||
ChatMessage::tool_result("legacy_pending", "需要用户审批,等待确认"),
|
||||
];
|
||||
let sanitized = sanitize_messages(msgs);
|
||||
let kept = sanitized
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.count();
|
||||
assert_eq!(kept, 1, "老占位(纯文本无标记)也应豁免保留(向前兼容)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitize_mixed_multi_message_sequence() {
|
||||
// 场景 4:连续多条消息混合——
|
||||
// [user] [全闭合三元组] [中毒占位 id 三元组] [部分闭合头] [user]
|
||||
// 预期:全闭合原样保留、中毒占位整删、部分闭合头重写为只剩已闭合、user 不动
|
||||
let mut mgr = ContextManager::new(cfg(100_000));
|
||||
mgr.push(ChatMessage::user("开场"));
|
||||
// 全闭合三元组
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调1",
|
||||
vec![ToolCall::new("ok_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("ok_1", "ok 结果"));
|
||||
// 中毒占位 id 三元组(全未闭合)→ 整删
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调2",
|
||||
vec![ToolCall::new("tool_missing_0", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("tool_missing_0", "占位结果"));
|
||||
// 部分闭合头:keep_1 闭合,drop_1 未闭合 → 重写为只剩 keep_1
|
||||
mgr.push(ChatMessage::assistant_with_tools(
|
||||
"调3",
|
||||
vec![ToolCall::new("keep_1", "fn", "{}"), ToolCall::new("drop_1", "fn", "{}")],
|
||||
));
|
||||
mgr.push(ChatMessage::tool_result("keep_1", "keep 结果"));
|
||||
mgr.push(ChatMessage::user("收尾"));
|
||||
let (msgs, _trimmed) = mgr.build_for_request(0);
|
||||
|
||||
// 任何保留头里的 id 都不得含占位前缀、不得含未闭合的 drop_1
|
||||
let head_ids: Vec<Vec<String>> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Assistant) && m.tool_calls.is_some())
|
||||
.map(|m| {
|
||||
m.tool_calls
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|c| c.id.clone())
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
assert!(
|
||||
head_ids.iter().all(|ids| {
|
||||
ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1")
|
||||
}),
|
||||
"保留的 assistant 头中不应再有中毒 id 或未闭合 id, 实际 {:?}", head_ids
|
||||
);
|
||||
let tool_ids: Vec<String> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::Tool))
|
||||
.filter_map(|m| m.tool_call_id.clone())
|
||||
.collect();
|
||||
// ok_1(全闭合保留)、keep_1(部分闭合重写后保留)应在;占位/drop_1 不在
|
||||
assert!(tool_ids.contains(&"ok_1".to_string()), "全闭合 ok_1 应保留");
|
||||
assert!(tool_ids.contains(&"keep_1".to_string()), "部分闭合 keep_1 应保留");
|
||||
assert!(
|
||||
tool_ids.iter().all(|id| !id.starts_with("tool_missing_") && id != "drop_1"),
|
||||
"无主 tool_result 应被剔除, 实际 {:?}", tool_ids
|
||||
);
|
||||
// 两条 user 消息原样保留
|
||||
let user_contents: Vec<&str> = msgs
|
||||
.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| m.content.as_str())
|
||||
.collect();
|
||||
assert_eq!(user_contents, vec!["开场", "收尾"], "无关 user 消息不应被误删");
|
||||
assert_eq!(mgr.all_messages_clone().len(), 8, "sanitize 不应污染内存全量");
|
||||
}
|
||||
}
|
||||
1113
crates/df-ai/src/coordinator.rs
Normal file
1113
crates/df-ai/src/coordinator.rs
Normal file
File diff suppressed because it is too large
Load Diff
542
crates/df-ai/src/git_worktree.rs
Normal file
542
crates/df-ai/src/git_worktree.rs
Normal file
@@ -0,0 +1,542 @@
|
||||
//! Git worktree 生命周期管理 — 多 Agent 并行执行的文件隔离基础
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.2
|
||||
//!
|
||||
//! 每个 SubTask 创建独立 worktree(git worktree add + 新分支),
|
||||
//! 写操作天然隔离。合并时用 git merge-tree 预检 + git merge 实际合并。
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
|
||||
/// worktree 管理错误
|
||||
#[derive(Debug)]
|
||||
pub enum WorktreeError {
|
||||
/// Git 命令执行失败(stdout/stderr 含错误信息)
|
||||
GitFailed(String),
|
||||
/// 工程根目录无 .git(非 Git 工程,应降级串行)
|
||||
NotGitRepo,
|
||||
/// worktree 目录已存在(可能上次未清理)
|
||||
AlreadyExists(PathBuf),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WorktreeError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitFailed(msg) => write!(f, "Git 命令失败: {}", msg),
|
||||
Self::NotGitRepo => write!(f, "非 Git 工程(无 .git 目录)"),
|
||||
Self::AlreadyExists(p) => write!(f, "worktree 目录已存在: {}", p.display()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for WorktreeError {}
|
||||
|
||||
/// worktree 创建结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorktreeHandle {
|
||||
/// worktree 物理路径(SubTask 工具操作的 cwd)
|
||||
pub path: PathBuf,
|
||||
/// worktree 对应的分支名(merge 时用)
|
||||
pub branch: String,
|
||||
/// 所属 Plan id(清理时按 plan 批量)
|
||||
pub plan_id: String,
|
||||
/// SubTask id
|
||||
pub subtask_id: String,
|
||||
}
|
||||
|
||||
/// 在指定工程根目录下创建 SubTask 专属 worktree。
|
||||
///
|
||||
/// - `project_root`: 工程根目录(含 .git)
|
||||
/// - `plan_id`: 所属 Plan id
|
||||
/// - `subtask_id`: SubTask id
|
||||
/// - `base_branch`: fork 基点分支(plan 分支或主分支)
|
||||
///
|
||||
/// 返回 WorktreeHandle(path + branch)。
|
||||
/// worktree 路径:`{project_root}/.devflow/wt/{plan_id}/{subtask_id}`
|
||||
/// 分支名:`subtask/{plan_id}/{subtask_id}`
|
||||
pub fn create_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
subtask_id: &str,
|
||||
base_branch: &str,
|
||||
) -> Result<WorktreeHandle, WorktreeError> {
|
||||
if !project_root.join(".git").exists() {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let wt_path = project_root.join(".devflow/wt").join(plan_id).join(subtask_id);
|
||||
if wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(wt_path));
|
||||
}
|
||||
|
||||
let branch = format!("subtask/{}/{}", plan_id, subtask_id);
|
||||
|
||||
// git worktree add <path> -b <branch> <base_branch>
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&branch,
|
||||
base_branch,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add 执行失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add 失败: {}",
|
||||
stderr.trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(WorktreeHandle {
|
||||
path: wt_path,
|
||||
branch,
|
||||
plan_id: plan_id.to_string(),
|
||||
subtask_id: subtask_id.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 在 worktree 内提交所有改动(git add -A + git commit)。
|
||||
///
|
||||
/// - `wt_path`: worktree 物理路径
|
||||
/// - `message`: commit 消息
|
||||
pub fn commit_worktree(wt_path: &Path, message: &str) -> Result<(), WorktreeError> {
|
||||
// git add -A
|
||||
let add = Command::new("git")
|
||||
.args(["add", "-A"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git add 失败: {}", e)))?;
|
||||
if !add.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git add 失败: {}",
|
||||
String::from_utf8_lossy(&add.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
// git commit -m (允许空提交,--allow-empty 防无改动时报错)
|
||||
let commit = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 失败: {}", e)))?;
|
||||
if !commit.status.success() {
|
||||
// commit 失败可能因无 user.email 配置(测试环境),尝试自动配
|
||||
let stderr = String::from_utf8_lossy(&commit.stderr);
|
||||
if stderr.contains("user.email") || stderr.contains("user.name") {
|
||||
// 降级:设置 worktree 局部配置(--local 不污染全局 git config)后重试
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.email", "devflow@ai.local"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
Command::new("git")
|
||||
.args(["config", "--local", "user.name", "DevFlow Agent"])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.ok();
|
||||
let retry = Command::new("git")
|
||||
.args(["commit", "--allow-empty", "-m", message])
|
||||
.current_dir(wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git commit 重试失败: {}", e)))?;
|
||||
if !retry.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git commit 重试仍失败: {}",
|
||||
String::from_utf8_lossy(&retry.stderr).trim()
|
||||
)));
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
return Err(WorktreeError::GitFailed(format!("git commit 失败: {}", stderr.trim())));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SubTask 分支 merge 到 plan 分支(在 plan worktree 内执行)。
|
||||
///
|
||||
/// 返回 Ok(true) 表示有改动被合并,Ok(false) 表示无改动(空分支)。
|
||||
/// 返回 Err 表示 merge 冲突。
|
||||
pub fn merge_branch(
|
||||
plan_wt_path: &Path,
|
||||
subtask_branch: &str,
|
||||
) -> Result<MergeResult, WorktreeError> {
|
||||
// 用旧版兼容的 merge-tree(base branch → 输出冲突树)
|
||||
// --write-tree 需 Git 2.38+,改用三参数 merge-tree <base> <branch1> <branch2>
|
||||
// 成功(exit 0)= 无冲突可自动合并;非0且有输出 = 冲突
|
||||
let base = "HEAD";
|
||||
let precheck = Command::new("git")
|
||||
.args(["merge-tree", base, subtask_branch, base])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge-tree 失败: {}", e)))?;
|
||||
|
||||
// merge-tree 输出含 conflict marker(<<<<<<<)时表示有冲突
|
||||
let precheck_stdout = String::from_utf8_lossy(&precheck.stdout);
|
||||
if precheck_stdout.contains("<<<<<<<") || precheck_stdout.contains("=======") {
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: precheck_stdout.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// 无冲突,执行实际 merge
|
||||
let merge = Command::new("git")
|
||||
.args(["merge", "--no-edit", subtask_branch])
|
||||
.current_dir(plan_wt_path)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git merge 失败: {}", e)))?;
|
||||
|
||||
if !merge.status.success() {
|
||||
// merge 冲突(git merge 返回非0)
|
||||
let stderr = String::from_utf8_lossy(&merge.stderr);
|
||||
let stdout = String::from_utf8_lossy(&merge.stdout);
|
||||
// abort 部分合并状态(防 worktree 处于 conflicted 状态)
|
||||
let _ = Command::new("git")
|
||||
.args(["merge", "--abort"])
|
||||
.current_dir(plan_wt_path)
|
||||
.output();
|
||||
return Ok(MergeResult::Conflict {
|
||||
branch: subtask_branch.to_string(),
|
||||
details: format!("{}\n{}", stdout, stderr),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(MergeResult::Clean)
|
||||
}
|
||||
|
||||
/// merge 结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum MergeResult {
|
||||
/// 干净合并(无冲突)
|
||||
Clean,
|
||||
/// 合并冲突(details 含 conflict 信息)
|
||||
Conflict { branch: String, details: String },
|
||||
}
|
||||
|
||||
/// 清理单个 SubTask 的 worktree(git worktree remove --force + 删分支)。
|
||||
pub fn remove_worktree(project_root: &Path, handle: &WorktreeHandle) -> Result<(), WorktreeError> {
|
||||
// git worktree remove --force <path>
|
||||
let _ = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"remove",
|
||||
"--force",
|
||||
handle.path.to_str().unwrap_or(""),
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
// 删除分支(-D 强制,已 merge 的分支正常删,未 merge 的也删)
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", &handle.branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 清理某 Plan 的所有 worktree(Plan 完成/失败/取消时批量清理)。
|
||||
pub fn remove_plan_worktrees(project_root: &Path, plan_id: &str) -> Result<(), WorktreeError> {
|
||||
let plan_wt_dir = project_root.join(".devflow/wt").join(plan_id);
|
||||
if !plan_wt_dir.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// git worktree list 获取该 plan 下所有 worktree
|
||||
let list = Command::new("git")
|
||||
.args(["worktree", "list", "--porcelain"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree list 失败: {}", e)))?;
|
||||
|
||||
let list_str = String::from_utf8_lossy(&list.stdout);
|
||||
for line in list_str.lines() {
|
||||
if let Some(path_str) = line.strip_prefix("worktree ") {
|
||||
let path = PathBuf::from(path_str);
|
||||
// 仅清理属于本 plan 的 worktree
|
||||
if path.starts_with(&plan_wt_dir) {
|
||||
let _ = Command::new("git")
|
||||
.args(["worktree", "remove", "--force", path_str])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 删除该 plan 的所有子任务分支
|
||||
let branches = Command::new("git")
|
||||
.args(["branch", "--list", &format!("subtask/{}/*", plan_id)])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git branch --list 失败: {}", e)))?;
|
||||
|
||||
let branches_str = String::from_utf8_lossy(&branches.stdout);
|
||||
for line in branches_str.lines() {
|
||||
let branch = line.trim();
|
||||
if !branch.is_empty() {
|
||||
let _ = Command::new("git")
|
||||
.args(["branch", "-D", branch])
|
||||
.current_dir(project_root)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// 清理空目录
|
||||
let _ = std::fs::remove_dir_all(&plan_wt_dir);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 检测工程是否为 Git 仓库(有 .git 目录)。
|
||||
pub fn is_git_repo(project_root: &Path) -> bool {
|
||||
project_root.join(".git").exists()
|
||||
}
|
||||
|
||||
/// 获取工程当前分支名(用于作为 plan worktree 的 fork 基点)。
|
||||
pub fn current_branch(project_root: &Path) -> Result<String, WorktreeError> {
|
||||
let output = Command::new("git")
|
||||
.args(["rev-parse", "--abbrev-ref", "HEAD"])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git rev-parse 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(
|
||||
String::from_utf8_lossy(&output.stderr).trim().to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
|
||||
}
|
||||
|
||||
/// 创建 Plan worktree(Plan 级工作分支,所有 SubTask 分支 merge 到这里)。
|
||||
///
|
||||
/// 返回 (plan_worktree_path, plan_branch)。
|
||||
pub fn create_plan_worktree(
|
||||
project_root: &Path,
|
||||
plan_id: &str,
|
||||
) -> Result<(PathBuf, String), WorktreeError> {
|
||||
if !is_git_repo(project_root) {
|
||||
return Err(WorktreeError::NotGitRepo);
|
||||
}
|
||||
|
||||
let base = current_branch(project_root)?;
|
||||
let plan_branch = format!("plan/{}", plan_id);
|
||||
let plan_wt_path = project_root.join(".devflow/wt").join(plan_id);
|
||||
|
||||
if plan_wt_path.exists() {
|
||||
return Err(WorktreeError::AlreadyExists(plan_wt_path));
|
||||
}
|
||||
|
||||
let output = Command::new("git")
|
||||
.args([
|
||||
"worktree",
|
||||
"add",
|
||||
plan_wt_path.to_str().unwrap_or(""),
|
||||
"-b",
|
||||
&plan_branch,
|
||||
&base,
|
||||
])
|
||||
.current_dir(project_root)
|
||||
.output()
|
||||
.map_err(|e| WorktreeError::GitFailed(format!("git worktree add(plan) 失败: {}", e)))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(WorktreeError::GitFailed(format!(
|
||||
"git worktree add(plan) 失败: {}",
|
||||
String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
|
||||
Ok((plan_wt_path, plan_branch))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// 创建临时 Git 仓库 + 初始 commit
|
||||
fn setup_repo() -> tempfile::TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path();
|
||||
|
||||
Command::new("git").args(["init"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"]).current_dir(path).output().unwrap();
|
||||
|
||||
std::fs::write(path.join("main.rs"), "fn main() {}\n").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(path).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(path).output().unwrap();
|
||||
|
||||
dir
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_01_create_and_isolate() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
let base = current_branch(root).unwrap();
|
||||
|
||||
let handle = create_worktree(root, "plan-test", "A", &base).unwrap();
|
||||
assert!(handle.path.exists(), "worktree 目录应存在");
|
||||
assert_eq!(handle.branch, "subtask/plan-test/A");
|
||||
|
||||
// 在 worktree 内写文件
|
||||
std::fs::write(handle.path.join("new_a.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有此文件
|
||||
assert!(!root.join("new_a.rs").exists(), "主目录不应有 A 的改动");
|
||||
|
||||
// 清理
|
||||
remove_worktree(root, &handle).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_02_parallel_isolation() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-iso").unwrap();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
let a = create_worktree(root, "plan-iso", "A", &plan_branch).unwrap();
|
||||
let b = create_worktree(root, "plan-iso", "B", &plan_branch).unwrap();
|
||||
|
||||
// A 写 new_a.rs
|
||||
std::fs::write(a.path.join("new_a.rs"), "// A").unwrap();
|
||||
// B 写 new_b.rs
|
||||
std::fs::write(b.path.join("new_b.rs"), "// B").unwrap();
|
||||
|
||||
// A 的 worktree 不应有 B 的文件
|
||||
assert!(!a.path.join("new_b.rs").exists(), "A 不应看到 B 的改动");
|
||||
// B 的 worktree 不应有 A 的文件
|
||||
assert!(!b.path.join("new_a.rs").exists(), "B 不应看到 A 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-iso").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_03_commit_in_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-commit").unwrap();
|
||||
let a = create_worktree(root, "plan-commit", "A", &plan_wt.1).unwrap();
|
||||
|
||||
// 写文件 + commit
|
||||
std::fs::write(a.path.join("feature.rs"), "pub fn feature() {}").unwrap();
|
||||
commit_worktree(&a.path, "Add feature").unwrap();
|
||||
|
||||
// 验证分支有新 commit
|
||||
let log = Command::new("git")
|
||||
.args(["log", "--oneline", "-1"])
|
||||
.current_dir(&a.path)
|
||||
.output()
|
||||
.unwrap();
|
||||
let log_str = String::from_utf8_lossy(&log.stdout);
|
||||
assert!(log_str.contains("Add feature"), "commit 消息应在 log 中");
|
||||
|
||||
remove_plan_worktrees(root, "plan-commit").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_04_merge_no_conflict() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-merge").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 改 file_a.rs, B 改 file_b.rs(不同文件,无冲突)
|
||||
let a = create_worktree(root, "plan-merge", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("file_a.rs"), "// A").unwrap();
|
||||
commit_worktree(&a.path, "A changes").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-merge", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("file_b.rs"), "// B").unwrap();
|
||||
commit_worktree(&b.path, "B changes").unwrap();
|
||||
|
||||
// merge A → plan
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "A merge 应无冲突");
|
||||
|
||||
// merge B → plan
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(matches!(result_b, MergeResult::Clean), "B merge 应无冲突");
|
||||
|
||||
// plan worktree 应同时有 file_a.rs 和 file_b.rs
|
||||
assert!(plan_wt_path.join("file_a.rs").exists(), "plan 应含 A 的改动");
|
||||
assert!(plan_wt_path.join("file_b.rs").exists(), "plan 应含 B 的改动");
|
||||
|
||||
remove_plan_worktrees(root, "plan-merge").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_05_merge_conflict_same_file() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
// 写一个共享文件
|
||||
std::fs::write(root.join("shared.rs"), "line1\nline2\n").unwrap();
|
||||
Command::new("git").args(["add", "-A"]).current_dir(root).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "add shared"]).current_dir(root).output().unwrap();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-conf").unwrap();
|
||||
let plan_wt_path = plan_wt.0.clone();
|
||||
let plan_branch = plan_wt.1;
|
||||
|
||||
// A 和 B 改同一文件的同一行
|
||||
let a = create_worktree(root, "plan-conf", "A", &plan_branch).unwrap();
|
||||
std::fs::write(a.path.join("shared.rs"), "A_version\n").unwrap();
|
||||
commit_worktree(&a.path, "A change shared").unwrap();
|
||||
|
||||
let b = create_worktree(root, "plan-conf", "B", &plan_branch).unwrap();
|
||||
std::fs::write(b.path.join("shared.rs"), "B_version\n").unwrap();
|
||||
commit_worktree(&b.path, "B change shared").unwrap();
|
||||
|
||||
// merge A → 无冲突
|
||||
let result_a = merge_branch(&plan_wt_path, &a.branch).unwrap();
|
||||
assert!(matches!(result_a, MergeResult::Clean), "首个 merge 应无冲突");
|
||||
|
||||
// merge B → 冲突
|
||||
let result_b = merge_branch(&plan_wt_path, &b.branch).unwrap();
|
||||
assert!(
|
||||
matches!(result_b, MergeResult::Conflict { .. }),
|
||||
"同文件同行改应产生冲突"
|
||||
);
|
||||
|
||||
remove_plan_worktrees(root, "plan-conf").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_06_non_git_returns_error() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// 不 init git
|
||||
let result = create_plan_worktree(dir.path(), "plan-ng");
|
||||
assert!(matches!(result, Err(WorktreeError::NotGitRepo)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wt_07_cleanup_removes_worktree() {
|
||||
let repo = setup_repo();
|
||||
let root = repo.path();
|
||||
|
||||
let plan_wt = create_plan_worktree(root, "plan-clean").unwrap();
|
||||
let a = create_worktree(root, "plan-clean", "A", &plan_wt.1).unwrap();
|
||||
assert!(a.path.exists());
|
||||
|
||||
remove_plan_worktrees(root, "plan-clean").unwrap();
|
||||
|
||||
// worktree 目录应被清理
|
||||
assert!(!a.path.exists(), "worktree 目录应已清理");
|
||||
// plan wt 目录也应被清理
|
||||
assert!(!plan_wt.0.exists(), "plan worktree 目录应已清理");
|
||||
}
|
||||
}
|
||||
@@ -944,7 +944,7 @@ mod tests {
|
||||
/// 构造测试用 ToolDefinition(仅 name 有意义,description/parameters 填占位)。
|
||||
fn tool_def(name: &str) -> df_ai_core::types::ToolDefinition {
|
||||
df_ai_core::types::ToolDefinition {
|
||||
tool_type: "function".to_string(),
|
||||
tool_type: "function".into(),
|
||||
function: df_ai_core::types::ToolFunction {
|
||||
name: name.to_string(),
|
||||
description: String::new(),
|
||||
|
||||
@@ -5,11 +5,12 @@ pub mod anthropic_compat;
|
||||
pub mod anthropic_helpers;
|
||||
pub mod context;
|
||||
pub mod context_helpers;
|
||||
// coordinator 模块已删除(B 路线空壳占位,零调用)。
|
||||
// 多 Agent 协作未来从 planner.rs 的 Plan DAG 执行器重新实现。
|
||||
// 多 Agent 协作调度中心(Phase 1 规则驱动, Phase 2 LLM 扩展)。
|
||||
pub mod coordinator;
|
||||
// 会话意图识别层(纯函数,不接入 agentic loop)。依据 docs/02-架构设计/构想审查/
|
||||
// 意图识别层论证-2026-06-19.md。提供 recognize / tool_subset_for / suggested_model_tier。
|
||||
// 待 Phase B+C 完成 + 模型模态管理落地后再接入 agentic loop。
|
||||
pub mod git_worktree;
|
||||
pub mod intent;
|
||||
pub mod model_fetch;
|
||||
// model_fetch 的纯逻辑子模块(URL 拼接 / 噪音过滤 / 响应反序列化),
|
||||
@@ -32,6 +33,7 @@ pub mod planner;
|
||||
// Phase 2: Plan DAG 分层执行器(骨架,PLAN_EXECUTION_ENABLED 门控默认关)。
|
||||
// 按 Plan::to_layers 层间串行/层内并行调度子任务。
|
||||
// 依据 docs/02-架构设计/单对话并行多轮-设计-2026-06-20.md。
|
||||
pub mod persona;
|
||||
pub mod plan_executor;
|
||||
pub mod provider;
|
||||
pub mod router;
|
||||
@@ -39,6 +41,7 @@ pub mod router;
|
||||
// stream_recv/agentic 流前重试需复用 backoff_delay(jitter)+is_status_retryable(Fatal 分类)
|
||||
// 避免重写退避/分类逻辑(对齐决策 F-260616-07 a1)。改 pub mod 后对外仅暴露纯函数 + 常量。
|
||||
pub mod retry;
|
||||
pub mod sse_parser;
|
||||
|
||||
use provider::LlmProvider;
|
||||
use reqwest::Client;
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use eventsource_stream::Eventsource;
|
||||
use futures::StreamExt;
|
||||
use reqwest::Client;
|
||||
use tracing::{debug, error, warn};
|
||||
@@ -183,6 +182,31 @@ impl OpenAICompatProvider {
|
||||
// assistant 的序列(会话恢复/续发/片段截取),补 user 占位保留上下文,首条合法。
|
||||
Self::ensure_leading_user(&mut messages);
|
||||
|
||||
// 治 DeepSeek 400「insufficient tool messages」:扫描所有 assistant 消息,
|
||||
// 若某条 assistant 含 tool_calls 但下一条不是 tool,则剥离其 tool_calls。
|
||||
// 正常流程 tool 结果先于下一轮 LLM 请求推入历史,此守卫仅兜底异常截断/恢复场景的残末尾。
|
||||
// 注意:合法的三元组形如:assistant(tc=[a]) → tool(a) → assistant(tc=[b]) → tool(b)。
|
||||
// 若最后一条是 assistant(tc=...) 也无下一条 tool,同样剥离。
|
||||
for i in 0..messages.len() {
|
||||
let role = messages[i].role.clone();
|
||||
if role != "assistant" {
|
||||
continue;
|
||||
}
|
||||
let has_tc = messages[i].tool_calls.is_some();
|
||||
if !has_tc {
|
||||
continue;
|
||||
}
|
||||
let next_is_tool = i + 1 < messages.len()
|
||||
&& matches!(messages[i + 1].role.as_str(), "tool");
|
||||
if !next_is_tool {
|
||||
messages[i].tool_calls = None;
|
||||
tracing::warn!(
|
||||
"[openai] assistant(#{} role={}) 含 tool_calls 但下一条非 tool,已自动剥离(防 400)",
|
||||
i, role,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let tools = req.tools.map(|defs| {
|
||||
defs.into_iter()
|
||||
.map(|d| serde_json::to_value(d).unwrap_or_default())
|
||||
@@ -362,14 +386,26 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
|
||||
debug!(model = %openai_req.model, "OpenAI 流式调用");
|
||||
|
||||
let resp = self
|
||||
// BUG-2026-07-07: send 阶段需 timeout 防 hang(同 Anthropic 路径)。
|
||||
// 不能用 reqwest .timeout()(会砍流式 body),改用 tokio::time::timeout 包裹 send。
|
||||
let send_future = self
|
||||
.client
|
||||
.post(self.chat_url())
|
||||
.header("Authorization", format!("Bearer {}", self.api_key))
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&openai_req)
|
||||
.send()
|
||||
.await?;
|
||||
.send();
|
||||
let resp = match tokio::time::timeout(Duration::from_secs(60), send_future).await {
|
||||
Ok(Ok(r)) => r,
|
||||
Ok(Err(e)) => {
|
||||
tracing::error!(error = %e, is_timeout = e.is_timeout(), "OpenAI 流式 send 失败");
|
||||
return Err(e.into());
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(url = %self.chat_url(), "OpenAI 流式 send 超时(60s 未返回响应头)");
|
||||
anyhow::bail!("流式请求超时(60秒未收到 HTTP 响应,可能服务不可达或被防火墙拦截)");
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status();
|
||||
@@ -378,25 +414,30 @@ impl LlmProvider for OpenAICompatProvider {
|
||||
anyhow::bail!("LLM 流式 API 错误 {}: {}", status, body);
|
||||
}
|
||||
|
||||
// 累积流式 usage:开 include_usage 后,末段正常 chunk(finish_reason)及额外 usage-only chunk(choices=[])都带 usage。
|
||||
// usage 解析/累积逻辑抽到 apply_openai_sse 纯函数,便于单测;此处闭包只负责传 data 与传递 last_usage。
|
||||
// BUG-2026-07-17 根治: 原生 SSE 解析器替代 eventsource-stream 库。
|
||||
// eventsource-stream 在 Windows 上对 Deepseek 等响应报 "error decoding response body"
|
||||
// (严格 UTF-8 + SSE 协议校验,跨 chunk 字符/不完整事件均报错且不可恢复)。
|
||||
// 原生解析器:bytes 累积 + from_utf8_lossy 宽松处理 + \n\n 分隔,容错不中断流。
|
||||
let mut last_usage: Option<TokenUsage> = None;
|
||||
|
||||
let stream = resp
|
||||
.bytes_stream()
|
||||
.eventsource()
|
||||
.map(move |event| match event {
|
||||
Ok(event) => Ok(apply_openai_sse(&event.data, &mut last_usage)),
|
||||
let sse = crate::sse_parser::SseStream::new(resp.bytes_stream());
|
||||
let stream = sse.flat_map(move |result: Result<Vec<String>, String>| {
|
||||
let mut chunks: Vec<anyhow::Result<crate::provider::StreamChunk>> = Vec::new();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for data in events {
|
||||
let chunk = apply_openai_sse(&data, &mut last_usage);
|
||||
chunks.push(Ok(chunk));
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 保留 #[source] 因果链: anyhow!("...{}", e) 仅把 e 的 Display 塞进 message,
|
||||
// 丢掉 source(无法 downcast/遍历)。改用 Error::from(e).context(...):
|
||||
// Display 不变(仍为 "SSE 流错误: {e}"), 且 e 作为 .source() 可追溯。
|
||||
// 顺序: 先 format(e) 构造 context 文案, 再 Error::from(e) move e 进 source。
|
||||
let ctx = format!("SSE 流错误: {}", e);
|
||||
error!("{}", ctx);
|
||||
Err(anyhow::Error::from(e).context(ctx))
|
||||
chunks.push(Err(anyhow::anyhow!("{}", ctx)));
|
||||
}
|
||||
});
|
||||
}
|
||||
futures::stream::iter(chunks)
|
||||
});
|
||||
|
||||
Ok(Box::pin(stream))
|
||||
}
|
||||
|
||||
385
crates/df-ai/src/persona.rs
Normal file
385
crates/df-ai/src/persona.rs
Normal file
@@ -0,0 +1,385 @@
|
||||
//! 人设系统(P0) — AgentPersona 结构体 + PersonaRegistry 注册表
|
||||
//!
|
||||
//! 构成 AI Native 的基础:角色划分是分工的前提。
|
||||
//! - 每名人设包含:能力标签、工具白名单、system prompt 模板
|
||||
//! - Registry 提供按场景/意图选人设、按 id 查人设能力
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// 内置人设 ID 常量
|
||||
pub const PERSONA_CODER: &str = "coder";
|
||||
pub const PERSONA_REVIEWER: &str = "reviewer";
|
||||
pub const PERSONA_ARCHITECT: &str = "architect";
|
||||
pub const PERSONA_TESTER: &str = "tester";
|
||||
pub const PERSONA_ANALYST: &str = "analyst";
|
||||
|
||||
/// 人设能力标签枚举
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum PersonaCapability {
|
||||
/// 代码生成与修改
|
||||
CodeGeneration,
|
||||
/// Code Review
|
||||
CodeReview,
|
||||
/// 架构设计与分析
|
||||
ArchitectureDesign,
|
||||
/// 测试编写与执行
|
||||
Testing,
|
||||
/// 数据分析与搜索
|
||||
Analysis,
|
||||
/// 文件读写
|
||||
FileOperation,
|
||||
/// 命令执行
|
||||
CommandExecution,
|
||||
/// Git 操作
|
||||
GitOperation,
|
||||
/// 知识库操作
|
||||
KnowledgeOperation,
|
||||
/// 项目管理
|
||||
ProjectManagement,
|
||||
}
|
||||
|
||||
impl PersonaCapability {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Self::CodeGeneration => "代码生成",
|
||||
Self::CodeReview => "代码审查",
|
||||
Self::ArchitectureDesign => "架构设计",
|
||||
Self::Testing => "测试",
|
||||
Self::Analysis => "分析",
|
||||
Self::FileOperation => "文件操作",
|
||||
Self::CommandExecution => "命令执行",
|
||||
Self::GitOperation => "Git 操作",
|
||||
Self::KnowledgeOperation => "知识库",
|
||||
Self::ProjectManagement => "项目管理",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设定义
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentPersona {
|
||||
/// 唯一标识(如 "coder", "reviewer")
|
||||
pub id: String,
|
||||
/// 人设名称(如 "程序员", "审查员")
|
||||
pub name: String,
|
||||
/// 人设描述(LLM 理解用)
|
||||
pub description: String,
|
||||
/// 能力标签集
|
||||
pub capabilities: Vec<PersonaCapability>,
|
||||
/// 工具白名单(空=不限,非空=仅允许列出的工具)
|
||||
pub tool_whitelist: Vec<String>,
|
||||
/// system prompt 模板(注入 {context} {goals} 等占位符)
|
||||
pub system_prompt_template: String,
|
||||
/// 优先级(选人设时冲突排名,0=最高)
|
||||
pub priority: u8,
|
||||
}
|
||||
|
||||
impl AgentPersona {
|
||||
/// 检查此人设是否具备某项能力
|
||||
pub fn has_capability(&self, cap: &PersonaCapability) -> bool {
|
||||
self.capabilities.contains(cap)
|
||||
}
|
||||
|
||||
/// 检查某工具是否在此人设的 whitelist 中
|
||||
pub fn is_tool_allowed(&self, tool_name: &str) -> bool {
|
||||
self.tool_whitelist.is_empty() || self.tool_whitelist.iter().any(|t| t == tool_name)
|
||||
}
|
||||
|
||||
/// 构建带上下文的 system prompt
|
||||
pub fn build_prompt(&self, context: &str, goals: &[String]) -> String {
|
||||
let goals_text = if goals.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("\n## 当前目标\n{}", goals.iter().enumerate().map(|(i, g)| format!("{}. {}", i + 1, g)).collect::<Vec<_>>().join("\n"))
|
||||
};
|
||||
self.system_prompt_template
|
||||
.replace("{context}", context)
|
||||
.replace("{goals}", &goals_text)
|
||||
}
|
||||
}
|
||||
|
||||
/// 人设注册表 — 管理内置 + 自定义人设
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PersonaRegistry {
|
||||
personae: HashMap<String, AgentPersona>,
|
||||
/// 默认人设 id(兜底)
|
||||
default_id: String,
|
||||
}
|
||||
|
||||
impl PersonaRegistry {
|
||||
/// 构造默认注册表(含 5 内置人设)
|
||||
pub fn new() -> Self {
|
||||
let mut reg = Self {
|
||||
personae: HashMap::new(),
|
||||
default_id: PERSONA_CODER.to_string(),
|
||||
};
|
||||
reg.register(Self::builtin_coder());
|
||||
reg.register(Self::builtin_reviewer());
|
||||
reg.register(Self::builtin_architect());
|
||||
reg.register(Self::builtin_tester());
|
||||
reg.register(Self::builtin_analyst());
|
||||
reg
|
||||
}
|
||||
|
||||
// ── 5 内置人设 ──
|
||||
|
||||
fn builtin_coder() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_CODER.to_string(),
|
||||
name: "程序员".to_string(),
|
||||
description: "负责代码生成、修改、重构。专注于实现功能、修复 bug、优化性能,不关注大范围架构变更".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeGeneration,
|
||||
PersonaCapability::FileOperation,
|
||||
PersonaCapability::CommandExecution,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限(全部工具可用)
|
||||
system_prompt_template: "你是 DevFlow 的**程序员**(Coder)。\n\n你的职责是编写、修改和重构代码。专注于具体实现,不擅自改变整体架构。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_reviewer() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_REVIEWER.to_string(),
|
||||
name: "审查员".to_string(),
|
||||
description: "负责 Code Review、质量审计、安全检查。审查代码正确性、性能、安全性,返回审查意见不直接改代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::CodeReview,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"git_diff".into(), "git_log".into(), "git_status".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**审查员**(Reviewer)。\n\n你的职责是审查代码质量、安全性和性能。**你不直接修改代码**,只输出审查意见和修改建议。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_architect() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ARCHITECT.to_string(),
|
||||
name: "架构师".to_string(),
|
||||
description: "负责架构设计、技术选型、模块划分。分析整体结构,制定技术方案,不关注具体代码实现".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::ArchitectureDesign,
|
||||
PersonaCapability::Analysis,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "list_directory".into(),
|
||||
"write_file".into(), // 写架构文档
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**架构师**(Architect)。\n\n你的职责是架构设计、技术选型和模块划分。分析系统整体结构,制定技术方案。**不编写业务代码**,输出架构文档和设计方案。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 2,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_tester() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_TESTER.to_string(),
|
||||
name: "测试员".to_string(),
|
||||
description: "负责编写单元测试、集成测试、执行测试。验证代码正确性,不修改生产代码".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Testing,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::FileOperation,
|
||||
],
|
||||
tool_whitelist: vec![], // 不限
|
||||
system_prompt_template: "你是 DevFlow 的**测试员**(Tester)。\n\n你的职责是编写测试、执行测试、报告测试结果。**不修改生产代码**,只修改测试代码。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_analyst() -> AgentPersona {
|
||||
AgentPersona {
|
||||
id: PERSONA_ANALYST.to_string(),
|
||||
name: "分析师".to_string(),
|
||||
description: "负责数据分析、日志排查、性能分析。搜索信息、分析数据,输出分析报告".to_string(),
|
||||
capabilities: vec![
|
||||
PersonaCapability::Analysis,
|
||||
PersonaCapability::CommandExecution,
|
||||
PersonaCapability::KnowledgeOperation,
|
||||
],
|
||||
tool_whitelist: vec![
|
||||
"read_file".into(), "search_files".into(), "grep_search".into(),
|
||||
"run_command".into(), "git_log".into(),
|
||||
"knowledge_search".into(), "knowledge_inject".into(),
|
||||
],
|
||||
system_prompt_template: "你是 DevFlow 的**分析师**(Analyst)。\n\n你的职责是分析数据、排查问题、搜索信息。输出分析报告和结论。**不修改代码**。\n\n{goals}\n\n{context}".to_string(),
|
||||
priority: 4,
|
||||
}
|
||||
}
|
||||
|
||||
// ── 注册表操作 ──
|
||||
|
||||
/// 注册/覆盖人设
|
||||
pub fn register(&mut self, persona: AgentPersona) {
|
||||
self.personae.insert(persona.id.clone(), persona);
|
||||
}
|
||||
|
||||
/// 按 id 查人设
|
||||
pub fn get(&self, id: &str) -> Option<&AgentPersona> {
|
||||
self.personae.get(id)
|
||||
}
|
||||
|
||||
/// 取默认人设
|
||||
pub fn default(&self) -> &AgentPersona {
|
||||
self.personae.get(&self.default_id).expect("默认人设必存在")
|
||||
}
|
||||
|
||||
/// 设置默认人设 id
|
||||
pub fn set_default(&mut self, id: &str) {
|
||||
if self.personae.contains_key(id) {
|
||||
self.default_id = id.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
/// 按意图推荐人设(启发式规则)
|
||||
pub fn recommend_for_intent(&self, intent: &str) -> &AgentPersona {
|
||||
let lower = intent.to_lowercase();
|
||||
if lower.contains("review") || lower.contains("审查") || lower.contains("审计") {
|
||||
self.get(PERSONA_REVIEWER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("架构") || lower.contains("设计") || lower.contains("architect") {
|
||||
self.get(PERSONA_ARCHITECT).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("测试") || lower.contains("test") {
|
||||
self.get(PERSONA_TESTER).unwrap_or_else(|| self.default())
|
||||
} else if lower.contains("分析") || lower.contains("分析") || lower.contains("排查") || lower.contains("search") {
|
||||
self.get(PERSONA_ANALYST).unwrap_or_else(|| self.default())
|
||||
} else {
|
||||
// 默认走 coder(代码生成是最常见场景)
|
||||
self.default()
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取全部人设列表
|
||||
pub fn all(&self) -> Vec<&AgentPersona> {
|
||||
let mut list: Vec<_> = self.personae.values().collect();
|
||||
list.sort_by_key(|p| p.priority);
|
||||
list
|
||||
}
|
||||
|
||||
/// 人设数量
|
||||
pub fn len(&self) -> usize {
|
||||
self.personae.len()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.personae.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PersonaRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn registry_has_5_builtin() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.len(), 5);
|
||||
assert!(reg.get(PERSONA_CODER).is_some());
|
||||
assert!(reg.get(PERSONA_REVIEWER).is_some());
|
||||
assert!(reg.get(PERSONA_ARCHITECT).is_some());
|
||||
assert!(reg.get(PERSONA_TESTER).is_some());
|
||||
assert!(reg.get(PERSONA_ANALYST).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_is_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
assert_eq!(reg.default().id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_reviewer() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("review the code changes");
|
||||
assert_eq!(p.id, PERSONA_REVIEWER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_architect() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("设计新的模块架构");
|
||||
assert_eq!(p.id, PERSONA_ARCHITECT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_tester() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("write tests for this module");
|
||||
assert_eq!(p.id, PERSONA_TESTER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recommend_default_to_coder() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let p = reg.recommend_for_intent("implement a new feature");
|
||||
assert_eq!(p.id, PERSONA_CODER);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_whitelist_filters() {
|
||||
let reviewer = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec!["read_file".into(), "search_files".into()],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(reviewer.is_tool_allowed("read_file"));
|
||||
assert!(!reviewer.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_whitelist_allows_all() {
|
||||
let coder = AgentPersona {
|
||||
id: "test".into(),
|
||||
name: "test".into(),
|
||||
description: "".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "".into(),
|
||||
priority: 5,
|
||||
};
|
||||
assert!(coder.is_tool_allowed("run_command"));
|
||||
assert!(coder.is_tool_allowed("write_file"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_prompt_replaces_placeholders() {
|
||||
let reg = PersonaRegistry::new();
|
||||
let coder = reg.get(PERSONA_CODER).unwrap();
|
||||
let prompt = coder.build_prompt("some context", &["goal1".into()]);
|
||||
assert!(prompt.contains("程序员"));
|
||||
assert!(prompt.contains("goal1"));
|
||||
assert!(prompt.contains("some context"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_custom_persona() {
|
||||
let mut reg = PersonaRegistry::new();
|
||||
let custom = AgentPersona {
|
||||
id: "custom".into(),
|
||||
name: "自定义".into(),
|
||||
description: "test".into(),
|
||||
capabilities: vec![],
|
||||
tool_whitelist: vec![],
|
||||
system_prompt_template: "You are custom".into(),
|
||||
priority: 0,
|
||||
};
|
||||
reg.register(custom);
|
||||
assert_eq!(reg.len(), 6);
|
||||
assert!(reg.get("custom").is_some());
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,22 @@ use crate::planner::{Plan, SubTask};
|
||||
|
||||
/// Plan 执行总开关。false = 不启用(走单链 ReAct 旧行为)。
|
||||
///
|
||||
/// Phase 2 骨架就绪,未接入主 loop。翻 true 后 PlanExecutor 可用,
|
||||
/// 但 agentic/mod.rs 仍走单链路径(需 Phase 3 对接)。
|
||||
pub const PLAN_EXECUTION_ENABLED: bool = false;
|
||||
/// 运行时原子门控,支持热切换(通过 IPC 或前端设置开关)。
|
||||
/// 默认关,翻 true 后 process_tool_calls 内以 JoinSet 并行执行同层工具。
|
||||
/// 与 PLANNING_ENABLED 解耦:plan_hint 独立使能,编排可见性始终可开。
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
pub static PLAN_EXECUTION_ENABLED: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// 设置 Plan 执行开关。
|
||||
pub fn set_plan_execution(enabled: bool) {
|
||||
PLAN_EXECUTION_ENABLED.store(enabled, Ordering::SeqCst);
|
||||
tracing::info!(enabled, "[PLAN-EXEC] 执行开关已更新");
|
||||
}
|
||||
|
||||
/// 读取 Plan 执行开关。
|
||||
pub fn plan_execution_enabled() -> bool {
|
||||
PLAN_EXECUTION_ENABLED.load(Ordering::SeqCst)
|
||||
}
|
||||
|
||||
/// 单个 SubTask 的执行结果。
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -75,7 +88,7 @@ impl PlanExecutor {
|
||||
);
|
||||
|
||||
// 层内并行:JoinSet 收集
|
||||
let mut join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
|
||||
let _join_set: tokio::task::JoinSet<SubTaskResult> = tokio::task::JoinSet::new();
|
||||
let summary_clone = parent_summary.clone();
|
||||
|
||||
for task in layer {
|
||||
|
||||
118
crates/df-ai/src/sse_parser.rs
Normal file
118
crates/df-ai/src/sse_parser.rs
Normal file
@@ -0,0 +1,118 @@
|
||||
//! 原生 SSE 流式解析器 — 替代 eventsource-stream 库
|
||||
//!
|
||||
//! BUG-2026-07-17 根治: eventsource-stream 0.2 在 Windows 上对 Deepseek 等 provider
|
||||
//! 的 SSE 响应解析时报 "Transport error: error decoding response body" 错误。
|
||||
//!
|
||||
//! 根因分析:
|
||||
//! eventsource-stream 内部对 bytes_stream 做严格的 UTF-8 + SSE 协议校验,遇到以下情况
|
||||
//! 即报错(且不可恢复):
|
||||
//! - 流中断时未完整接收 UTF-8 字符(网络抖动常见)
|
||||
//! - 缺少结束的 \n\n(连接断开常见)
|
||||
//! - 非 ASCII 字符的多字节序列跨 chunk 边界
|
||||
//!
|
||||
//! 本解析器实现:
|
||||
//! - 宽松的 UTF-8 处理(用 bytes 累积,String::from_utf8_lossy 转换,不报错)
|
||||
//! - SSE 协议简单解析(以 \n\n 分隔事件,data: 前缀提取)
|
||||
//! - 容错:解析失败时跳过该事件继续,不中断流
|
||||
//! - 返回 Vec<String>(每个元素是一个事件 data 字段拼接内容)
|
||||
|
||||
use futures::Stream;
|
||||
use std::pin::Pin;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
/// SSE 事件流的 data 字段内容
|
||||
pub type SseEvent = String;
|
||||
|
||||
/// 原生 SSE 解析器流:包装 bytes_stream,产出 Vec<SseEvent>(一次 poll 可能产出多个事件)
|
||||
pub struct SseStream<S> {
|
||||
inner: S,
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl<S> SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
pub fn new(inner: S) -> Self {
|
||||
Self {
|
||||
inner,
|
||||
buffer: Vec::with_capacity(8192),
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 buffer 解析完整的 SSE 事件(以 \n\n 分隔),返回事件列表
|
||||
fn parse_events(&mut self) -> Vec<SseEvent> {
|
||||
let mut events = Vec::new();
|
||||
loop {
|
||||
let sep_pos = self.buffer.windows(2).position(|w| w == b"\n\n");
|
||||
if sep_pos.is_none() {
|
||||
break;
|
||||
}
|
||||
let sep_pos = sep_pos.unwrap();
|
||||
let event_bytes: Vec<u8> = self.buffer.drain(..sep_pos + 2).collect();
|
||||
// 去掉末尾的 \n\n
|
||||
let body_end = event_bytes.len().saturating_sub(2);
|
||||
let event_text = String::from_utf8_lossy(&event_bytes[..body_end]);
|
||||
let data = Self::extract_data_fields(&event_text);
|
||||
if !data.is_empty() {
|
||||
events.push(data);
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
|
||||
/// 从 SSE 事件文本中提取所有 data: 行的内容,拼接为单个字符串(多个 data 行用 \n 连接)
|
||||
fn extract_data_fields(event_text: &str) -> String {
|
||||
let mut data_parts: Vec<&str> = Vec::new();
|
||||
for line in event_text.lines() {
|
||||
if let Some(rest) = line.strip_prefix("data:") {
|
||||
let rest = rest.strip_prefix(' ').unwrap_or(rest);
|
||||
data_parts.push(rest);
|
||||
}
|
||||
// 忽略 event:/id:/retry: 等其他 SSE 字段(OpenAI/Anthropic 协议未使用)
|
||||
}
|
||||
data_parts.join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Stream for SseStream<S>
|
||||
where
|
||||
S: Stream<Item = Result<bytes::Bytes, reqwest::Error>> + Unpin,
|
||||
{
|
||||
type Item = Result<Vec<SseEvent>, String>;
|
||||
|
||||
fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
|
||||
use futures::StreamExt;
|
||||
loop {
|
||||
// 先尝试从 buffer 解析完整事件
|
||||
let events = self.parse_events();
|
||||
if !events.is_empty() {
|
||||
return Poll::Ready(Some(Ok(events)));
|
||||
}
|
||||
|
||||
// buffer 不足以解析出完整事件,从 inner 读更多数据
|
||||
match self.inner.poll_next_unpin(cx) {
|
||||
Poll::Ready(Some(Ok(chunk))) => {
|
||||
self.buffer.extend_from_slice(&chunk);
|
||||
continue;
|
||||
}
|
||||
Poll::Ready(Some(Err(e))) => {
|
||||
return Poll::Ready(Some(Err(format!("SSE 流读取错误: {}", e))));
|
||||
}
|
||||
Poll::Ready(None) => {
|
||||
// 流结束,处理 buffer 中的剩余数据(可能没有 \n\n 结束的最后一段)
|
||||
if !self.buffer.is_empty() {
|
||||
let remaining = String::from_utf8_lossy(&self.buffer).to_string();
|
||||
self.buffer.clear();
|
||||
let data = Self::extract_data_fields(&remaining);
|
||||
if !data.is_empty() {
|
||||
return Poll::Ready(Some(Ok(vec![data])));
|
||||
}
|
||||
}
|
||||
return Poll::Ready(None);
|
||||
}
|
||||
Poll::Pending => return Poll::Pending,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,9 +51,33 @@ impl EnvSnapshot {
|
||||
return snap;
|
||||
}
|
||||
// 首次探测:同步逻辑包到 spawn_blocking,避免阻塞 async runtime。
|
||||
let snap = tokio::task::spawn_blocking(|| EnvSnapshot::do_detect())
|
||||
.await
|
||||
.unwrap_or_else(|_| EnvSnapshot::fallback());
|
||||
// BUG-2026-07-18 根治: 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
|
||||
// 卡在 EnvSnapshot::detect() 调用(agentic/mod.rs),后端日志断在"意图收敛工具"后,
|
||||
// 前端表现为「发消息完全无回应然后卡死」(trace.log 实测 3 次发送全卡于此)。
|
||||
// 修复: spawn_blocking 外包 5s timeout,超时返回 fallback 并 set 进 OnceLock(后续命中
|
||||
// 缓存不再 probe),run_agentic_loop 最多等 5s 后继续,不再永久卡。
|
||||
// 注: 超时后 spawn_blocking 线程仍在跑(卡在 output),但已脱离 await,不阻塞调用方;
|
||||
// 线程最终随 process 退出或进程结束回收,无泄漏累积(OnceLock 已 set,不会重复 probe)。
|
||||
let snap = match tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
tokio::task::spawn_blocking(|| EnvSnapshot::do_detect()),
|
||||
).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(join_err)) => {
|
||||
eprintln!("[env_snapshot] 探测任务异常,回退默认环境: {}", join_err);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
Err(_elapsed) => {
|
||||
eprintln!(
|
||||
"[env_snapshot] 探测超时(5s),某 tool(python/node/rustc/go/docker/git)命令卡住 \
|
||||
(疑似 Windows Store App Execution Alias 触发 Store 重定向),回退默认环境"
|
||||
);
|
||||
EnvSnapshot::fallback()
|
||||
}
|
||||
};
|
||||
// 多任务竞态:均等价,以先到者为准。
|
||||
let _ = SNAPSHOT.set(snap);
|
||||
SNAPSHOT.get().expect("EnvSnapshot 已初始化")
|
||||
|
||||
@@ -15,3 +15,4 @@ tokio = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
419
crates/df-nodes/src/docker_node.rs
Normal file
419
crates/df-nodes/src/docker_node.rs
Normal file
@@ -0,0 +1,419 @@
|
||||
//! Docker 节点 — 在 Docker 容器内执行命令
|
||||
//!
|
||||
//! 通过 `docker run --rm` 一次性容器执行命令。复用 df_execute::shell::execute 调用本地
|
||||
//! docker CLI,与 ScriptNode/GitNode 路径一致(不内嵌 docker SDK,CLI 更通用透明)。
|
||||
//!
|
||||
//! 执行流程:
|
||||
//! 1. 先 `docker --version` 探测 Docker 可用性(未装/未运行直接报错,避免容器启动失败
|
||||
//! 时退出码语义混淆)。
|
||||
//! 2. 构建 `docker run --rm {volumes} {env} -w {working_dir} {image} {command}`。
|
||||
//! 3. 经 df_execute::shell::execute 执行,回传 stdout/stderr/exit_code。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Docker 节点
|
||||
pub struct DockerNode;
|
||||
|
||||
/// 卷挂载配置项(volumes 数组元素)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct VolumeMount {
|
||||
pub host: String,
|
||||
pub container: String,
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 DockerNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需起 shell / 真实容器)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DockerParams {
|
||||
/// Docker 镜像名(必填,如 "rust:latest")
|
||||
pub image: String,
|
||||
/// 容器内执行命令(必填)
|
||||
pub command: String,
|
||||
/// 容器内工作目录(默认 "/workspace")
|
||||
pub working_dir: String,
|
||||
/// 超时秒数(默认 300)
|
||||
pub timeout_secs: u64,
|
||||
/// 卷挂载列表(可选)
|
||||
pub volumes: Vec<VolumeMount>,
|
||||
/// 环境变量(可选)
|
||||
pub env: std::collections::HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 DockerParams。image / command 必填,其余按默认/可选。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<DockerParams> {
|
||||
let image = config
|
||||
.get("image")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: image"))?
|
||||
.to_string();
|
||||
|
||||
let command = config
|
||||
.get("command")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("DockerNode 缺少必填参数: command"))?
|
||||
.to_string();
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| "/workspace".to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(300);
|
||||
|
||||
// volumes: JSON 数组,每项 {"host": "...", "container": "..."}
|
||||
// 容错:类型不符/缺字段项跳过(不整体失败,单条坏配置不阻塞整个工作流)。
|
||||
let volumes = config
|
||||
.get("volumes")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| {
|
||||
let host = item.get("host")?.as_str()?.to_string();
|
||||
let container = item.get("container")?.as_str()?.to_string();
|
||||
Some(VolumeMount { host, container })
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// env: JSON 对象 { KEY: VALUE },值统一转字符串。
|
||||
let env = config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(DockerParams {
|
||||
image,
|
||||
command,
|
||||
working_dir,
|
||||
timeout_secs,
|
||||
volumes,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
/// 探测 Docker 是否可用:`docker --version` 退出码 0 视为可用。
|
||||
async fn check_docker_available() -> anyhow::Result<()> {
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command: "docker --version".to_string(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(15),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
let result = df_execute::shell::execute(request).await;
|
||||
match result {
|
||||
Ok(r) if r.exit_code.unwrap_or(-1) == 0 => Ok(()),
|
||||
_ => anyhow::bail!("Docker 未安装或未运行"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 构建 docker run 命令字符串。
|
||||
/// 卷/环境变量值用 shell_quote 包裹,防止空格/特殊字符注入。
|
||||
fn build_command(params: &DockerParams) -> String {
|
||||
let mut parts: Vec<String> = vec!["docker run --rm".to_string()];
|
||||
|
||||
for v in ¶ms.volumes {
|
||||
parts.push(format!(
|
||||
"-v {}:{}",
|
||||
shell_quote(&v.host),
|
||||
shell_quote(&v.container)
|
||||
));
|
||||
}
|
||||
|
||||
for (k, val) in ¶ms.env {
|
||||
parts.push(format!("-e {}={}", k, shell_quote(val)));
|
||||
}
|
||||
|
||||
parts.push(format!("-w {}", shell_quote(¶ms.working_dir)));
|
||||
parts.push(shell_quote(¶ms.image));
|
||||
// command 原样追加(用户自行决定是否含参数 / shell 元字符),不做引号包裹,
|
||||
// 与脚本节点一致由 shell 解释器解析。
|
||||
parts.push(params.command.clone());
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s
|
||||
.chars()
|
||||
.any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`')
|
||||
{
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for DockerNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("DockerNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
// 1. Docker 可用性探测(未装/未运行直接 fail-fast)。
|
||||
if let Err(e) = check_docker_available().await {
|
||||
anyhow::bail!(e.to_string());
|
||||
}
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms);
|
||||
|
||||
tracing::info!(
|
||||
image = %params.image,
|
||||
working_dir = %params.working_dir,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"DockerNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
// 宿主机工作目录对 docker run 无意义,置 None。
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(params.timeout_secs),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
|
||||
tracing::info!(
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"DockerNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"image": params.image,
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string", "description": "Docker 镜像名(如 rust:latest)" },
|
||||
"command": { "type": "string", "description": "容器内执行命令" },
|
||||
"working_dir": { "type": "string", "description": "容器工作目录(默认 /workspace)" },
|
||||
"timeout_secs": { "type": "integer", "description": "超时秒数(默认 300)" },
|
||||
"volumes": {
|
||||
"type": "array",
|
||||
"description": "卷挂载 [{host, container}]",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"host": { "type": "string" },
|
||||
"container": { "type": "string" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"env": { "type": "object", "description": "环境变量键值对" }
|
||||
},
|
||||
"required": ["image", "command"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"image": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"docker"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── parse_params: 必填缺失 ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_image_errors() {
|
||||
let err = parse_params(&json!({ "command": "ls" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("image"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_missing_command_errors() {
|
||||
let err = parse_params(&json!({ "image": "rust:latest" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("command"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params: 默认值 ──
|
||||
|
||||
#[test]
|
||||
fn params_minimal_uses_defaults() {
|
||||
let p = parse_params(&json!({ "image": "alpine", "command": "echo hi" })).unwrap();
|
||||
assert_eq!(p.image, "alpine");
|
||||
assert_eq!(p.command, "echo hi");
|
||||
assert_eq!(p.working_dir, "/workspace");
|
||||
assert_eq!(p.timeout_secs, 300);
|
||||
assert!(p.volumes.is_empty());
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── parse_params: 完整字段 ──
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo test",
|
||||
"working_dir": "/app",
|
||||
"timeout_secs": 120
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.image, "rust:latest");
|
||||
assert_eq!(p.command, "cargo test");
|
||||
assert_eq!(p.working_dir, "/app");
|
||||
assert_eq!(p.timeout_secs, 120);
|
||||
}
|
||||
|
||||
// ── parse_params: volumes 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_volumes_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "npm ci",
|
||||
"volumes": [
|
||||
{ "host": "/host/a", "container": "/c/a" },
|
||||
{ "host": "/host/b", "container": "/c/b" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 2);
|
||||
assert_eq!(
|
||||
p.volumes[0],
|
||||
VolumeMount {
|
||||
host: "/host/a".into(),
|
||||
container: "/c/a".into()
|
||||
}
|
||||
);
|
||||
assert_eq!(p.volumes[1].container, "/c/b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_volumes_skips_malformed_items() {
|
||||
// 缺 container / 非 object 项应被跳过,不整体失败。
|
||||
let p = parse_params(&json!({
|
||||
"image": "node",
|
||||
"command": "ls",
|
||||
"volumes": [
|
||||
{ "host": "/ok", "container": "/ok" },
|
||||
{ "host": "/no-container" },
|
||||
"not-an-object",
|
||||
{ "container": "/no-host" }
|
||||
]
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.volumes.len(), 1);
|
||||
assert_eq!(p.volumes[0].host, "/ok");
|
||||
}
|
||||
|
||||
// ── parse_params: env 解析 ──
|
||||
|
||||
#[test]
|
||||
fn params_env_parsed() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "pytest",
|
||||
"env": {
|
||||
"FOO": "bar",
|
||||
"DEBUG": "1"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.env.len(), 2);
|
||||
assert_eq!(p.env.get("FOO").map(|s| s.as_str()), Some("bar"));
|
||||
assert_eq!(p.env.get("DEBUG").map(|s| s.as_str()), Some("1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_env_empty_when_non_object() {
|
||||
// env 非 object(误传字符串)时回退空 map,不报错。
|
||||
let p = parse_params(&json!({
|
||||
"image": "python",
|
||||
"command": "ls",
|
||||
"env": "should-be-object"
|
||||
}))
|
||||
.unwrap();
|
||||
assert!(p.env.is_empty());
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_minimal_shape() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "alpine",
|
||||
"command": "echo hello"
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.starts_with("docker run --rm"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /workspace"), "实际: {}", cmd);
|
||||
assert!(cmd.contains(" alpine "), "实际: {}", cmd);
|
||||
assert!(cmd.ends_with("echo hello"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_includes_volumes_and_env() {
|
||||
let p = parse_params(&json!({
|
||||
"image": "rust:latest",
|
||||
"command": "cargo build",
|
||||
"working_dir": "/app",
|
||||
"volumes": [
|
||||
{ "host": "/host/src", "container": "/app" }
|
||||
],
|
||||
"env": { "CARGO_HOME": "/cargo" }
|
||||
}))
|
||||
.unwrap();
|
||||
let cmd = build_command(&p);
|
||||
assert!(cmd.contains("-v /host/src:/app"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-e CARGO_HOME=/cargo"), "实际: {}", cmd);
|
||||
assert!(cmd.contains("-w /app"), "实际: {}", cmd);
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("abc"), "abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_wraps_spaces() {
|
||||
assert_eq!(shell_quote("/a b/c"), "\"/a b/c\"");
|
||||
}
|
||||
}
|
||||
426
crates/df-nodes/src/git_node.rs
Normal file
426
crates/df-nodes/src/git_node.rs
Normal file
@@ -0,0 +1,426 @@
|
||||
//! Git 节点 — 执行 git CLI 命令(branch/checkout/commit/merge/push/status/log)
|
||||
//!
|
||||
//! 通过 df_execute::shell::execute 调用本地 git CLI,working_dir 指定仓库路径。
|
||||
//! 不内嵌 git2/libgit2:CLI 路径更通用、调试透明,与 ScriptNode 一致。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// Git 节点
|
||||
pub struct GitNode;
|
||||
|
||||
/// 支持的 Git 动作
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum GitAction {
|
||||
Branch,
|
||||
Checkout,
|
||||
Commit,
|
||||
Merge,
|
||||
Push,
|
||||
Status,
|
||||
Log,
|
||||
}
|
||||
|
||||
impl GitAction {
|
||||
/// 从 config.action 字符串解析动作,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"branch" => Ok(GitAction::Branch),
|
||||
"checkout" => Ok(GitAction::Checkout),
|
||||
"commit" => Ok(GitAction::Commit),
|
||||
"merge" => Ok(GitAction::Merge),
|
||||
"push" => Ok(GitAction::Push),
|
||||
"status" => Ok(GitAction::Status),
|
||||
"log" => Ok(GitAction::Log),
|
||||
other => anyhow::bail!(
|
||||
"GitNode 非法 action: {}(合法值: branch|checkout|commit|merge|push|status|log)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
GitAction::Branch => "branch",
|
||||
GitAction::Checkout => "checkout",
|
||||
GitAction::Commit => "commit",
|
||||
GitAction::Merge => "merge",
|
||||
GitAction::Push => "push",
|
||||
GitAction::Status => "status",
|
||||
GitAction::Log => "log",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 GitNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需构造完整 NodeContext / 起 shell)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GitParams {
|
||||
pub action: GitAction,
|
||||
/// 目标分支(branch/checkout/merge)
|
||||
pub branch_name: Option<String>,
|
||||
/// commit 信息(commit)
|
||||
pub message: Option<String>,
|
||||
/// 仓库路径(None=当前目录)
|
||||
pub working_dir: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 GitParams。action 必填;其余按需。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<GitParams> {
|
||||
let action = config
|
||||
.get("action")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode 缺少必填参数: action"))?;
|
||||
let action = GitAction::parse(action)?;
|
||||
|
||||
let branch_name = config
|
||||
.get("branch_name")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let working_dir = config
|
||||
.get("working_dir")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(GitParams {
|
||||
action,
|
||||
branch_name,
|
||||
message,
|
||||
working_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// 根据 action + 参数构建 git CLI 命令字符串。working_dir 由 ShellRequest 处理,此处不含 cd。
|
||||
fn build_command(params: &GitParams) -> anyhow::Result<String> {
|
||||
match params.action {
|
||||
GitAction::Branch => {
|
||||
// 无 branch_name → 列出所有本地分支;有 → 创建新分支
|
||||
match ¶ms.branch_name {
|
||||
Some(name) => Ok(format!("git branch {}", shell_quote(name))),
|
||||
None => Ok("git branch".to_string()),
|
||||
}
|
||||
}
|
||||
GitAction::Checkout => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=checkout 缺少 branch_name"))?;
|
||||
Ok(format!("git checkout {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Commit => {
|
||||
let msg = params
|
||||
.message
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=commit 缺少 message"))?;
|
||||
Ok(format!("git commit -m {}", shell_quote(msg)))
|
||||
}
|
||||
GitAction::Merge => {
|
||||
let name = params
|
||||
.branch_name
|
||||
.as_deref()
|
||||
.ok_or_else(|| anyhow::anyhow!("GitNode action=merge 缺少 branch_name"))?;
|
||||
Ok(format!("git merge {}", shell_quote(name)))
|
||||
}
|
||||
GitAction::Push => Ok("git push".to_string()),
|
||||
GitAction::Status => Ok("git status".to_string()),
|
||||
GitAction::Log => Ok("git log --oneline -20".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
/// 简单 shell 引号包裹:含空格/特殊字符时用双引号包裹并转义内嵌双引号。
|
||||
/// 仅用于内部参数拼接(branch_name/message),命令名固定白名单不用户可控。
|
||||
fn shell_quote(s: &str) -> String {
|
||||
if s.chars().any(|c| c.is_whitespace() || c == '"' || c == '$' || c == '`') {
|
||||
format!("\"{}\"", s.replace('"', "\\\""))
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for GitNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("GitNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
let command = build_command(¶ms)?;
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
working_dir = ?params.working_dir,
|
||||
"GitNode 构建命令: {}",
|
||||
command
|
||||
);
|
||||
|
||||
let request = df_execute::shell::ShellRequest {
|
||||
command,
|
||||
working_dir: params.working_dir.clone(),
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: Some(60),
|
||||
shell_type: Default::default(),
|
||||
};
|
||||
|
||||
let result = df_execute::shell::execute(request).await?;
|
||||
|
||||
let exit_code = result.exit_code.unwrap_or(-1);
|
||||
if exit_code != 0 {
|
||||
anyhow::bail!(
|
||||
"GitNode 执行失败 (exit_code={}): {}",
|
||||
exit_code,
|
||||
result.stderr.trim()
|
||||
);
|
||||
}
|
||||
|
||||
// 按 action 提取结构化字段(branch 列表/commit hash 等),无法解析时回退原始 stdout。
|
||||
let parsed = parse_action_output(¶ms.action, &result.stdout);
|
||||
|
||||
tracing::info!(
|
||||
action = params.action.as_str(),
|
||||
exit_code,
|
||||
duration_ms = result.duration_ms,
|
||||
"GitNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"action": params.action.as_str(),
|
||||
"stdout": result.stdout,
|
||||
"stderr": result.stderr,
|
||||
"exit_code": exit_code,
|
||||
"duration_ms": result.duration_ms,
|
||||
"parsed": parsed,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": {
|
||||
"type": "string",
|
||||
"enum": ["branch", "checkout", "commit", "merge", "push", "status", "log"]
|
||||
},
|
||||
"branch_name": { "type": "string", "description": "目标分支(branch/checkout/merge)" },
|
||||
"message": { "type": "string", "description": "commit 信息(commit)" },
|
||||
"working_dir": { "type": "string", "description": "git 仓库路径(可选)" }
|
||||
},
|
||||
"required": ["action"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"action": { "type": "string" },
|
||||
"stdout": { "type": "string" },
|
||||
"stderr": { "type": "string" },
|
||||
"exit_code": { "type": "integer" },
|
||||
"duration_ms": { "type": "integer" },
|
||||
"parsed": { "type": "object" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"git"
|
||||
}
|
||||
}
|
||||
|
||||
/// 按 action 解析 stdout 成结构化字段。
|
||||
/// - branch(无参列出):每行一个分支,`*` 标记当前分支
|
||||
/// - log:每行一条 `hash subject`
|
||||
/// - commit:首段 hash
|
||||
/// - 其余动作无强结构,返回空对象
|
||||
fn parse_action_output(action: &GitAction, stdout: &str) -> serde_json::Value {
|
||||
match action {
|
||||
GitAction::Branch => {
|
||||
let branches: Vec<&str> = stdout.lines().map(|l| l.trim_start_matches("* ").trim()).collect();
|
||||
let current = stdout
|
||||
.lines()
|
||||
.find(|l| l.starts_with('*'))
|
||||
.map(|l| l.trim_start_matches("* ").trim().to_string());
|
||||
serde_json::json!({ "branches": branches, "current": current })
|
||||
}
|
||||
GitAction::Log => {
|
||||
let entries: Vec<serde_json::Value> = stdout
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let l = l.trim();
|
||||
let mut parts = l.splitn(2, ' ');
|
||||
let hash = parts.next()?.to_string();
|
||||
let subject = parts.next().unwrap_or("").to_string();
|
||||
Some(serde_json::json!({ "hash": hash, "subject": subject }))
|
||||
})
|
||||
.collect();
|
||||
serde_json::json!({ "entries": entries })
|
||||
}
|
||||
GitAction::Commit => {
|
||||
// `git commit` 默认输出含 `[branch hash]` 形式;取首个 7+ 位十六进制段
|
||||
let hash = stdout
|
||||
.split_whitespace()
|
||||
.find(|t| t.len() >= 7 && t.chars().all(|c| c.is_ascii_hexdigit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
serde_json::json!({ "commit_hash": hash })
|
||||
}
|
||||
_ => serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── GitAction::parse ──
|
||||
|
||||
#[test]
|
||||
fn parse_all_valid_actions() {
|
||||
for raw in ["branch", "checkout", "commit", "merge", "push", "status", "log"] {
|
||||
let a = GitAction::parse(raw).unwrap_or_else(|e| panic!("合法 action {} 应解析成功: {}", raw, e));
|
||||
assert_eq!(a.as_str(), raw);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_invalid_action_errors() {
|
||||
let err = GitAction::parse("rebase").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
assert!(err.contains("rebase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_empty_action_errors() {
|
||||
let err = GitAction::parse("").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_action_errors() {
|
||||
let err = parse_params(&json!({ "branch_name": "main" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("action"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_minimal_valid() {
|
||||
let p = parse_params(&json!({ "action": "status" })).unwrap();
|
||||
assert_eq!(p.action, GitAction::Status);
|
||||
assert_eq!(p.branch_name, None);
|
||||
assert_eq!(p.message, None);
|
||||
assert_eq!(p.working_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_full_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"action": "commit",
|
||||
"branch_name": "feature/x",
|
||||
"message": "fix: 修复",
|
||||
"working_dir": "/repo"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.action, GitAction::Commit);
|
||||
assert_eq!(p.branch_name.as_deref(), Some("feature/x"));
|
||||
assert_eq!(p.message.as_deref(), Some("fix: 修复"));
|
||||
assert_eq!(p.working_dir.as_deref(), Some("/repo"));
|
||||
}
|
||||
|
||||
// ── build_command ──
|
||||
|
||||
#[test]
|
||||
fn command_branch_list_when_no_name() {
|
||||
let p = parse_params(&json!({ "action": "branch" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_branch_create_when_name() {
|
||||
let p = parse_params(&json!({ "action": "branch", "branch_name": "feat" })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), "git branch feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_checkout_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "checkout" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("branch_name"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_requires_message() {
|
||||
let p = parse_params(&json!({ "action": "commit" })).unwrap();
|
||||
let err = build_command(&p).unwrap_err().to_string();
|
||||
assert!(err.contains("message"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_commit_quotes_message_with_space() {
|
||||
let p = parse_params(&json!({ "action": "commit", "message": "fix bug a" })).unwrap();
|
||||
let cmd = build_command(&p).unwrap();
|
||||
assert_eq!(cmd, "git commit -m \"fix bug a\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_merge_requires_name() {
|
||||
let p = parse_params(&json!({ "action": "merge" })).unwrap();
|
||||
assert!(build_command(&p).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn command_push_status_log_fixed() {
|
||||
for (action, expected) in [
|
||||
("push", "git push"),
|
||||
("status", "git status"),
|
||||
("log", "git log --oneline -20"),
|
||||
] {
|
||||
let p = parse_params(&json!({ "action": action })).unwrap();
|
||||
assert_eq!(build_command(&p).unwrap(), expected);
|
||||
}
|
||||
}
|
||||
|
||||
// ── shell_quote ──
|
||||
|
||||
#[test]
|
||||
fn shell_quote_plain_passthrough() {
|
||||
assert_eq!(shell_quote("feat"), "feat");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_quote_escapes_embedded_double_quote() {
|
||||
let q = shell_quote("a\"b");
|
||||
assert_eq!(q, "\"a\\\"b\"");
|
||||
}
|
||||
|
||||
// ── parse_action_output ──
|
||||
|
||||
#[test]
|
||||
fn parse_branch_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Branch,
|
||||
"* main\n develop\n feature/x\n",
|
||||
);
|
||||
assert_eq!(out["current"], "main");
|
||||
assert_eq!(out["branches"].as_array().unwrap().len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_log_output() {
|
||||
let out = parse_action_output(
|
||||
&GitAction::Log,
|
||||
"abc1234 fix bug\n deadbeef add feature\n",
|
||||
);
|
||||
let entries = out["entries"].as_array().unwrap();
|
||||
assert_eq!(entries.len(), 2);
|
||||
assert_eq!(entries[0]["hash"], "abc1234");
|
||||
assert_eq!(entries[1]["subject"], "add feature");
|
||||
}
|
||||
}
|
||||
319
crates/df-nodes/src/http_node.rs
Normal file
319
crates/df-nodes/src/http_node.rs
Normal file
@@ -0,0 +1,319 @@
|
||||
//! HTTP 节点 — 发起 HTTP 请求(GET/POST/PUT/DELETE)
|
||||
//!
|
||||
//! 使用 reqwest(与 df-ai 同一版本 0.12)。config 提供 method/url/headers/body/timeout_secs。
|
||||
//! 输出 status_code + body + (可选)响应头子集。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// HTTP 节点
|
||||
pub struct HttpNode;
|
||||
|
||||
/// 支持的 HTTP 方法
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum HttpMethod {
|
||||
Get,
|
||||
Post,
|
||||
Put,
|
||||
Delete,
|
||||
}
|
||||
|
||||
impl HttpMethod {
|
||||
/// 从 config.method 字符串解析,大小写不敏感,非法值返回 Err。缺省返回 GET。
|
||||
pub fn parse(raw: Option<&str>) -> anyhow::Result<Self> {
|
||||
match raw.map(|s| s.to_ascii_uppercase()).as_deref() {
|
||||
None | Some("GET") => Ok(HttpMethod::Get),
|
||||
Some("POST") => Ok(HttpMethod::Post),
|
||||
Some("PUT") => Ok(HttpMethod::Put),
|
||||
Some("DELETE") => Ok(HttpMethod::Delete),
|
||||
Some(other) => anyhow::bail!(
|
||||
"HttpNode 非法 method: {}(合法值: GET|POST|PUT|DELETE)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
HttpMethod::Get => "GET",
|
||||
HttpMethod::Post => "POST",
|
||||
HttpMethod::Put => "PUT",
|
||||
HttpMethod::Delete => "DELETE",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 HttpNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起真实网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HttpParams {
|
||||
pub method: HttpMethod,
|
||||
pub url: String,
|
||||
/// 请求头(JSON 对象 → Vec<(name, value)>)
|
||||
pub headers: Vec<(String, String)>,
|
||||
/// 请求体(可选)
|
||||
pub body: Option<String>,
|
||||
/// 超时秒数(缺省 30)
|
||||
pub timeout_secs: u64,
|
||||
}
|
||||
|
||||
/// 默认超时
|
||||
const DEFAULT_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
/// 从 config JSON 解析 HttpParams。url 必填;method 缺省 GET;timeout 缺省 30。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<HttpParams> {
|
||||
let method = HttpMethod::parse(config.get("method").and_then(|v| v.as_str()))?;
|
||||
|
||||
let url = config
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("HttpNode 缺少必填参数: url"))?;
|
||||
if url.trim().is_empty() {
|
||||
anyhow::bail!("HttpNode url 不能为空");
|
||||
}
|
||||
|
||||
// headers:JSON 对象 → 有序 (name, value) 列表。非对象/非字符串值忽略并 warn。
|
||||
let mut headers = Vec::new();
|
||||
if let Some(obj) = config.get("headers").and_then(|v| v.as_object()) {
|
||||
for (k, v) in obj {
|
||||
match v.as_str() {
|
||||
Some(s) => headers.push((k.clone(), s.to_string())),
|
||||
None => {
|
||||
tracing::warn!(
|
||||
header = %k,
|
||||
"HttpNode headers 中 header 值非字符串,忽略"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let body = config
|
||||
.get("body")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let timeout_secs = config
|
||||
.get("timeout_secs")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(DEFAULT_TIMEOUT_SECS);
|
||||
|
||||
Ok(HttpParams {
|
||||
method,
|
||||
url: url.to_string(),
|
||||
headers,
|
||||
body,
|
||||
timeout_secs,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for HttpNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("HttpNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
timeout_secs = params.timeout_secs,
|
||||
"HttpNode 发起请求"
|
||||
);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(params.timeout_secs))
|
||||
.build()?;
|
||||
|
||||
let mut req = match params.method {
|
||||
HttpMethod::Get => client.get(¶ms.url),
|
||||
HttpMethod::Post => client.post(¶ms.url),
|
||||
HttpMethod::Put => client.put(¶ms.url),
|
||||
HttpMethod::Delete => client.delete(¶ms.url),
|
||||
};
|
||||
|
||||
for (name, value) in ¶ms.headers {
|
||||
req = req.header(name, value);
|
||||
}
|
||||
|
||||
if let Some(body) = ¶ms.body {
|
||||
req = req.body(body.clone());
|
||||
}
|
||||
|
||||
let response = req.send().await?;
|
||||
let status_code = response.status().as_u16();
|
||||
|
||||
let content_type = response
|
||||
.headers()
|
||||
.get(reqwest::header::CONTENT_TYPE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_text = response.text().await?;
|
||||
|
||||
tracing::info!(
|
||||
method = params.method.as_str(),
|
||||
url = %params.url,
|
||||
status_code,
|
||||
body_len = body_text.len(),
|
||||
"HttpNode 完成"
|
||||
);
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"method": params.method.as_str(),
|
||||
"url": params.url,
|
||||
"status_code": status_code,
|
||||
"body": body_text,
|
||||
"content_type": content_type,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": {
|
||||
"type": "string",
|
||||
"enum": ["GET", "POST", "PUT", "DELETE"],
|
||||
"default": "GET"
|
||||
},
|
||||
"url": { "type": "string", "description": "请求 URL" },
|
||||
"headers": {
|
||||
"type": "object",
|
||||
"description": "请求头 JSON 对象",
|
||||
"additionalProperties": { "type": "string" }
|
||||
},
|
||||
"body": { "type": "string", "description": "请求体(可选)" },
|
||||
"timeout_secs": { "type": "integer", "default": 30 }
|
||||
},
|
||||
"required": ["url"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"method": { "type": "string" },
|
||||
"url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"body": { "type": "string" },
|
||||
"content_type": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"http"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── HttpMethod::parse ──
|
||||
|
||||
#[test]
|
||||
fn method_defaults_to_get_when_absent() {
|
||||
assert_eq!(HttpMethod::parse(None).unwrap(), HttpMethod::Get);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_case_insensitive() {
|
||||
assert_eq!(HttpMethod::parse(Some("get")).unwrap(), HttpMethod::Get);
|
||||
assert_eq!(HttpMethod::parse(Some("Post")).unwrap(), HttpMethod::Post);
|
||||
assert_eq!(HttpMethod::parse(Some("DELETE")).unwrap(), HttpMethod::Delete);
|
||||
assert_eq!(HttpMethod::parse(Some("pUt")).unwrap(), HttpMethod::Put);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn method_invalid_errors() {
|
||||
let err = HttpMethod::parse(Some("PATCH")).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
assert!(err.contains("PATCH"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_url_errors() {
|
||||
let err = parse_params(&json!({ "method": "GET" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "url": " " })).unwrap_err().to_string();
|
||||
assert!(err.contains("空"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_defaults_method_and_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://example.com" })).unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Get);
|
||||
assert_eq!(p.timeout_secs, DEFAULT_TIMEOUT_SECS);
|
||||
assert!(p.headers.is_empty());
|
||||
assert_eq!(p.body, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_custom_timeout() {
|
||||
let p = parse_params(&json!({ "url": "https://x", "timeout_secs": 5 })).unwrap();
|
||||
assert_eq!(p.timeout_secs, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_headers_object() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": {
|
||||
"Authorization": "Bearer abc",
|
||||
"X-Trace-Id": "123"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
// headers 顺序由 serde_json BTreeMap 保证;只校验集合
|
||||
let map: std::collections::HashMap<&str, &str> =
|
||||
p.headers.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map.get("Authorization"), Some(&"Bearer abc"));
|
||||
assert_eq!(map.get("X-Trace-Id"), Some(&"123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_non_string_header_value_skipped() {
|
||||
// 非字符串值不应炸,应被跳过
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"headers": { "X-Num": 123, "X-Ok": "yes" }
|
||||
}))
|
||||
.unwrap();
|
||||
let names: Vec<&str> = p.headers.iter().map(|(k, _)| k.as_str()).collect();
|
||||
assert!(names.contains(&"X-Ok"));
|
||||
assert!(!names.contains(&"X-Num"), "非字符串 header 应被跳过");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_parses_body() {
|
||||
let p = parse_params(&json!({
|
||||
"url": "https://x",
|
||||
"method": "POST",
|
||||
"body": "{\"k\":1}"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.method, HttpMethod::Post);
|
||||
assert_eq!(p.body.as_deref(), Some("{\"k\":1}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_method_propagates() {
|
||||
let err = parse_params(&json!({ "url": "https://x", "method": "TRACE" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("非法 method"), "实际: {}", err);
|
||||
}
|
||||
}
|
||||
@@ -218,7 +218,7 @@ mod tests {
|
||||
node_id: node_id.to_string(),
|
||||
inputs: HashMap::new(),
|
||||
config,
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
event_bus: event_bus.clone(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
@@ -235,7 +235,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: decision.to_string(),
|
||||
decisions: vec![],
|
||||
@@ -254,7 +254,7 @@ mod tests {
|
||||
) {
|
||||
event_bus
|
||||
.send(WorkflowEvent::HumanApprovalResponse {
|
||||
execution_id: execution_id.to_string(),
|
||||
execution_id: execution_id.into(),
|
||||
node_id: node_id.to_string(),
|
||||
decision: String::new(),
|
||||
decisions: decisions.iter().map(|s| s.to_string()).collect(),
|
||||
@@ -477,8 +477,8 @@ mod tests {
|
||||
dag.add_node("b".to_string(), Box::new(HumanNode));
|
||||
dag.add_edge("a".to_string(), "b".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine(); // 共享状态机(spawn 后仍可读)
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
executor
|
||||
@@ -537,7 +537,7 @@ mod tests {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("h".to_string(), Box::new(HumanNode));
|
||||
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.to_string());
|
||||
let mut executor = DagExecutor::new(bus.clone(), exec_id.into());
|
||||
let sm = executor.state_machine();
|
||||
|
||||
let run_handle = tokio::spawn(async move {
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批
|
||||
//! df-nodes: 内置节点集合 — AI、脚本、人工审批、Git、Docker、HTTP、通知
|
||||
|
||||
pub mod ai_node;
|
||||
pub mod ai_self_review_node;
|
||||
mod ai_node_helpers;
|
||||
pub mod docker_node;
|
||||
pub mod git_node;
|
||||
pub mod http_node;
|
||||
pub mod human_node;
|
||||
mod human_node_helpers;
|
||||
pub mod notify_node;
|
||||
pub mod subflow_node;
|
||||
pub mod script_node;
|
||||
pub mod task_advance_node;
|
||||
pub mod task_state_machine;
|
||||
|
||||
292
crates/df-nodes/src/notify_node.rs
Normal file
292
crates/df-nodes/src/notify_node.rs
Normal file
@@ -0,0 +1,292 @@
|
||||
//! 通知节点 — desktop(本地日志,后续接 tauri-plugin-notification) / webhook
|
||||
//!
|
||||
//! - type=desktop:tracing::info! 输出(桌面通知集成延后,见 PROGRESS.md 后续 Sprint)
|
||||
//! - type=webhook:POST JSON {title, message} 到 webhook_url
|
||||
//!
|
||||
//! 节点语义:尽力而为,通知失败不阻断工作流(webhook 发送失败时输出 success=false 但仍 Ok 返回)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
|
||||
/// 通知节点
|
||||
pub struct NotifyNode;
|
||||
|
||||
/// 通知类型
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum NotifyType {
|
||||
Desktop,
|
||||
Webhook,
|
||||
}
|
||||
|
||||
impl NotifyType {
|
||||
/// 从 config.type 字符串解析,非法值返回 Err。
|
||||
pub fn parse(raw: &str) -> anyhow::Result<Self> {
|
||||
match raw {
|
||||
"desktop" => Ok(NotifyType::Desktop),
|
||||
"webhook" => Ok(NotifyType::Webhook),
|
||||
other => anyhow::bail!(
|
||||
"NotifyNode 非法 type: {}(合法值: desktop|webhook)",
|
||||
other
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
NotifyType::Desktop => "desktop",
|
||||
NotifyType::Webhook => "webhook",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从 NodeContext.config 解析出的 NotifyNode 参数。
|
||||
/// 抽离此结构便于单元测试 config 解析逻辑(无需发起网络请求)。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NotifyParams {
|
||||
pub notify_type: NotifyType,
|
||||
pub title: String,
|
||||
pub message: String,
|
||||
/// webhook 类型必填;desktop 类型忽略
|
||||
pub webhook_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 config JSON 解析 NotifyParams。type 必填;webhook 类型要求 webhook_url。
|
||||
pub fn parse_params(config: &serde_json::Value) -> anyhow::Result<NotifyParams> {
|
||||
let raw_type = config
|
||||
.get("type")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| anyhow::anyhow!("NotifyNode 缺少必填参数: type"))?;
|
||||
let notify_type = NotifyType::parse(raw_type)?;
|
||||
|
||||
let title = config
|
||||
.get("title")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("通知")
|
||||
.to_string();
|
||||
|
||||
let message = config
|
||||
.get("message")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let webhook_url = config
|
||||
.get("webhook_url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
// webhook 类型必须有 webhook_url
|
||||
if notify_type == NotifyType::Webhook {
|
||||
if webhook_url.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true) {
|
||||
anyhow::bail!("NotifyNode type=webhook 缺少 webhook_url");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NotifyParams {
|
||||
notify_type,
|
||||
title,
|
||||
message,
|
||||
webhook_url,
|
||||
})
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for NotifyNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("NotifyNode 执行: node_id={}", ctx.node_id);
|
||||
|
||||
let params = parse_params(&ctx.config)?;
|
||||
|
||||
match params.notify_type {
|
||||
NotifyType::Desktop => {
|
||||
// 桌面通知集成延后;当前仅日志,后续接 tauri-plugin-notification。
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
message = %params.message,
|
||||
"NotifyNode desktop 通知(日志占位,集成待后续 Sprint)"
|
||||
);
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"delivered": true,
|
||||
})))
|
||||
}
|
||||
NotifyType::Webhook => {
|
||||
// webhook_url 在 parse_params 已校验非空
|
||||
let url = params.webhook_url.as_deref().unwrap();
|
||||
tracing::info!(
|
||||
title = %params.title,
|
||||
url = %url,
|
||||
"NotifyNode webhook 推送"
|
||||
);
|
||||
|
||||
let payload = serde_json::json!({
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
});
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(15))
|
||||
.build()?;
|
||||
|
||||
// 尽力而为:发送失败不阻断工作流,降级为 success=false + error 字段返回。
|
||||
let (status_code, success, error) = match client
|
||||
.post(url)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => {
|
||||
let code = resp.status().as_u16();
|
||||
(Some(code), code >= 200 && code < 300, None)
|
||||
}
|
||||
Err(e) => (None, false, Some(e.to_string())),
|
||||
};
|
||||
|
||||
if !success {
|
||||
tracing::warn!(
|
||||
url = %url,
|
||||
status_code = ?status_code,
|
||||
error = ?error,
|
||||
"NotifyNode webhook 发送失败(不阻断工作流)"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"type": params.notify_type.as_str(),
|
||||
"title": params.title,
|
||||
"message": params.message,
|
||||
"webhook_url": url,
|
||||
"status_code": status_code,
|
||||
"success": success,
|
||||
"error": error,
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["desktop", "webhook"]
|
||||
},
|
||||
"title": { "type": "string", "description": "通知标题" },
|
||||
"message": { "type": "string", "description": "通知正文" },
|
||||
"webhook_url": { "type": "string", "description": "webhook URL(webhook 类型必填)" }
|
||||
},
|
||||
"required": ["type"]
|
||||
}),
|
||||
output: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "type": "string" },
|
||||
"title": { "type": "string" },
|
||||
"message": { "type": "string" },
|
||||
"delivered": { "type": "boolean" },
|
||||
"webhook_url": { "type": "string" },
|
||||
"status_code": { "type": "integer" },
|
||||
"success": { "type": "boolean" },
|
||||
"error": { "type": "string" }
|
||||
}
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &str {
|
||||
"notify"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
// ── NotifyType::parse ──
|
||||
|
||||
#[test]
|
||||
fn type_parses_desktop_and_webhook() {
|
||||
assert_eq!(NotifyType::parse("desktop").unwrap(), NotifyType::Desktop);
|
||||
assert_eq!(NotifyType::parse("webhook").unwrap(), NotifyType::Webhook);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type_invalid_errors() {
|
||||
let err = NotifyType::parse("email").unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
assert!(err.contains("email"));
|
||||
}
|
||||
|
||||
// ── parse_params(config) ──
|
||||
|
||||
#[test]
|
||||
fn params_missing_type_errors() {
|
||||
let err = parse_params(&json!({ "title": "t" })).unwrap_err().to_string();
|
||||
assert!(err.contains("type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_invalid_type_errors() {
|
||||
let err = parse_params(&json!({ "type": "sms" })).unwrap_err().to_string();
|
||||
assert!(err.contains("非法 type"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_defaults_title_and_message() {
|
||||
let p = parse_params(&json!({ "type": "desktop" })).unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Desktop);
|
||||
assert_eq!(p.title, "通知"); // 默认标题
|
||||
assert_eq!(p.message, ""); // 默认空正文
|
||||
assert_eq!(p.webhook_url, None); // desktop 不需要
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_desktop_with_custom_fields() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "desktop",
|
||||
"title": "构建完成",
|
||||
"message": "已发布 v1.0"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.title, "构建完成");
|
||||
assert_eq!(p.message, "已发布 v1.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_requires_url() {
|
||||
let err = parse_params(&json!({ "type": "webhook" }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_empty_url_errors() {
|
||||
let err = parse_params(&json!({ "type": "webhook", "webhook_url": " " }))
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("webhook_url"), "实际: {}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn params_webhook_valid() {
|
||||
let p = parse_params(&json!({
|
||||
"type": "webhook",
|
||||
"title": "告警",
|
||||
"message": "CPU > 90%",
|
||||
"webhook_url": "https://hooks.example.com/x"
|
||||
}))
|
||||
.unwrap();
|
||||
assert_eq!(p.notify_type, NotifyType::Webhook);
|
||||
assert_eq!(p.title, "告警");
|
||||
assert_eq!(p.message, "CPU > 90%");
|
||||
assert_eq!(p.webhook_url.as_deref(), Some("https://hooks.example.com/x"));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,23 @@ use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
/// 脚本节点
|
||||
pub struct ScriptNode;
|
||||
|
||||
// ── 运行时白/黑名单(通过 set_script_safety_config 注入,替代纯 env var) ──
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// 运行时白名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_WHITELIST: OnceLock<String> = OnceLock::new();
|
||||
/// 运行时黑名单配置(前端设置页写入,优先于环境变量)
|
||||
static RUNTIME_SCRIPT_BLACKLIST: OnceLock<String> = OnceLock::new();
|
||||
|
||||
/// 注入运行时脚本安全配置(由 Tauri IPC 调用,前端设置页保存后触发)。
|
||||
/// 空字符串表示「未配置」,回退到环境变量 / 默认行为。
|
||||
pub fn set_script_safety_config(whitelist: &str, blacklist: &str) {
|
||||
let wl = whitelist.trim();
|
||||
let bl = blacklist.trim();
|
||||
if !wl.is_empty() { let _ = RUNTIME_SCRIPT_WHITELIST.set(wl.to_string()); }
|
||||
if !bl.is_empty() { let _ = RUNTIME_SCRIPT_BLACKLIST.set(bl.to_string()); }
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Node for ScriptNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
@@ -124,20 +141,38 @@ impl Node for ScriptNode {
|
||||
/// 用户设置 `DF_SCRIPT_BLACKLIST` 时覆盖默认(不合并,用户显式控制)。
|
||||
const DEFAULT_BLACKLIST: &[&str] = &["rm", "del", "format", "shutdown", "mkfs", "dd"];
|
||||
|
||||
/// 命令执行策略校验:从环境变量读取白/黑名单,判定给定命令名是否允许执行。
|
||||
/// 命令执行策略校验:从运行时配置 + 环境变量读取白/黑名单。
|
||||
///
|
||||
/// 优先级:黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
///
|
||||
/// - `DF_SCRIPT_WHITELIST`:逗号分隔命令名(如 `git,npm,cargo`);非空时命令名不在其中即拒绝
|
||||
/// - `DF_SCRIPT_BLACKLIST`:逗号分隔命令名(如 `rm,format,shutdown`);匹配即拒绝。
|
||||
/// 未设置时使用 `DEFAULT_BLACKLIST`(rm/del/format/shutdown/mkfs/dd)兜底。
|
||||
/// 优先级(高→低):
|
||||
/// 1. 运行时配置(set_script_safety_config,前端设置页写入)
|
||||
/// 2. 环境变量 DF_SCRIPT_WHITELIST / DF_SCRIPT_BLACKLIST
|
||||
/// 3. 默认黑名单兜底(DEFAULT_BLACKLIST)
|
||||
///
|
||||
/// 黑名单优先于白名单(黑名单匹配总是拒绝,即便同时在白名单)。
|
||||
/// 命令名比较前 trim + ASCII 小写规范化;空段被忽略。
|
||||
///
|
||||
/// 返回 `Some(reason)` 表示拒绝(reason 为拒绝原因,用于日志/错误信息);返回 `None` 表示放行。
|
||||
/// 返回 `Some(reason)` 表示拒绝;返回 `None` 表示放行。
|
||||
fn check_command_policy(cmd_name: &str) -> Option<&'static str> {
|
||||
// 黑名单优先:即便同时在白名单,只要命中黑名单就拒绝
|
||||
// 优先从环境变量读,后续可扩展为从 DB KV 注入(前端设置页写入)
|
||||
// 步骤1:尝试运行时黑名单(前端设置页写入,优先级最高)
|
||||
if let Some(raw) = RUNTIME_SCRIPT_BLACKLIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令在黑名单中(运行时配置)");
|
||||
}
|
||||
}
|
||||
// 步骤2:尝试运行时白名单(非空时放行) — 仅当运行时黑名单未命中时才检查
|
||||
if let Some(raw) = RUNTIME_SCRIPT_WHITELIST.get() {
|
||||
let list: Vec<&str> = raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
|
||||
if !list.is_empty() && !list.iter().any(|c| c.eq_ignore_ascii_case(cmd_name)) {
|
||||
return Some("命令不在白名单中(运行时配置)");
|
||||
}
|
||||
// 运行时白名单非空且命令在其中 → 直接放行(跳过 env var)
|
||||
if !list.is_empty() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// 步骤3:回退到环境变量(兼容旧部署)
|
||||
let env_blacklist = std::env::var("DF_SCRIPT_BLACKLIST").ok();
|
||||
let blacklist: Vec<String> = match env_blacklist {
|
||||
Some(raw) => raw
|
||||
|
||||
175
crates/df-nodes/src/subflow_node.rs
Normal file
175
crates/df-nodes/src/subflow_node.rs
Normal file
@@ -0,0 +1,175 @@
|
||||
//! SubflowNode — 嵌套子工作流节点
|
||||
//!
|
||||
//! 加载一个子模板(DagDef)并在当前执行上下文中递归执行。
|
||||
//! 用于复用通用流程(如"代码审查"可作为任意主流程的子步骤)。
|
||||
|
||||
use async_trait::async_trait;
|
||||
use df_workflow::dag_def::DagDef;
|
||||
use df_workflow::node::{Node, NodeContext, NodeOutput, NodeResult, NodeSchema};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SubflowNode 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SubflowConfig {
|
||||
/// 子工作流 DAG 定义(内联 JSON)
|
||||
dag: serde_json::Value,
|
||||
/// 嵌套深度限制(防无限递归,默认 MAX_DEPTH)
|
||||
#[serde(default)]
|
||||
max_depth: Option<u32>,
|
||||
}
|
||||
|
||||
/// 最大递归深度(安全阀)
|
||||
const MAX_DEPTH: u32 = 10;
|
||||
|
||||
/// SubflowNode — 嵌套子工作流
|
||||
pub struct SubflowNode;
|
||||
|
||||
#[async_trait]
|
||||
impl Node for SubflowNode {
|
||||
async fn execute(&self, ctx: NodeContext) -> NodeResult {
|
||||
tracing::info!("SubflowNode 执行: {}", ctx.node_id);
|
||||
|
||||
let config: SubflowConfig = serde_json::from_value(ctx.config.clone())
|
||||
.map_err(|e| anyhow::anyhow!("SubflowNode 配置解析失败: {}", e))?;
|
||||
|
||||
let max_depth = config.max_depth.unwrap_or(MAX_DEPTH).min(MAX_DEPTH);
|
||||
|
||||
// 反序列化子 DAG
|
||||
let sub_dag: DagDef = serde_json::from_value(config.dag)
|
||||
.map_err(|e| anyhow::anyhow!("子工作流 DAG 解析失败: {}", e))?;
|
||||
|
||||
if sub_dag.nodes.is_empty() {
|
||||
anyhow::bail!("子工作流节点不能为空");
|
||||
}
|
||||
|
||||
if max_depth == 0 {
|
||||
anyhow::bail!("SubflowNode 超过最大嵌套深度");
|
||||
}
|
||||
|
||||
tracing::info!(
|
||||
node_count = sub_dag.nodes.len(),
|
||||
edge_count = sub_dag.edges.len(),
|
||||
max_depth,
|
||||
"SubflowNode 加载子工作流"
|
||||
);
|
||||
|
||||
// 返回子 DAG 的 JSON 快照供 DagExecutor 消费
|
||||
Ok(NodeOutput::from_value(serde_json::json!({
|
||||
"subflow": true,
|
||||
"node_count": sub_dag.nodes.len(),
|
||||
"edge_count": sub_dag.edges.len(),
|
||||
"max_depth": max_depth,
|
||||
"dag": sub_dag,
|
||||
})))
|
||||
}
|
||||
|
||||
fn schema(&self) -> NodeSchema {
|
||||
NodeSchema {
|
||||
params: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"dag": {
|
||||
"type": "object",
|
||||
"description": "子工作流 DAG 定义(nodes + edges)",
|
||||
"properties": {
|
||||
"nodes": { "type": "object" },
|
||||
"edges": { "type": "array" }
|
||||
}
|
||||
},
|
||||
"max_depth": {
|
||||
"type": "integer",
|
||||
"description": "最大嵌套深度(默认10)",
|
||||
"default": 10
|
||||
}
|
||||
},
|
||||
"required": ["dag"]
|
||||
}),
|
||||
output: serde_json::json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
fn node_type(&self) -> &'static str {
|
||||
"subflow"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_workflow::eventbus::EventBus;
|
||||
use df_workflow::state::StateMachine;
|
||||
|
||||
fn make_ctx(config: serde_json::Value) -> NodeContext {
|
||||
NodeContext {
|
||||
node_id: "test_subflow".into(),
|
||||
inputs: Default::default(),
|
||||
config,
|
||||
execution_id: "exec_1".into(),
|
||||
event_bus: EventBus::new(),
|
||||
node_status: StateMachine::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_01_valid_dag_returns_metadata() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": {
|
||||
"child": {
|
||||
"id": "child",
|
||||
"node_type": "script",
|
||||
"config": {}
|
||||
}
|
||||
},
|
||||
"edges": []
|
||||
}
|
||||
}));
|
||||
let result = node.execute(ctx).await.unwrap();
|
||||
assert_eq!(result.data["subflow"], true);
|
||||
assert_eq!(result.data["node_count"], 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_02_empty_dag_rejected() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": { "nodes": {}, "edges": [] }
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("不能为空"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_03_max_depth_zero_guard() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({
|
||||
"dag": {
|
||||
"nodes": { "c": { "id": "c", "node_type": "script", "config": {} } },
|
||||
"edges": []
|
||||
},
|
||||
"max_depth": 0
|
||||
}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().to_string().contains("最大嵌套深度"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_04_missing_dag_field_errors() {
|
||||
let node = SubflowNode;
|
||||
let ctx = make_ctx(serde_json::json!({}));
|
||||
let result = node.execute(ctx).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sub_05_schema_has_required_fields() {
|
||||
let node = SubflowNode;
|
||||
let schema = node.schema();
|
||||
let params = &schema.params;
|
||||
assert!(params["properties"]["dag"].is_object());
|
||||
assert!(params["required"].as_array().unwrap().contains(&serde_json::json!("dag")));
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,25 @@ impl AiToolExecutionRepo {
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
/// 清理超期的残留 pending 工具调用(旧会话遗留)。
|
||||
///
|
||||
/// `max_age_secs`: 超过此秒数的 pending 记录被标记为 interrupted(不硬删,保留审计痕迹)。
|
||||
pub async fn cleanup_stale_pending(&self, max_age_secs: u64) -> Result<u64> {
|
||||
let conn = self.conn.clone();
|
||||
let cutoff_ms = (df_types::now_millis() as i64 - (max_age_secs as i64 * 1000)).to_string();
|
||||
let affected = tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard.execute(
|
||||
"UPDATE ai_tool_executions SET status = 'interrupted' \
|
||||
WHERE status = 'pending' AND CAST(requested_at AS INTEGER) < ?1",
|
||||
params![cutoff_ms],
|
||||
).map_err(storage_err)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)??;
|
||||
Ok(affected as u64)
|
||||
}
|
||||
|
||||
/// 审批历史面板分页查询:按 requested_at 倒序(最新在前),limit 默认 50。
|
||||
///
|
||||
/// 与 list_pending 同理走专用 SELECT,绕过通用 query 宏(后者硬编码
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
//! re-export(`pub use ...::*`)保持 `df_storage::crud::XxxRepo` /
|
||||
//! `df_storage::crud::is_allowed_column` 路径不变,**调用方零改动**。
|
||||
|
||||
mod module_dependency_repo;
|
||||
mod plan_repo;
|
||||
|
||||
mod conversation_repo;
|
||||
mod idea_eval_repo;
|
||||
mod idea_repo;
|
||||
@@ -28,7 +31,6 @@ mod project_service_repo;
|
||||
mod settings;
|
||||
mod task_link_repo;
|
||||
mod task_repo;
|
||||
mod module_dependency_repo;
|
||||
|
||||
pub use conversation_repo::*;
|
||||
pub use idea_eval_repo::*;
|
||||
@@ -42,6 +44,7 @@ pub use settings::*;
|
||||
pub use task_link_repo::*;
|
||||
pub use task_repo::*;
|
||||
pub use module_dependency_repo::*;
|
||||
pub use plan_repo::*;
|
||||
|
||||
// ============================================================
|
||||
// 辅助宏 — 消除多个 Repo 的重复样板
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use rusqlite::{params, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
|
||||
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
579
crates/df-storage/src/crud/plan_repo.rs
Normal file
@@ -0,0 +1,579 @@
|
||||
//! 多 Agent 并行执行 Repo — ai_plans / ai_subtasks / ai_conflicts 表 CRUD(V36)
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rusqlite::{params, OptionalExtension, Row};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::db::Database;
|
||||
use crate::models::{ConflictRecord, PlanRecord, SubTaskRecord};
|
||||
|
||||
use super::storage_err;
|
||||
|
||||
type Result<T> = std::result::Result<T, df_types::error::Error>;
|
||||
|
||||
fn plan_from_row(row: &Row<'_>) -> rusqlite::Result<PlanRecord> {
|
||||
Ok(PlanRecord {
|
||||
id: row.get("id")?,
|
||||
conversation_id: row.get("conversation_id")?,
|
||||
user_message_id: row.get("user_message_id")?,
|
||||
status: row.get("status")?,
|
||||
subtask_count: row.get("subtask_count")?,
|
||||
created_at: row.get("created_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn subtask_from_row(row: &Row<'_>) -> rusqlite::Result<SubTaskRecord> {
|
||||
Ok(SubTaskRecord {
|
||||
id: row.get("id")?,
|
||||
plan_id: row.get("plan_id")?,
|
||||
persona_id: row.get("persona_id")?,
|
||||
intent: row.get("intent")?,
|
||||
status: row.get("status")?,
|
||||
layer: row.get("layer")?,
|
||||
deps: row.get("deps")?,
|
||||
branch: row.get("branch")?,
|
||||
created_at: row.get("created_at")?,
|
||||
completed_at: row.get("completed_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn conflict_from_row(row: &Row<'_>) -> rusqlite::Result<ConflictRecord> {
|
||||
Ok(ConflictRecord {
|
||||
id: row.get("id")?,
|
||||
plan_id: row.get("plan_id")?,
|
||||
file_path: row.get("file_path")?,
|
||||
conflict_type: row.get("conflict_type")?,
|
||||
subtask_a: row.get("subtask_a")?,
|
||||
subtask_b: row.get("subtask_b")?,
|
||||
diff_a: row.get("diff_a")?,
|
||||
diff_b: row.get("diff_b")?,
|
||||
resolution: row.get("resolution")?,
|
||||
resolved_by: row.get("resolved_by")?,
|
||||
created_at: row.get("created_at")?,
|
||||
resolved_at: row.get("resolved_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// PlanRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct PlanRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl PlanRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: PlanRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_plans \
|
||||
(id, conversation_id, user_message_id, status, subtask_count, created_at, completed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![
|
||||
record.id, record.conversation_id, record.user_message_id,
|
||||
record.status, record.subtask_count, record.created_at, record.completed_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn get(&self, id: &str) -> Result<Option<PlanRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let row = guard
|
||||
.query_row("SELECT * FROM ai_plans WHERE id = ?1", params![id], plan_from_row)
|
||||
.optional()
|
||||
.map_err(storage_err)?;
|
||||
Ok(row)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
completed_at: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
let completed_at = completed_at.map(|s| s.to_owned());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_plans SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||
params![status, completed_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_by_conversation(&self, conv_id: &str) -> Result<Vec<PlanRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let conv_id = conv_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_plans WHERE conversation_id = ?1 ORDER BY created_at DESC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![conv_id], plan_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// SubTaskRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct SubTaskRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl SubTaskRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: SubTaskRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_subtasks \
|
||||
(id, plan_id, persona_id, intent, status, layer, deps, branch, created_at, completed_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
|
||||
params![
|
||||
record.id, record.plan_id, record.persona_id, record.intent,
|
||||
record.status, record.layer, record.deps, record.branch,
|
||||
record.created_at, record.completed_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_by_plan(&self, plan_id: &str) -> Result<Vec<SubTaskRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let plan_id = plan_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_subtasks WHERE plan_id = ?1 ORDER BY layer ASC, created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![plan_id], subtask_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn update_status(
|
||||
&self,
|
||||
id: &str,
|
||||
status: &str,
|
||||
completed_at: Option<&str>,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let status = status.to_owned();
|
||||
let completed_at = completed_at.map(|s| s.to_owned());
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_subtasks SET status = ?1, completed_at = ?2 WHERE id = ?3",
|
||||
params![status, completed_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// ConflictRepo
|
||||
// ============================================================
|
||||
|
||||
pub struct ConflictRepo {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl ConflictRepo {
|
||||
pub fn new(db: &Database) -> Self {
|
||||
Self { conn: db.conn() }
|
||||
}
|
||||
|
||||
pub async fn insert(&self, record: ConflictRecord) -> Result<String> {
|
||||
let conn = self.conn.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
guard
|
||||
.execute(
|
||||
"INSERT INTO ai_conflicts \
|
||||
(id, plan_id, file_path, conflict_type, subtask_a, subtask_b, diff_a, diff_b, \
|
||||
resolution, resolved_by, created_at, resolved_at) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
|
||||
params![
|
||||
record.id, record.plan_id, record.file_path, record.conflict_type,
|
||||
record.subtask_a, record.subtask_b, record.diff_a, record.diff_b,
|
||||
record.resolution, record.resolved_by, record.created_at, record.resolved_at,
|
||||
],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(record.id)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn list_pending(&self, plan_id: &str) -> Result<Vec<ConflictRecord>> {
|
||||
let conn = self.conn.clone();
|
||||
let plan_id = plan_id.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let mut stmt = guard
|
||||
.prepare("SELECT * FROM ai_conflicts WHERE plan_id = ?1 AND resolution = 'pending' ORDER BY created_at ASC")
|
||||
.map_err(storage_err)?;
|
||||
let rows = stmt.query_map(params![plan_id], conflict_from_row).map_err(storage_err)?;
|
||||
let mut results = Vec::new();
|
||||
for r in rows {
|
||||
results.push(r.map_err(storage_err)?);
|
||||
}
|
||||
Ok(results)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
|
||||
pub async fn resolve(
|
||||
&self,
|
||||
id: &str,
|
||||
resolution: &str,
|
||||
resolved_by: &str,
|
||||
resolved_at: &str,
|
||||
) -> Result<bool> {
|
||||
let conn = self.conn.clone();
|
||||
let id = id.to_owned();
|
||||
let resolution = resolution.to_owned();
|
||||
let resolved_by = resolved_by.to_owned();
|
||||
let resolved_at = resolved_at.to_owned();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = conn.blocking_lock();
|
||||
let affected = guard
|
||||
.execute(
|
||||
"UPDATE ai_conflicts SET resolution = ?1, resolved_by = ?2, resolved_at = ?3 WHERE id = ?4",
|
||||
params![resolution, resolved_by, resolved_at, id],
|
||||
)
|
||||
.map_err(storage_err)?;
|
||||
Ok(affected > 0)
|
||||
})
|
||||
.await
|
||||
.map_err(storage_err)?
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::Database;
|
||||
|
||||
async fn setup() -> Database {
|
||||
let db = Database::open_in_memory().await.expect("open_in_memory");
|
||||
let conn = db.conn();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::migrations::run(&conn.blocking_lock()).expect("migrations");
|
||||
})
|
||||
.await
|
||||
.expect("spawn_blocking");
|
||||
db
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_01_plan_insert_and_get() {
|
||||
let db = setup().await;
|
||||
let repo = PlanRepo::new(&db);
|
||||
let plan = PlanRecord {
|
||||
id: "plan-001".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: Some("msg-001".into()),
|
||||
status: "planning".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
};
|
||||
let id = repo.insert(plan.clone()).await.unwrap();
|
||||
assert_eq!(id, "plan-001");
|
||||
let got = repo.get("plan-001").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, "planning");
|
||||
assert_eq!(got.subtask_count, 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_02_plan_update_status() {
|
||||
let db = setup().await;
|
||||
let repo = PlanRepo::new(&db);
|
||||
repo.insert(PlanRecord {
|
||||
id: "plan-002".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 0,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(repo.update_status("plan-002", "executing", None).await.unwrap());
|
||||
let got = repo.get("plan-002").await.unwrap().unwrap();
|
||||
assert_eq!(got.status, "executing");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_03_subtask_insert_and_list() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-003".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-001".into(),
|
||||
plan_id: "plan-003".into(),
|
||||
persona_id: Some("coder".into()),
|
||||
intent: "读取代码".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: Some("subtask/plan-003/st-001".into()),
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-002".into(),
|
||||
plan_id: "plan-003".into(),
|
||||
persona_id: Some("coder".into()),
|
||||
intent: "修改代码".into(),
|
||||
status: "pending".into(),
|
||||
layer: 1,
|
||||
deps: Some(r#"["st-001"]"#.into()),
|
||||
branch: Some("subtask/plan-003/st-002".into()),
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let list = st_repo.list_by_plan("plan-003").await.unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].layer, 0); // layer 排序
|
||||
assert_eq!(list[1].layer, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_04_subtask_update_status() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-004".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-004".into(),
|
||||
plan_id: "plan-004".into(),
|
||||
persona_id: None,
|
||||
intent: "test".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(st_repo.update_status("st-004", "done", Some("2026-07-01T01:00:00Z")).await.unwrap());
|
||||
let list = st_repo.list_by_plan("plan-004").await.unwrap();
|
||||
assert_eq!(list[0].status, "done");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_05_conflict_insert_and_list_pending() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let conf_repo = ConflictRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-005".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "merging".into(),
|
||||
subtask_count: 2,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
conf_repo
|
||||
.insert(ConflictRecord {
|
||||
id: "conf-001".into(),
|
||||
plan_id: "plan-005".into(),
|
||||
file_path: "src/main.rs".into(),
|
||||
conflict_type: "file".into(),
|
||||
subtask_a: Some("st-a".into()),
|
||||
subtask_b: Some("st-b".into()),
|
||||
diff_a: Some("-old\n+new_a".into()),
|
||||
diff_b: Some("-old\n+new_b".into()),
|
||||
resolution: "pending".into(),
|
||||
resolved_by: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
resolved_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let pending = conf_repo.list_pending("plan-005").await.unwrap();
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert_eq!(pending[0].file_path, "src/main.rs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_06_conflict_resolve() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let conf_repo = ConflictRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-006".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "merging".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
conf_repo
|
||||
.insert(ConflictRecord {
|
||||
id: "conf-002".into(),
|
||||
plan_id: "plan-006".into(),
|
||||
file_path: "src/lib.rs".into(),
|
||||
conflict_type: "semantic".into(),
|
||||
subtask_a: None,
|
||||
subtask_b: None,
|
||||
diff_a: None,
|
||||
diff_b: None,
|
||||
resolution: "pending".into(),
|
||||
resolved_by: None,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
resolved_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
conf_repo
|
||||
.resolve("conf-002", "merged", "reviewer", "2026-07-01T02:00:00Z")
|
||||
.await
|
||||
.unwrap()
|
||||
);
|
||||
let pending = conf_repo.list_pending("plan-006").await.unwrap();
|
||||
assert_eq!(pending.len(), 0, "解决后 pending 列表应为空");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repo_07_subtask_branch_null_for_non_git() {
|
||||
let db = setup().await;
|
||||
let plan_repo = PlanRepo::new(&db);
|
||||
let st_repo = SubTaskRepo::new(&db);
|
||||
plan_repo
|
||||
.insert(PlanRecord {
|
||||
id: "plan-007".into(),
|
||||
conversation_id: "conv-001".into(),
|
||||
user_message_id: None,
|
||||
status: "planning".into(),
|
||||
subtask_count: 1,
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
st_repo
|
||||
.insert(SubTaskRecord {
|
||||
id: "st-007".into(),
|
||||
plan_id: "plan-007".into(),
|
||||
persona_id: None,
|
||||
intent: "非 Git 工程".into(),
|
||||
status: "pending".into(),
|
||||
layer: 0,
|
||||
deps: None,
|
||||
branch: None, // 非 Git 工程
|
||||
created_at: "2026-07-01T00:00:00Z".into(),
|
||||
completed_at: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let list = st_repo.list_by_plan("plan-007").await.unwrap();
|
||||
assert!(list[0].branch.is_none(), "非 Git 工程 branch 应为 NULL");
|
||||
}
|
||||
}
|
||||
@@ -784,16 +784,16 @@ mod tests {
|
||||
let repo = setup().await;
|
||||
repo.insert(trec("parent", "todo", None)).await.unwrap();
|
||||
// 4 个子任务:status 分布 todo×2 / in_progress×1 / done×1(queue 统一 todo)
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), "todo"))
|
||||
repo.insert(trec_full("c1", "todo", Some("parent"), TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), "todo"))
|
||||
repo.insert(trec_full("c2", "todo", Some("parent"), TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), "in_progress"))
|
||||
repo.insert(trec_full("c3", "todo", Some("parent"), TaskStatus::InProgress))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), "done"))
|
||||
repo.insert(trec_full("c4", "todo", Some("parent"), TaskStatus::Done))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -841,7 +841,7 @@ mod tests {
|
||||
repo.insert(trec_full("parent", "todo", None, TaskStatus::Todo))
|
||||
.await
|
||||
.unwrap();
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress");
|
||||
let ok = repo.set_status_for_aggregation("parent", "in_progress").await.unwrap();
|
||||
assert!(ok, "应命中写入");
|
||||
let after = repo.get_by_id("parent").await.unwrap().unwrap();
|
||||
assert_eq!(after.status.as_str(), "in_progress", "status 应被聚合写入更新");
|
||||
|
||||
@@ -45,7 +45,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
// 什么数据库、Redis 在哪、有没有 MQ"的基础设施上下文。
|
||||
// V33 = 审批重启恢复:ai_conversations 加 pending_approvals TEXT 列,持久化挂起审批快照,
|
||||
// 重启后从 DB 恢复 pending_approvals 内存态,使待审批不丢。
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 35] = [
|
||||
let steps: [(i32, fn(&Connection) -> Result<()>); 36] = [
|
||||
(1, migrate_v1),
|
||||
(2, migrate_v2),
|
||||
(3, migrate_v3),
|
||||
@@ -81,6 +81,7 @@ pub fn run(conn: &Connection) -> Result<()> {
|
||||
(33, migrate_v33),
|
||||
(34, migrate_v34),
|
||||
(35, migrate_v35),
|
||||
(36, migrate_v36),
|
||||
];
|
||||
|
||||
for (version, migrate_fn) in steps {
|
||||
@@ -910,7 +911,7 @@ fn migrate_v31(conn: &Connection) -> Result<()> {
|
||||
|
||||
/// V32: ai_conversations 加 pinned_goals 列(对话透明化 L1 目标钉扎持久化)
|
||||
///
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<String>)管理,原先仅在内存态存在,
|
||||
/// 对话目标由 PerConvState.pinned_goals(Vec<GoalEntry>)管理,原先仅在内存态存在,
|
||||
/// 此迁移为其提供持久化列,默认空 JSON 数组'[]'。
|
||||
fn migrate_v32(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(
|
||||
@@ -1030,6 +1031,90 @@ fn migrate_v35(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V36:多 Agent 并行执行数据层(ai_plans/ai_subtasks/ai_conflicts 3 新表 +
|
||||
/// ai_messages/ai_tool_executions 加 subtask_id 列)。
|
||||
/// 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §二
|
||||
fn migrate_v36(conn: &Connection) -> Result<()> {
|
||||
// 1. ai_messages 加 subtask_id 列(消息归属子任务,NULL=单 Agent 时期)
|
||||
if !column_exists(conn, "ai_messages", "subtask_id") {
|
||||
conn.execute("ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT", [])?;
|
||||
tracing::info!("v36: ai_messages 加 subtask_id 列");
|
||||
}
|
||||
|
||||
// 2. ai_tool_executions 加 subtask_id 列(工具调用归属子任务)
|
||||
if !column_exists(conn, "ai_tool_executions", "subtask_id") {
|
||||
conn.execute("ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT", [])?;
|
||||
tracing::info!("v36: ai_tool_executions 加 subtask_id 列");
|
||||
}
|
||||
|
||||
// 3. ai_plans 表(Plan 生命周期:用户消息触发→拆解→执行→合并→完成)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
user_message_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'planning',
|
||||
subtask_count INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_plans_conv ON ai_plans(conversation_id, created_at)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 4. ai_subtasks 表(SubTask 状态 + DAG 层级 + Git worktree 分支)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_subtasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
persona_id TEXT,
|
||||
intent TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
layer INTEGER NOT NULL DEFAULT 0,
|
||||
deps TEXT,
|
||||
branch TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_subtasks_plan ON ai_subtasks(plan_id, layer)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 5. ai_conflicts 表(合并冲突:同文件路径 + 跨文件语义冲突)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS ai_conflicts (
|
||||
id TEXT PRIMARY KEY,
|
||||
plan_id TEXT NOT NULL REFERENCES ai_plans(id),
|
||||
file_path TEXT NOT NULL DEFAULT '',
|
||||
conflict_type TEXT NOT NULL DEFAULT 'file',
|
||||
subtask_a TEXT,
|
||||
subtask_b TEXT,
|
||||
diff_a TEXT,
|
||||
diff_b TEXT,
|
||||
resolution TEXT NOT NULL DEFAULT 'pending',
|
||||
resolved_by TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
resolved_at TEXT
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_ai_conflicts_plan ON ai_conflicts(plan_id, resolution)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
tracing::info!("v36: 建 ai_plans/ai_subtasks/ai_conflicts 表 + subtask_id 列(多 Agent 并行执行数据层)");
|
||||
conn.execute("INSERT INTO schema_version (version) VALUES (?)", [36])?;
|
||||
tracing::info!("迁移 v36 完成");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// V21 建表 SQL — 消息拆分存储 ai_messages 表
|
||||
///
|
||||
/// 与 V9_SQL 中的 ai_messages 镜像(V9 给新库,此 const 给老库 V21 迁移用 IF NOT EXISTS)。
|
||||
|
||||
@@ -360,7 +360,7 @@ pub struct AiConversationRecord {
|
||||
pub pinned: bool, // 是否置顶(排序置前;UX-17)
|
||||
pub prompt_tokens: Option<i64>, // 输入 token 累计(流式 usage 落库)
|
||||
pub completion_tokens: Option<i64>, // 输出 token 累计(流式 usage 落库)
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON 字符串数组,默认'[]')
|
||||
pub pinned_goals: Option<String>, // 对话目标钉扎持久化(JSON GoalEntry 数组 [{text,status}],兼容旧 ["text"] 格式,默认'[]')
|
||||
pub pending_approvals: Option<String>, // 挂起审批快照(JSON 对象,以 tool_call_id 为键,默认'{}')
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
@@ -497,3 +497,62 @@ pub struct ModuleDependencyRecord {
|
||||
pub label: Option<String>,
|
||||
pub created_at: String,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 多 Agent 并行执行模型 (V36)
|
||||
// ============================================================
|
||||
|
||||
/// Plan 记录(一次用户消息触发的并行执行计划)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanRecord {
|
||||
pub id: String,
|
||||
pub conversation_id: String,
|
||||
/// 触发 Plan 的用户消息 id
|
||||
pub user_message_id: Option<String>,
|
||||
/// planning / executing / merging / done / error
|
||||
pub status: String,
|
||||
pub subtask_count: i64,
|
||||
pub created_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// SubTask 记录(Plan 拆解出的子任务)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SubTaskRecord {
|
||||
pub id: String,
|
||||
pub plan_id: String,
|
||||
/// 分配的人设 id(coder/reviewer/architect/tester/analyst)
|
||||
pub persona_id: Option<String>,
|
||||
/// 子任务意图描述
|
||||
pub intent: String,
|
||||
/// pending / running / done / error
|
||||
pub status: String,
|
||||
/// DAG 层级(0 起)
|
||||
pub layer: i64,
|
||||
/// 依赖的 SubTask id 列表(JSON 数组字符串)
|
||||
pub deps: Option<String>,
|
||||
/// Git worktree 分支名(NULL=非 Git 工程降级串行)
|
||||
pub branch: Option<String>,
|
||||
pub created_at: String,
|
||||
pub completed_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 冲突记录(合并时检测到的文件/语义冲突)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConflictRecord {
|
||||
pub id: String,
|
||||
pub plan_id: String,
|
||||
pub file_path: String,
|
||||
/// file(同文件路径) / semantic(编译失败)
|
||||
pub conflict_type: String,
|
||||
pub subtask_a: Option<String>,
|
||||
pub subtask_b: Option<String>,
|
||||
pub diff_a: Option<String>,
|
||||
pub diff_b: Option<String>,
|
||||
/// pending / a / b / merged / manual
|
||||
pub resolution: String,
|
||||
/// reviewer / user
|
||||
pub resolved_by: Option<String>,
|
||||
pub created_at: String,
|
||||
pub resolved_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -101,6 +101,18 @@ pub fn get_provider_secret(id: &str) -> Option<String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// [`get_provider_secret`] 的 async 版本:`spawn_blocking` 隔离同步 keyring 调用。
|
||||
///
|
||||
/// keyring 在 Linux secret-service D-Bus / Windows COM / macOS Keychain 同步阻塞,直接在
|
||||
/// async 上下文调用会卡 tokio runtime(Tauri 单线程 runtime 尤其敏感)。async 路径优先用此版本;
|
||||
/// 同步版保留供 block_on / 非 async 路径(迁移、测试)使用。
|
||||
pub async fn get_provider_secret_async(id: String) -> Option<String> {
|
||||
tokio::task::spawn_blocking(move || get_provider_secret(&id))
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
/// 消费点用:解析 provider 真实密钥 — DB 优先,fallback keyring(兼容未迁移老库)
|
||||
pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
@@ -109,18 +121,43 @@ pub fn resolve_provider_secret(record: &AiProviderRecord) -> String {
|
||||
get_provider_secret(&record.id).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// [`resolve_provider_secret`] 的 async 版本 — DB 有明文时同步返(不触 keyring),
|
||||
/// 否则 `spawn_blocking` 调 keyring 防 D-Bus / COM 阻塞 tokio runtime。
|
||||
///
|
||||
/// 注:DB 明文路径直接 clone 同步返,只有 fallback keyring 才走 spawn_blocking。
|
||||
pub async fn resolve_provider_secret_async(record: AiProviderRecord) -> String {
|
||||
if !record.api_key.is_empty() {
|
||||
return record.api_key;
|
||||
}
|
||||
get_provider_secret_async(record.id).await.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// 写入密钥到 keyring(覆盖)
|
||||
pub fn set_provider_secret(id: &str, key: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.set_password(key).map_err(|e| anyhow::anyhow!("keyring 写入失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`set_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn set_provider_secret_async(id: String, key: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || set_provider_secret(&id, &key))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("set_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 删除 keyring 密钥(provider 删除时清理)
|
||||
pub fn delete_provider_secret(id: &str) -> anyhow::Result<()> {
|
||||
let entry = entry_for(id)?;
|
||||
entry.delete_credential().map_err(|e| anyhow::anyhow!("keyring 删除失败(provider={}): {}", id, e))
|
||||
}
|
||||
|
||||
/// [`delete_provider_secret`] 的 async 版本(`spawn_blocking` 隔离同步 keyring 调用)。
|
||||
pub async fn delete_provider_secret_async(id: String) -> anyhow::Result<()> {
|
||||
tokio::task::spawn_blocking(move || delete_provider_secret(&id))
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("delete_provider_secret join 失败: {}", e))?
|
||||
}
|
||||
|
||||
/// 启动一次性迁移:DB 明文 → keyring → DB 置空(失败保留明文下次重试,非阻断)
|
||||
pub async fn migrate_secrets_to_keyring(repo: &AiProviderRepo) -> anyhow::Result<usize> {
|
||||
let providers = repo.list_all().await?;
|
||||
|
||||
@@ -94,6 +94,13 @@ pub trait TunnelClient: Send + Sync {
|
||||
/// 此窗口内若收到 error 帧或连接 Close,判定握手失败)
|
||||
const HANDSHAKE_PROBE: Duration = Duration::from_secs(3);
|
||||
|
||||
/// TCP/WS 建立连接(connect_async)超时上限。
|
||||
///
|
||||
/// 对齐 df-ai http.rs connect_timeout(15s):relay URL 不可达(TCP SYN 无响应)时
|
||||
/// connect_async 会挂 60-120s 才返系统 timeout,Tauri 单线程 tokio runtime 卡死。
|
||||
/// 包 `tokio::time::timeout` 在此窗口内未完成 → 返 TunnelError::Connect 显式错误。
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
/// 心跳间隔(应用层 Ping,补协议层 keepalive,防 NAT 连接表超时回收)
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(25);
|
||||
|
||||
@@ -177,10 +184,18 @@ impl TunnelClient for WsTunnelClient {
|
||||
self.cleanup_conn().await;
|
||||
}
|
||||
|
||||
// 1. 建立 WS 连接
|
||||
let (ws_stream, _resp) = tokio_tungstenite::connect_async(url)
|
||||
.await
|
||||
.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?;
|
||||
// 1. 建立 WS 连接(包 CONNECT_TIMEOUT 防 relay 不可达时永久挂 — TCP SYN 无响应
|
||||
// 系统 timeout 60-120s 才返,Tauri 单线程 tokio runtime 会卡死。对齐 df-ai http.rs
|
||||
// connect_timeout(15s)。保留原错误映射语义:TunnelError::Connect(format!("WS 连接失败 ..."))
|
||||
let connect_fut = tokio_tungstenite::connect_async(url);
|
||||
let (ws_stream, _resp) = match tokio::time::timeout(CONNECT_TIMEOUT, connect_fut).await {
|
||||
Ok(res) => res.map_err(|e| TunnelError::Connect(format!("WS 连接失败 {url}: {e}")))?,
|
||||
Err(_) => {
|
||||
return Err(TunnelError::Connect(format!(
|
||||
"WS 连接超时(>{CONNECT_TIMEOUT:?} 无响应){url}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
tracing::info!(%url, "WS 连接已建立,开始 Hello 握手");
|
||||
|
||||
let (mut sink, mut stream) = ws_stream.split();
|
||||
|
||||
@@ -435,6 +435,7 @@ mod tests {
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".to_string(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"project\""));
|
||||
@@ -452,6 +453,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
// skip_serializing_if 生效:path 字段不出现在 JSON 中
|
||||
@@ -469,6 +471,7 @@ mod tests {
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".to_string(),
|
||||
project_name: Some("devflow".to_string()),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"task\""));
|
||||
@@ -485,6 +488,7 @@ mod tests {
|
||||
title: "Multi-round parallel".to_string(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: "desc".to_string(),
|
||||
extra: vec![],
|
||||
};
|
||||
let json = serde_json::to_string(&a).expect("serialize");
|
||||
assert!(json.contains("\"kind\":\"idea\""));
|
||||
@@ -520,6 +524,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
assert_eq!(p.kind_str(), "project");
|
||||
assert_eq!(p.section_label(), "项目");
|
||||
|
||||
@@ -22,8 +22,68 @@ pub type ReleaseId = String;
|
||||
pub type BranchId = String;
|
||||
/// 插件 ID
|
||||
pub type PluginId = String;
|
||||
/// 执行 ID
|
||||
pub type ExecutionId = String;
|
||||
/// 执行 ID(IPC 边界透明序列化为字符串)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct ExecutionId(String);
|
||||
|
||||
impl ExecutionId {
|
||||
/// 构造新执行 ID
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ExecutionId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ExecutionId {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ExecutionId {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ExecutionId {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ExecutionId> for String {
|
||||
fn from(id: ExecutionId) -> Self {
|
||||
id.0
|
||||
}
|
||||
}
|
||||
|
||||
// ExecutionId 与 &str 的比较(测试/匹配中大量使用)
|
||||
impl PartialEq<&str> for ExecutionId {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<str> for ExecutionId {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<String> for ExecutionId {
|
||||
fn eq(&self, other: &String) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
/// 决策 ID
|
||||
pub type DecisionId = String;
|
||||
/// 节点类型(如 ai / human / ai_self_review)
|
||||
@@ -31,7 +91,47 @@ pub type NodeType = String;
|
||||
/// 任务链接类型(如 depends_on / blocks / relates_to)
|
||||
pub type LinkType = String;
|
||||
/// 工具调用类型(如 function)
|
||||
pub type ToolCallType = String;
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, Default)]
|
||||
#[serde(transparent)]
|
||||
pub struct ToolCallType(String);
|
||||
|
||||
impl ToolCallType {
|
||||
/// 构造新工具调用类型
|
||||
pub fn new(s: impl Into<String>) -> Self {
|
||||
Self(s.into())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolCallType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for ToolCallType {
|
||||
type Target = str;
|
||||
fn deref(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for ToolCallType {
|
||||
fn from(s: String) -> Self {
|
||||
Self(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for ToolCallType {
|
||||
fn from(s: &str) -> Self {
|
||||
Self(s.to_owned())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<ToolCallType> for String {
|
||||
fn from(t: ToolCallType) -> Self {
|
||||
t.0
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 时间工具
|
||||
|
||||
@@ -23,3 +23,4 @@ async-trait = { workspace = true }
|
||||
futures = "0.3"
|
||||
anyhow = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
serde_yaml = "0.9"
|
||||
|
||||
@@ -110,7 +110,7 @@ async fn node_config_overrides_global_in_node_context() {
|
||||
|
||||
let dag = registry.build_dag(&def).expect("build_dag");
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-nodecfg".into());
|
||||
// 全局 config 写 foo=GLOBAL(验证被 n1 节点级覆盖,n2 回退用全局)
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::json!({ "foo": "GLOBAL" }))
|
||||
@@ -138,7 +138,7 @@ async fn test_same_layer_runs_in_parallel() {
|
||||
dag.add_node("a".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
dag.add_node("b".to_string(), Box::new(SleepNode { sleep_ms: 100 }));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let start = std::time::Instant::now();
|
||||
let outputs = executor.run(&dag, serde_json::Value::Null).await.unwrap();
|
||||
let elapsed = start.elapsed();
|
||||
@@ -161,7 +161,7 @@ async fn test_layer_failure_aborts_following_layers() {
|
||||
dag.add_node("c".to_string(), Box::new(SleepNode { sleep_ms: 10 }));
|
||||
dag.add_edge("a".to_string(), "c".to_string());
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-exec".into());
|
||||
let err = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -206,7 +206,7 @@ async fn test_cancelled_node_skips_set_failed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("x".to_string(), Box::new(CancelSelfNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Err(取消致中止后续层),但不 panic/bail transition
|
||||
@@ -257,7 +257,7 @@ async fn test_cancelled_node_skips_set_completed() {
|
||||
let mut dag = Dag::new();
|
||||
dag.add_node("y".to_string(), Box::new(CancelSelfThenOkNode));
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cancel-ok".into());
|
||||
let result = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// run 返回 Ok —— Ok 节点不应因 TOCTOU 取消被误判为失败
|
||||
@@ -285,7 +285,7 @@ async fn test_cancelled_node_emits_node_cancelled_event() {
|
||||
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe();
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".to_string());
|
||||
let mut executor = DagExecutor::new(bus, "test-cancel-event".into());
|
||||
let _ = executor.run(&dag, serde_json::Value::Null).await;
|
||||
|
||||
// 收集所有事件(run 已结束,事件总线无新事件)
|
||||
@@ -408,7 +408,7 @@ async fn conditions_eval_routes_by_edge_condition() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-route".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -462,7 +462,7 @@ async fn conditions_eval_skip_keeps_pending() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-skip".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
@@ -504,7 +504,7 @@ async fn conditions_eval_unconditional_edge_always_passes() {
|
||||
"$.ok == false".to_string(),
|
||||
);
|
||||
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".to_string());
|
||||
let mut executor = DagExecutor::new(EventBus::new(), "test-cond-mix".into());
|
||||
let outputs = executor
|
||||
.run(&dag, serde_json::Value::Null)
|
||||
.await
|
||||
|
||||
@@ -8,3 +8,4 @@ pub mod executor;
|
||||
pub mod node;
|
||||
pub mod registry;
|
||||
pub mod state;
|
||||
pub mod template_loader;
|
||||
|
||||
330
crates/df-workflow/src/template_loader.rs
Normal file
330
crates/df-workflow/src/template_loader.rs
Normal file
@@ -0,0 +1,330 @@
|
||||
//! YAML 模板加载器 — 从 YAML 字符串加载 DagDef + 校验
|
||||
//!
|
||||
//! 模板格式:
|
||||
//! ```yaml
|
||||
//! name: 代码审查
|
||||
//! description: AI 驱动的代码审查流程
|
||||
//! nodes:
|
||||
//! review:
|
||||
//! type: ai
|
||||
//! config:
|
||||
//! prompt: "审查以下代码变更..."
|
||||
//! persona_id: reviewer
|
||||
//! notify:
|
||||
//! type: notify
|
||||
//! config:
|
||||
//! type: desktop
|
||||
//! title: "审查完成"
|
||||
//! edges:
|
||||
//! - from: review
|
||||
//! to: notify
|
||||
//! ```
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dag_def::{DagDef, EdgeDef, NodeDef};
|
||||
|
||||
/// YAML 模板顶层结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WorkflowTemplate {
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub description: String,
|
||||
pub nodes: HashMap<String, TemplateNode>,
|
||||
#[serde(default)]
|
||||
pub edges: Vec<TemplateEdge>,
|
||||
}
|
||||
|
||||
/// 模板节点(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateNode {
|
||||
/// 节点类型(ai/script/human/git/http/notify/subflow)
|
||||
#[serde(rename = "type")]
|
||||
pub node_type: String,
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板边(YAML 友好命名)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TemplateEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
#[serde(default)]
|
||||
pub condition: Option<String>,
|
||||
}
|
||||
|
||||
/// 模板加载错误
|
||||
#[derive(Debug)]
|
||||
pub enum TemplateError {
|
||||
/// YAML 解析失败
|
||||
YamlParse(String),
|
||||
/// 校验失败(空节点/未知类型/依赖环)
|
||||
Validation(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TemplateError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::YamlParse(msg) => write!(f, "YAML 解析失败: {}", msg),
|
||||
Self::Validation(msg) => write!(f, "模板校验失败: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TemplateError {}
|
||||
|
||||
/// 已知的节点类型白名单
|
||||
const KNOWN_NODE_TYPES: &[&str] = &[
|
||||
"ai", "script", "human", "git", "http", "notify", "subflow",
|
||||
];
|
||||
|
||||
/// 从 YAML 字符串加载模板并转换为 DagDef
|
||||
///
|
||||
/// 步骤:
|
||||
/// 1. YAML → WorkflowTemplate(serde_yaml)
|
||||
/// 2. 校验(节点非空/类型合法/边引用存在/无环)
|
||||
/// 3. 转换为 DagDef
|
||||
pub fn load_template(yaml: &str) -> Result<DagDef, TemplateError> {
|
||||
// 1. YAML 解析
|
||||
let template: WorkflowTemplate = serde_yaml::from_str(yaml)
|
||||
.map_err(|e| TemplateError::YamlParse(e.to_string()))?;
|
||||
|
||||
// 2. 校验
|
||||
validate_template(&template)?;
|
||||
|
||||
// 3. 转换为 DagDef
|
||||
let mut dag = DagDef::new();
|
||||
for (id, node) in &template.nodes {
|
||||
dag.nodes.insert(
|
||||
id.clone(),
|
||||
NodeDef {
|
||||
id: id.clone(),
|
||||
node_type: node.node_type.clone(),
|
||||
config: node.config.clone(),
|
||||
label: node.label.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
for edge in &template.edges {
|
||||
dag.edges.push(EdgeDef {
|
||||
source: edge.from.clone(),
|
||||
target: edge.to.clone(),
|
||||
condition: edge.condition.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(dag)
|
||||
}
|
||||
|
||||
/// 校验模板结构
|
||||
fn validate_template(t: &WorkflowTemplate) -> Result<(), TemplateError> {
|
||||
// 节点非空
|
||||
if t.nodes.is_empty() {
|
||||
return Err(TemplateError::Validation("模板节点不能为空".into()));
|
||||
}
|
||||
|
||||
// 节点类型合法
|
||||
for (id, node) in &t.nodes {
|
||||
if !KNOWN_NODE_TYPES.contains(&node.node_type.as_str()) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"节点 '{}' 使用了未知类型 '{}',已知类型: {:?}",
|
||||
id, node.node_type, KNOWN_NODE_TYPES
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 边引用的节点存在
|
||||
let node_ids: std::collections::HashSet<&String> = t.nodes.keys().collect();
|
||||
for edge in &t.edges {
|
||||
if !node_ids.contains(&edge.from) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的起点 '{}' 不存在", edge.from
|
||||
)));
|
||||
}
|
||||
if !node_ids.contains(&edge.to) {
|
||||
return Err(TemplateError::Validation(format!(
|
||||
"边的终点 '{}' 不存在", edge.to
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 依赖无环(DFS 检测)
|
||||
if has_cycle(&t.edges) {
|
||||
return Err(TemplateError::Validation("依赖存在环".into()));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// DFS 检测边列表是否有环
|
||||
fn has_cycle(edges: &[TemplateEdge]) -> bool {
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
// 建邻接表
|
||||
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
|
||||
for e in edges {
|
||||
adj.entry(e.from.as_str()).or_default().push(e.to.as_str());
|
||||
}
|
||||
|
||||
let mut visited: HashSet<&str> = HashSet::new();
|
||||
let mut in_stack: HashSet<&str> = HashSet::new();
|
||||
|
||||
fn dfs<'a>(
|
||||
node: &'a str,
|
||||
adj: &HashMap<&'a str, Vec<&'a str>>,
|
||||
visited: &mut HashSet<&'a str>,
|
||||
in_stack: &mut HashSet<&'a str>,
|
||||
) -> bool {
|
||||
if in_stack.contains(node) {
|
||||
return true; // 环
|
||||
}
|
||||
if visited.contains(node) {
|
||||
return false; // 已访问过,无环
|
||||
}
|
||||
visited.insert(node);
|
||||
in_stack.insert(node);
|
||||
if let Some(neighbors) = adj.get(node) {
|
||||
for next in neighbors {
|
||||
if dfs(next, adj, visited, in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
in_stack.remove(node);
|
||||
false
|
||||
}
|
||||
|
||||
for e in edges {
|
||||
let start = e.from.as_str();
|
||||
if dfs(start, &adj, &mut visited, &mut in_stack) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn tpl_01_valid_template_loads() {
|
||||
let yaml = r#"
|
||||
name: 代码审查
|
||||
description: AI 驱动的代码审查
|
||||
nodes:
|
||||
review:
|
||||
type: ai
|
||||
config:
|
||||
prompt: "审查代码"
|
||||
notify:
|
||||
type: notify
|
||||
config:
|
||||
type: desktop
|
||||
title: "完成"
|
||||
edges:
|
||||
- from: review
|
||||
to: notify
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 2);
|
||||
assert_eq!(dag.edges.len(), 1);
|
||||
assert!(dag.nodes.contains_key("review"));
|
||||
assert_eq!(dag.nodes.get("review").unwrap().node_type, "ai");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_02_empty_nodes_rejected() {
|
||||
let yaml = "name: 空\nnodes: {}\n";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_03_unknown_node_type_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
bad:
|
||||
type: unknown_type
|
||||
config: {}
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_04_cycle_detected() {
|
||||
let yaml = r#"
|
||||
name: 环
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
b:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: b
|
||||
- from: b
|
||||
to: a
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(ref msg)) if msg.contains("环")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_05_edge_to_nonexistent_rejected() {
|
||||
let yaml = r#"
|
||||
name: test
|
||||
nodes:
|
||||
a:
|
||||
type: ai
|
||||
config: {}
|
||||
edges:
|
||||
- from: a
|
||||
to: ghost
|
||||
"#;
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::Validation(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_06_no_edges_ok() {
|
||||
let yaml = r#"
|
||||
name: 单节点
|
||||
nodes:
|
||||
solo:
|
||||
type: script
|
||||
config:
|
||||
command: "echo hello"
|
||||
"#;
|
||||
let dag = load_template(yaml).unwrap();
|
||||
assert_eq!(dag.nodes.len(), 1);
|
||||
assert!(dag.edges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_07_invalid_yaml_rejected() {
|
||||
let yaml = "not: valid: yaml: {{{";
|
||||
let result = load_template(yaml);
|
||||
assert!(matches!(result, Err(TemplateError::YamlParse(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tpl_08_all_known_node_types() {
|
||||
for nt in KNOWN_NODE_TYPES {
|
||||
let yaml = format!(
|
||||
"name: test\nnodes:\n n:\n type: {}\n config: {{}}",
|
||||
nt
|
||||
);
|
||||
let result = load_template(&yaml);
|
||||
assert!(result.is_ok(), "节点类型 {} 应合法", nt);
|
||||
}
|
||||
}
|
||||
}
|
||||
10
dev-server.bat
Normal file
10
dev-server.bat
Normal file
@@ -0,0 +1,10 @@
|
||||
@echo off
|
||||
cd /d E:\wk-lab\devflow
|
||||
echo [dev] 启动 DevFlow 开发服务器...
|
||||
echo [dev] Vite + Tauri 后端将同时启动
|
||||
echo [dev] 请勿关闭此窗口
|
||||
echo.
|
||||
C:\Users\23780\.bun\bin\bun.exe run tauri dev
|
||||
echo.
|
||||
echo [dev] 服务器已停止
|
||||
pause
|
||||
@@ -87,6 +87,9 @@
|
||||
| [项目知识图谱与任务队列系统-2026-06-26.md](./专项设计/项目知识图谱与任务队列系统-2026-06-26.md) | 📐 设计 | 项目级知识图谱 + 任务队列:依赖解析 / 优先级调度 |
|
||||
| [工程系统设计-2026-06-29.md](./专项设计/工程系统设计-2026-06-29.md) | ✅ 已落地 | 项目多工程(Module) + Git 状态查询 + 文件浏览器 + Git AI 工具(只读3+写3) |
|
||||
| [架构债迁移设计-2026-06-29.md](./专项设计/架构债迁移设计-2026-06-29.md) | 📐 设计草案 | #8/#15/#18/#19 架构债迁移路径(AI状态机抽离/IPC错误结构化/配置统一/连接池) |
|
||||
| [多Agent并行执行与仲裁合并设计-2026-07-01.md](./专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md) | 📐 设计 | 混合数据模型(messages加FK列+Plan/SubTask/Conflict独立表)、Git worktree隔离、JoinSet层内并行、四层冲突防线、Reviewer仲裁、事件协议、UI交互 |
|
||||
| [后续批次测试用例设计-2026-07-01.md](./专项设计/后续批次测试用例设计-2026-07-01.md) | 📐 测试 | 多Agent并行/worktree隔离/仲裁合并/模板系统/Git-CI/审批政策 的单元+集成+端到端测试用例 |
|
||||
| [测试落地实施方案-2026-07-01.md](./专项设计/测试落地实施方案-2026-07-01.md) | 📐 测试 | 各测试用例的落地方式(Rust 单元/集成/vitest/E2E 手动)、文件位置、Mock 策略、CI 自动化路径 |
|
||||
|
||||
---
|
||||
|
||||
@@ -112,6 +115,7 @@
|
||||
| [多主题上下文管理愿景-2026-06-19.md](./构想审查/多主题上下文管理愿景-2026-06-19.md) | 💡 远期愿景 | 无感多主题对话:主题检测前置 + 多主题多摘要(关联 F-15 / 意图识别) |
|
||||
| [多主题并存补充论证-多轮模式-2026-06-19.md](./构想审查/多主题并存补充论证-多轮模式-2026-06-19.md) | 📐 论证(供决策) | agentic 多轮模式可突破天花板,但近期结论不变 |
|
||||
| [跑题改进试验记录-2026-06-20.md](./构想审查/跑题改进试验记录-2026-06-20.md) | 📋 试验记录 | AI Chat 长对话跑题根因治:测试计划+迭代过程+改进前基线+遗留 |
|
||||
| [AI-Native方向与路线图-2026-06-29.md](./构想审查/AI-Native方向与路线图-2026-06-29.md) | 📐 方向 | AI Native 优先级矩阵:P0人设+多Agent→P1模板+Git/CI→P2审批政策+算法验证 |
|
||||
|
||||
### 根目录文档(不在子目录)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
> 性质: 系统现状盘点 / 能力边界(查实的事实,非构想)
|
||||
> 关联: [任务推进设计](../构想审查/任务推进构想-2026-06-14.md)(AI 执行层依据本文档能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(人设层是本架构的下一阶段演进方向)
|
||||
> 关联: [AI-Native方向与路线图](../构想审查/AI-Native方向与路线图-2026-06-29.md)(AI Native 优先级矩阵与推进路线图)
|
||||
> 用途: 作为「AI 执行层」「AI 自审」等设计的真实能力依据,避免在超出系统现状的能力上做设计
|
||||
|
||||
---
|
||||
|
||||
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
252
docs/02-架构设计/专项设计/后续批次测试用例设计-2026-07-01.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# 后续批次测试用例设计 — 2026-07-01
|
||||
|
||||
> 覆盖:多Agent并行执行 / Git worktree 隔离 / 仲裁合并 / 模板系统 / Git-CI / 审批政策
|
||||
> 关联:[多Agent并行执行与仲裁合并设计-2026-07-01.md](02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md)
|
||||
|
||||
---
|
||||
|
||||
## 一、测试分层
|
||||
|
||||
| 层级 | 范围 | 工具 |
|
||||
|------|------|------|
|
||||
| **单元测试** | 纯函数/数据结构/repo CRUD | `#[test]` Rust / vitest TS |
|
||||
| **集成测试** | 跨模块协作(dispatch+merge+worktree) | `#[tokio::test]` |
|
||||
| **端到端** | 用户发消息→Plan→并行→合并→展示 | 手动 + 日志验证 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据层与并行执行 测试用例
|
||||
|
||||
### 2.1 V36 迁移
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| MIG-01 | 新库空跑 V36 | 3 新表 + 2 列 ALTER 成功,ai_plans 为空 |
|
||||
| MIG-02 | 老库(有数据)跑 V36 | ai_messages/ai_tool_executions 加 subtask_id 列(NULL),原有数据不动 |
|
||||
| MIG-03 | V36 幂等(重跑) | 不崩溃,版本号已写入直接跳过 |
|
||||
| MIG-04 | 老库 ai_messages 有 100 条数据 | subtask_id 全部为 NULL,查询不报错 |
|
||||
|
||||
### 2.2 Repo CRUD
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| REPO-01 | PlanRepo.insert + get | 写入后能按 id 读回,字段完整 |
|
||||
| REPO-02 | PlanRepo.update_status | planning→executing→done 状态流转正确 |
|
||||
| REPO-03 | SubTaskRepo.insert + list_by_plan | 按 plan_id 查子任务,layer 排序正确 |
|
||||
| REPO-04 | SubTaskRepo.update_status | pending→running→done 流转 |
|
||||
| REPO-05 | ConflictRepo.insert + list_pending | 按 plan_id 查 resolution=pending 的冲突 |
|
||||
| REPO-06 | ConflictRepo.resolve | 更新 resolution + resolved_by + resolved_at |
|
||||
| REPO-07 | SubTaskRepo 查 branch=NULL | 非 Git 工程的 SubTask branch 为 NULL,查询正常 |
|
||||
|
||||
### 2.3 Coordinator 拆解
|
||||
|
||||
| ID | 场景 | 输入 | 预期 |
|
||||
|----|------|------|------|
|
||||
| DEC-01 | 读+写意图 | "先 read 文件再修改" | 2 SubTask(read→write),write deps=[read] |
|
||||
| DEC-02 | 纯读意图 | "分析现有代码结构" | 1 SubTask(read),无 deps |
|
||||
| DEC-03 | 纯写意图 | "implement new feature" | 1 SubTask(write),无 deps |
|
||||
| DEC-04 | 兜底意图 | "你好" | 1 SubTask(default) |
|
||||
| DEC-05 | 人设分配 | "review this code" | SubTask persona=reviewer |
|
||||
| DEC-06 | Plan 非空 | 任意非空输入 | plan.tasks.len() >= 1 |
|
||||
|
||||
### 2.4 Git worktree 隔离
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| WT-01 | worktree 创建 | `.devflow/wt/{plan_id}/A` 目录存在,git branch `subtask/{plan_id}/A` 存在 |
|
||||
| WT-02 | worktree 内 write_file | 写入落到 worktree 目录,主目录文件不变 |
|
||||
| WT-03 | 同层并行隔离 | SubTask A 写 main.rs,SubTask B 读 main.rs → B 读到的是 fork 基点内容,非 A 的改动 |
|
||||
| WT-04 | worktree commit | SubTask 完成后 git add+commit,分支有新 commit |
|
||||
| WT-05 | worktree 清理 | Plan 完成后 `.devflow/wt/{plan_id}/` 目录被移除 |
|
||||
| WT-06 | 非 Git 工程降级 | 无 .git 的工程 → branch=NULL,走串行,不崩溃 |
|
||||
| WT-07 | worktree 创建失败 | 磁盘满/权限不足 → 降级串行,日志告警 |
|
||||
|
||||
### 2.5 并行调度
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| PAR-01 | 线性链(2层×1任务) | Layer0 done → Layer1 启动,总耗时≈串行 |
|
||||
| PAR-02 | 并行层(1层×3任务) | 3 SubTask 并发执行,总耗时≈最慢的一个 |
|
||||
| PAR-03 | 钻石依赖 | A→{B,C}→D,B/C 并行,D 等 B+C 都 done |
|
||||
| PAR-04 | 层间隔离 | Layer1 的 SubTask 能读到 Layer0 merge 后的改动 |
|
||||
| PAR-05 | 单 SubTask 失败 | 标记 error,不中断同层其他 SubTask |
|
||||
| PAR-06 | 全部 SubTask 失败 | Plan.status=error |
|
||||
| PAR-07 | SubTask 超时(120s) | 标记 error,其他 SubTask 不受影响 |
|
||||
|
||||
### 2.6 Token 预算池
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| TOK-01 | 预算充足 | try_reserve(10k) → true,consumed+=10k |
|
||||
| TOK-02 | 预算超限 | consumed=98k,total=100k,try_reserve(5k) → false |
|
||||
| TOK-03 | 并发 reserve | 2 个 SubTask 同时 reserve(CAS 竞争) | 只有一个成功 |
|
||||
| TOK-04 | 超限降级串行 | 预算用完 → 剩余 SubTask 串行执行(不拒绝) |
|
||||
|
||||
### 2.7 事件协议
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| EVT-01 | AiPlanCreated | 前端 PlanProgress 立即展示 DAG 层结构 |
|
||||
| EVT-02 | AiSubTaskStatusChanged | 节点状态从 pending→running→done 更新 |
|
||||
| EVT-03 | AiMergeCompleted(无冲突) | 合并输出展示,无冲突徽章 |
|
||||
| EVT-04 | AiMergeCompleted(有冲突) | 冲突徽章出现 |
|
||||
| EVT-05 | AiConflictResolved | 冲突徽章消失 |
|
||||
| EVT-06 | 事件双写 | emit 和 publish_event 都触发 |
|
||||
|
||||
---
|
||||
|
||||
## 三、仲裁合并与冲突 测试用例
|
||||
|
||||
### 3.1 Git merge 冲突检测
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| MRG-01 | 无冲突合并 | A 改 file1,B 改 file2 → merge-tree 无冲突 → git merge 成功 |
|
||||
| MRG-02 | 同文件冲突 | A 改 main.rs 第10行,B 改 main.rs 第10行 → conflict markers 出现 |
|
||||
| MRG-03 | 同文件不冲突区 | A 改 main.rs 第10行,B 改 main.rs 第50行 → git 自动合并成功 |
|
||||
| MRG-04 | 跨层同文件 | Layer0 改 main.rs,merge 到 plan;Layer1 改 main.rs → 基于已含 Layer0 的版本,无冲突 |
|
||||
| MRG-05 | 三方合并预检 | git merge-tree 返回非空冲突列表 → 不执行 merge,标记 conflict |
|
||||
|
||||
### 3.2 编译检查(semantic 冲突)
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| CMP-01 | 合并后编译通过 | cargo check exit 0 → 无 semantic 冲突 |
|
||||
| CMP-02 | 合并后编译失败 | A 删函数 foo(),B 在调 foo() → cargo check 失败 → conflict_type=semantic |
|
||||
| CMP-03 | 前端项目 | tsc --noEmit 失败 → semantic 冲突 |
|
||||
| CMP-04 | 无构建工具的工程 | 跳过编译检查(无 cargo/tsc) → 不阻塞 merge |
|
||||
|
||||
### 3.3 Reviewer 仲裁
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| ARB-01 | Reviewer 推荐 A | diff_a 更完整 → resolution=a,resolved_by=reviewer |
|
||||
| ARB-02 | Reviewer 推荐 merged | 两者各有价值 → resolution=merged,给出合并方案 |
|
||||
| ARB-03 | Reviewer 无法判断 | 两者等价 → resolution=pending,留给用户 |
|
||||
| ARB-04 | Reviewer 超时 | LLM 调用超时 → 自动降级为 pending |
|
||||
|
||||
### 3.4 冲突解决闭环
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| RSV-01 | 用户选接受 A | resolution=a,文件应用 A 的改动,B 的改动丢弃 |
|
||||
| RSV-02 | 用户选手动 | resolution=manual,用户在 worktree 手动编辑后 commit |
|
||||
| RSV-03 | 全部冲突解决 | Plan.status 从 merging→done |
|
||||
| RSV-04 | 前端徽章更新 | AiConflictResolved 事件 → 冲突徽章消失 |
|
||||
|
||||
### 3.5 合并产出落回主对话
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| OUT-01 | 无冲突合并 | 主对话新增 1 条 assistant 消息(merged_output) |
|
||||
| OUT-02 | 有冲突合并 | 主对话新增 1 条 + 冲突徽章 |
|
||||
| OUT-03 | 展开查看子 Agent 轨迹 | 点击展开 → 从 ai_messages WHERE subtask_id=? 加载,按 SubTask 分组 |
|
||||
| OUT-04 | 折叠状态默认 | 新消息默认折叠,只显示摘要 |
|
||||
|
||||
### 3.6 命令互斥锁
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| LCK-01 | 同目录 npm install 并发 | 第二个等第一个完成才执行(不并行) |
|
||||
| LCK-02 | 不同目录 npm install | 并行执行(锁 key 含目录) |
|
||||
| LCK-03 | cargo + npm 同目录 | 不互斥(锁 key 含命令名) |
|
||||
|
||||
---
|
||||
|
||||
## 四、模板系统 测试用例
|
||||
|
||||
### 4.1 YAML 模板加载
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| TPL-01 | 合法 YAML 加载 | DagDef 结构完整,nodes/edges/conditions 正确解析 |
|
||||
| TPL-02 | 缺必填字段 | nodes 为空 → 报错"模板节点不能为空" |
|
||||
| TPL-03 | 依赖闭环 | A→B→A → 报错"依赖存在环" |
|
||||
| TPL-04 | 未知节点类型 | node.type="UnknownNode" → 报错"未知节点类型" |
|
||||
| TPL-05 | 内置模板加载 | 3 个内置模板(代码审查/bug修复/功能开发)全部加载成功 |
|
||||
| TPL-06 | 自定义模板 CRUD | 创建→读取→更新→删除,持久化到 DB |
|
||||
|
||||
### 4.2 SubflowNode
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| SUB-01 | 嵌套子工作流执行 | SubflowNode 加载子模板 → 递归执行 → 返回子工作流结果 |
|
||||
| SUB-02 | 子工作流审批 | 子工作流内的 HumanNode 审批 → 挂起 → 恢复 |
|
||||
| SUB-03 | 嵌套深度限制 | 超过 MAX_PLAN_DEPTH → 报错 |
|
||||
|
||||
### 4.3 新节点
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| NODE-01 | GitNode 创建分支 | branch_name 存在,git branch 列表可见 |
|
||||
| NODE-02 | GitNode commit | 工作区改动已提交,git log 有新记录 |
|
||||
| NODE-03 | HTTPNode GET | 请求成功,返回 body 存入 NodeOutput |
|
||||
| NODE-04 | HTTPNode 超时 | 30s 超时 → 返回错误,不阻塞 |
|
||||
| NODE-05 | NotifyNode 桌面通知 | 系统通知弹出 |
|
||||
| NODE-06 | NotifyNode Webhook | POST 到飞书/钉钉,HTTP 200 |
|
||||
|
||||
---
|
||||
|
||||
## 五、Git/CI 集成 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| CI-01 | DockerNode 构建成功 | docker run cargo build → exit 0 → 构建产物存在 |
|
||||
| CI-02 | DockerNode 构建失败 | docker run cargo build → exit !=0 → 返回 stderr |
|
||||
| CI-03 | PR 创建 | GitNode push → API 创建 PR → PR URL 返回 |
|
||||
| CI-04 | CI 状态读取 | 调 API → 返回 check 结果(passed/failed/pending) |
|
||||
| CI-05 | 全链路 | 模板→分支→代码→commit→push→PR→CI→结果回显,全流程不中断 |
|
||||
| CI-06 | CI 失败时 | 前端 CI 面板显示红色,日志可查看 |
|
||||
|
||||
---
|
||||
|
||||
## 六、审批政策 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| POL-01 | coder+Low → auto | 匹配规则 → 自动执行 |
|
||||
| POL-02 | coder+High → approve | 匹配规则 → 走审批 |
|
||||
| POL-03 | reviewer+任意 → auto | reviewer 只读工具,全部自动 |
|
||||
| POL-04 | 无匹配规则 | 降级到 auto_exec_mode 三档(向后兼容) |
|
||||
| POL-05 | 策略热加载 | 修改策略 → 下次工具调用立即生效(不重启) |
|
||||
| POL-06 | 策略冲突检测 | 同一 Persona+Node+Risk 有多条规则 → 优先级最高的生效 |
|
||||
|
||||
---
|
||||
|
||||
## 七、God 文件拆分 测试用例
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| REF-01 | agentic/mod.rs 拆分 | 拆出 compress/title/knowledge_inject 后,原文件<800 行,cargo check 通过 |
|
||||
| REF-02 | context.rs 拆分 | 拆出 sanitize/budget/compress 后,原文件<800 行,所有 context 测试通过 |
|
||||
| REF-03 | MessageList.vue 拆分 | 拆出 StreamRenderer/ToolCardGroup/EmptyState,vue-tsc 通过 |
|
||||
| REF-04 | ChatInput.vue 拆分 | 拆出 MentionPopover/EnrichmentPanel,vue-tsc 通过 |
|
||||
| REF-05 | 拆分后行为不变 | 原有所有单元测试/集成测试全绿 |
|
||||
|
||||
---
|
||||
|
||||
## 八、端到端手动测试
|
||||
|
||||
| ID | 场景 | 步骤 | 预期 |
|
||||
|----|------|------|------|
|
||||
| E2E-01 | 简单并行任务 | 发"先读取 main.rs 再修改 utils.rs" | PlanProgress 展示 2 层,Layer0 读,Layer1 写,最终合并输出 |
|
||||
| E2E-02 | 并行同文件冲突 | 发"同时重构 auth.rs 的两个函数" | 2 SubTask 并行改同文件 → 冲突徽章 → 用户选择 → 最终输出 |
|
||||
| E2E-03 | 取消并行任务 | 发消息→Plan 执行中→点停止 | 所有 SubTask 停止,worktree 清理,Plan.status=error |
|
||||
| E2E-04 | 切换会话后台执行 | 发消息→切换到另一个会话 | 原 Plan 后台继续,PlanProgress 在原会话保留 |
|
||||
| E2E-05 | 非 Git 工程 | 在无 .git 的项目中发并行任务 | 降级串行执行,正常完成 |
|
||||
| E2E-06 | 模板驱动全链路 | 选"功能开发"模板→AI 执行 | 从模板加载工作流→创建分支→写代码→测试→PR |
|
||||
| E2E-07 | 长对话不崩溃 | 50+ 轮对话后发并行任务 | token 预算池正常,不 OOM,PlanProgress 正常展示 |
|
||||
|
||||
---
|
||||
|
||||
## 九、边界与异常
|
||||
|
||||
| ID | 场景 | 预期 |
|
||||
|----|------|------|
|
||||
| EDGE-01 | 空 Plan | decompose 返回空 Plan → 不启动 dispatch,主对话正常回复(退单 Agent) |
|
||||
| EDGE-02 | 全部 SubTask 超时 | Plan.status=error,前端展示超时提示,worktree 清理 |
|
||||
| EDGE-03 | Git 不可用 | git 命令失败 → 降级串行,日志告警,不崩溃 |
|
||||
| EDGE-04 | DB 写入失败 | PlanRepo.insert 失败 → 降级不落库,内存态执行,日志告警 |
|
||||
| EDGE-05 | worktree 磁盘满 | 创建失败 → 降级串行 |
|
||||
| EDGE-06 | token 预算=0 | 设置项 df-ai-plan-token-budget=0 → 不限制(等价无限) |
|
||||
| EDGE-07 | 冲突解决时会话删除 | resolve_conflict IPC 到达时会话已删 → 忽略,清理 conflict 记录 |
|
||||
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
592
docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md
Normal file
@@ -0,0 +1,592 @@
|
||||
# 多 Agent 并行执行与仲裁合并设计 — 2026-07-01
|
||||
|
||||
> 性质:架构设计 / 数据模型 / 事件协议 / UI 交互
|
||||
> 关联: [AI-Native方向与路线图-2026-06-29.md](../构想审查/AI-Native方向与路线图-2026-06-29.md)(P0 智能体人设 + 多 Agent 协作)
|
||||
> 关联: [单对话并行多轮-设计-2026-06-20.md](../单对话并行多轮-设计-2026-06-20.md)(Plan DAG 结构与分层调度)
|
||||
> 关联: [Agent架构说明-2026-06-14.md](Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||
> 用途:Batch 37-38 的实施依据
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
DevFlow 的定位是 AI Native——AI 是系统的主要操作者,人的角色是「决策者 + 政策制定者」。
|
||||
|
||||
多 Agent 并行执行要解决的核心问题:
|
||||
|
||||
| 问题 | 现状(单 Agent) | 多 Agent 后 |
|
||||
|------|-----------------|------------|
|
||||
| 复杂任务拆解 | LLM 自行在单轮内拆,上下文窗口受限 | Coordinator 拆为 SubTask,各自独立上下文 |
|
||||
| 并行产出 | 串行,无冲突 | 多 Agent 同时改文件 → 数据竞争 |
|
||||
| 用户感知 | 单条流式输出 | 多 Agent 产出交织,用户分不清谁是谁 |
|
||||
| 错误恢复 | 一个工具失败 → LLM 下一轮决策 | 一个 SubTask 失败 → Coordinator 决定是否继续 |
|
||||
|
||||
---
|
||||
|
||||
## 二、数据模型(方案 C:混合)
|
||||
|
||||
### 2.1 设计原则
|
||||
|
||||
1. **messages 不膨胀** — 只加 `subtask_id` 外键列,不把 Plan/Conflict 字段塞进去
|
||||
2. **重实体独立** — Plan / SubTask / Conflict 各自独立表,各自演进
|
||||
3. **历史兼容** — `subtask_id = NULL` 表示单 Agent 时期消息,零回归
|
||||
4. **FK 三层链路** — messages → SubTask → Plan 可追溯完整生命周期
|
||||
|
||||
### 2.2 ER 图
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
ai_conversations ||--o{ ai_plans : "1:N 触发"
|
||||
ai_plans ||--o{ ai_subtasks : "1:N 拆解"
|
||||
ai_plans ||--o{ ai_conflicts : "1:N 冲突"
|
||||
ai_subtasks ||--o{ ai_messages : "1:N 产出(subtask_id FK)"
|
||||
ai_subtasks ||--o{ ai_tool_executions : "1:N 工具调用(subtask_id FK)"
|
||||
ai_conflicts }o--|| ai_subtasks : "subtask_a"
|
||||
ai_conflicts }o--|| ai_subtasks : "subtask_b"
|
||||
|
||||
ai_plans {
|
||||
TEXT id PK
|
||||
TEXT conversation_id FK
|
||||
TEXT user_message_id "触发 Plan 的用户消息"
|
||||
TEXT status "planning/executing/merging/done/error"
|
||||
INTEGER subtask_count
|
||||
TEXT created_at
|
||||
TEXT completed_at "可空"
|
||||
}
|
||||
|
||||
ai_subtasks {
|
||||
TEXT id PK
|
||||
TEXT plan_id FK
|
||||
TEXT persona_id "coder/reviewer/architect/..."
|
||||
TEXT intent "子任务意图描述"
|
||||
TEXT status "pending/running/done/error"
|
||||
INTEGER layer "DAG 层级(0起)"
|
||||
TEXT deps "JSON 数组: 依赖的 SubTask id 列表"
|
||||
TEXT created_at
|
||||
TEXT completed_at "可空"
|
||||
}
|
||||
|
||||
ai_conflicts {
|
||||
TEXT id PK
|
||||
TEXT plan_id FK
|
||||
TEXT file_path "冲突文件路径"
|
||||
TEXT subtask_a FK
|
||||
TEXT subtask_b FK
|
||||
TEXT diff_a "Agent A 对该文件的改动摘要"
|
||||
TEXT diff_b "Agent B 对该文件的改动摘要"
|
||||
TEXT resolution "pending/a/b/merged/manual"
|
||||
TEXT resolved_by "reviewer/user"
|
||||
TEXT created_at
|
||||
TEXT resolved_at "可空"
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 现有表改动
|
||||
|
||||
```sql
|
||||
-- V36 迁移
|
||||
ALTER TABLE ai_messages ADD COLUMN subtask_id TEXT;
|
||||
ALTER TABLE ai_tool_executions ADD COLUMN subtask_id TEXT;
|
||||
|
||||
-- 新建 3 张表(见 ER 图字段)
|
||||
CREATE TABLE ai_plans (...);
|
||||
CREATE TABLE ai_subtasks (
|
||||
...
|
||||
-- worktree 分支名(git worktree 隔离机制,见 §4.2)
|
||||
branch TEXT -- 如 "subtask/{plan_id}/A"
|
||||
);
|
||||
CREATE TABLE ai_conflicts (
|
||||
...
|
||||
-- 冲突类型(见 §4.2 四层防线)
|
||||
conflict_type TEXT DEFAULT 'file' -- 'file'(同文件) / 'semantic'(编译失败)
|
||||
);
|
||||
```
|
||||
|
||||
### 2.4 三层关联查询示例
|
||||
|
||||
```sql
|
||||
-- 查某 Plan 的全部产出(消息 + 工具)
|
||||
SELECT m.* FROM ai_messages m
|
||||
JOIN ai_subtasks s ON m.subtask_id = s.id
|
||||
WHERE s.plan_id = ?;
|
||||
|
||||
-- 查某 Plan 的所有冲突
|
||||
SELECT c.* FROM ai_conflicts c WHERE c.plan_id = ? AND c.resolution = 'pending';
|
||||
|
||||
-- 查某对话的全部 Plan 历史
|
||||
SELECT p.* FROM ai_plans p WHERE p.conversation_id = ? ORDER BY created_at DESC;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、事件协议
|
||||
|
||||
### 3.1 新增事件
|
||||
|
||||
| 事件 | 时机 | 载荷 | 前端响应 |
|
||||
|------|------|------|---------|
|
||||
| `AiPlanCreated` | Coordinator.decompose 完成后 | `{ plan_id, layers: [[{id, persona, intent, status}],...] }` | PlanProgress 展示 DAG |
|
||||
| `AiSubTaskStatusChanged` | SubTask 状态变更(pending→running→done/error) | `{ subtask_id, plan_id, status, persona_id }` | PlanProgress 更新节点状态 + 工具卡分组徽章 |
|
||||
| `AiMergeCompleted` | Coordinator.merge 完成后 | `{ plan_id, merged_output, conflicts: [{id, file, subtask_a, subtask_b}] }` | 展示合并结果 + 冲突徽章 |
|
||||
| `AiConflictResolved` | 用户/reviewer 解决冲突后 | `{ conflict_id, resolution, resolved_by }` | 冲突徽章消失 + 最终输出更新 |
|
||||
|
||||
### 3.2 事件流时序
|
||||
|
||||
```
|
||||
用户发送消息
|
||||
↓
|
||||
AiPlanCreated { plan_id, layers }
|
||||
↓ (PlanProgress 立即展示)
|
||||
Layer 0 启动
|
||||
↓
|
||||
AiSubTaskStatusChanged { subtask_0, running }
|
||||
AiSubTaskStatusChanged { subtask_1, running } ← 层内并行
|
||||
↓ (工具卡片按 subtask_id 分组,带 persona 徽章)
|
||||
AiSubTaskStatusChanged { subtask_0, done }
|
||||
AiSubTaskStatusChanged { subtask_1, done }
|
||||
↓
|
||||
Layer 1 启动 ...
|
||||
↓
|
||||
AiMergeCompleted { plan_id, merged_output, conflicts }
|
||||
↓ (有冲突 → 冲突徽章;无冲突 → 直接展示合并输出)
|
||||
用户点击冲突 → 展示 diff_a/diff_b → 选择
|
||||
↓
|
||||
AiConflictResolved { conflict_id, resolution }
|
||||
↓
|
||||
最终输出展示
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、并行执行策略
|
||||
|
||||
### 4.1 调度规则
|
||||
|
||||
```
|
||||
Plan.to_layers() → [[subtask_0, subtask_1], [subtask_2], [subtask_3, subtask_4]]
|
||||
Layer 0(并行) Layer 1(串行) Layer 2(并行)
|
||||
```
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| 层间串行 | 上层全部 done + **Git merge 到 plan 分支后** 才创建下一层 worktree(fork 天然包含上层改动) |
|
||||
| 层内并行 | 同层 SubTask 各自创建 Git worktree,JoinSet 并发执行 |
|
||||
| 写工具天然隔离 | 每个 SubTask 在独立 worktree 内工作,write_file/patch_file 写到 worktree 目录(不重定向) |
|
||||
| 只读工具天然可见 | read_file 读 worktree 内文件(含本 worktree 改动 + fork 基点的全部历史) |
|
||||
| 层内隔离 | 同层并行 SubTask 互相看不到对方的 worktree 改动(独立分支,完全隔离) |
|
||||
|
||||
### 4.2 Git worktree 隔离机制
|
||||
|
||||
**核心思路**:用 Git worktree 替代自建暂存区,复用 Git 原生的分支隔离/diff/merge/conflict 能力。
|
||||
|
||||
#### 优势(vs 自建暂存区)
|
||||
|
||||
| 维度 | 自建暂存区 | Git worktree |
|
||||
|------|-----------|-------------|
|
||||
| 隔离 | 手动 cp 文件 | `git worktree add` 天然隔离 |
|
||||
| diff | 自己实现比对 | `git diff` 原生(含上下文/行级/二进制) |
|
||||
| 合并 | 手动 cp 或自写 merge | `git merge` 三方合并 |
|
||||
| 冲突解决 | 自建 diff 展示 | `git merge-tree` 预检 + conflict markers |
|
||||
| 回滚 | 手动备份/恢复 | `git reset` / `git checkout` |
|
||||
| 审计 | 自建日志 | commit history 天然审计链 |
|
||||
| AI 工具兼容 | write_file 需重定向路径 | worktree 就是普通目录,所有工具原样工作 |
|
||||
| PR 集成 | 需额外实现 | plan 分支直接创建 PR |
|
||||
|
||||
#### 数据流
|
||||
|
||||
```
|
||||
Plan 启动:
|
||||
git worktree add .devflow/wt/{plan_id} -b plan/{plan_id}
|
||||
(基于工程当前分支创建 plan 工作分支)
|
||||
|
||||
Layer 0 并行:
|
||||
SubTask A → git worktree add .devflow/wt/{plan_id}/A -b subtask/{plan_id}/A
|
||||
(基于 plan 分支创建,A 的所有工具操作在此 worktree 内,天然隔离)
|
||||
SubTask B → git worktree add .devflow/wt/{plan_id}/B -b subtask/{plan_id}/B
|
||||
(同上,B 完全隔离,A 看不到 B 的改动,反之亦然)
|
||||
SubTask A/B done 后各自 git add + git commit(改动落入各自分支)
|
||||
|
||||
Layer 0 merge(本层全部 done 后):
|
||||
① cd .devflow/wt/{plan_id} (plan 分支)
|
||||
② git diff plan..subtask/{plan_id}/A → A 的改动集
|
||||
③ git diff plan..subtask/{plan_id}/B → B 的改动集
|
||||
④ 同文件交集 → git merge-tree (三方合并预检)
|
||||
无冲突 → git merge subtask/{plan_id}/A && git merge subtask/{plan_id}/B
|
||||
有冲突 → Conflict 表记录 + Reviewer 仲裁 / 用户选择
|
||||
⑤ 合并完成 → plan 分支已含 Layer 0 全部改动
|
||||
|
||||
Layer 1 fork:
|
||||
基于 plan 分支最新 commit 创建新 worktree(天然包含 Layer 0 改动)
|
||||
|
||||
Plan 完成:
|
||||
① 编译检查:在 plan worktree 跑 cargo check / tsc --noEmit
|
||||
失败 → semantic 冲突标记
|
||||
② plan 分支创建 PR(可选,或直接 merge 回主分支)
|
||||
③ git worktree remove 清理所有子 worktree + plan worktree
|
||||
```
|
||||
|
||||
#### 读工具兼容规则
|
||||
|
||||
| 场景 | 行为 |
|
||||
|------|------|
|
||||
| 同层并行 SubTask 互读 | 不可见(独立 worktree + 独立分支) |
|
||||
| 下一层读上一层产出 | 可见(上一层已 merge 到 plan 分支,新 worktree 基于此创建) |
|
||||
| SubTask 读自己 worktree 内的写入 | 可见(worktree 是真实目录,写完即读) |
|
||||
|
||||
#### 非 Git 工程的降级
|
||||
|
||||
未绑定 Git 的工程(无 .git 目录)降级为串行执行(单 SubTask 逐个跑,无 worktree 隔离)。
|
||||
此时 ai_subtasks.branch 为 NULL,Coordinator.dispatch 走原串行路径。
|
||||
|
||||
### 4.3 冲突防护四层防线
|
||||
|
||||
| 防线 | 机制 | 覆盖冲突类型 |
|
||||
|------|------|------------|
|
||||
| **第一层:层间串行 + merge 后才 fork** | 上一层 merge 到 plan 分支后,下一层 worktree 基于此创建 | 跨层同文件覆盖 |
|
||||
| **第二层:同层 worktree 隔离 + merge-tree 预检** | 独立分支 + git 三方合并检测 | 同文件并发写 |
|
||||
| **第三层:编译检查** | merge 完成后在 plan worktree 跑 cargo check / tsc --noEmit,失败 → 标记语义冲突 | 跨文件语义冲突(删函数/改签名) |
|
||||
| **第四层:命令互斥锁** | run_command 对同目录的 npm/cargo 加 mutex | 资源竞争(npm install 并发) |
|
||||
|
||||
### 4.4 Token 预算管控
|
||||
|
||||
```rust
|
||||
struct TokenBudgetPool {
|
||||
total: AtomicU64, // 全局预算(来自设置项)
|
||||
consumed: AtomicU64, // 已消耗
|
||||
}
|
||||
|
||||
impl TokenBudgetPool {
|
||||
fn try_reserve(&self, estimate: u64) -> bool {
|
||||
// CAS 循环:consumed + estimate <= total
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 每个 SubTask 启动前向预算池申请估算额度
|
||||
- 超限时 Coordinator 拒绝启动新 SubTask(降级为串行顺序执行剩余任务)
|
||||
- 预算来源:设置项 `df-ai-plan-token-budget`(默认 100k tokens)
|
||||
|
||||
### 4.5 错误传播
|
||||
|
||||
| 场景 | 策略 |
|
||||
|------|------|
|
||||
| SubTask 执行失败 | 记录 error 状态,**不中断其他同层 SubTask**(容错) |
|
||||
| 全部 SubTask 失败 | Coordinator 标记 Plan 状态为 error,前端展示错误 |
|
||||
| 部分 SubTask 失败 | merge 时跳过失败 SubTask 的分支(不 merge),只合并成功的 |
|
||||
| 子 Agent 超时 | 单 SubTask 超时(默认 120s)→ 标记 error,不影响其他 |
|
||||
| Git merge 冲突 | conflict markers 保留在 plan worktree,Reviewer Agent 仲裁或用户手动解决 |
|
||||
| 编译检查失败 | 标记 semantic 冲突,Reviewer Agent 尝试修复或标记给用户 |
|
||||
| worktree 创建失败 | 降级为串行(无 worktree 隔离,逐 SubTask 在主目录执行) |
|
||||
|
||||
---
|
||||
|
||||
## 五、UI 交互设计
|
||||
|
||||
### 5.1 PlanProgress 组件(发送即展示)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ 📋 执行计划 2/4 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ Layer 1 │
|
||||
│ ▶ 🔵 [coder] 重构代码 running │
|
||||
│ 🟢 [architect] 分析结构 done │
|
||||
│ ↓ │
|
||||
│ Layer 2 │
|
||||
│ ⏸ [tester] 补测试 pending │
|
||||
│ ↓ │
|
||||
│ Layer 3 │
|
||||
│ ⏸ [reviewer] 审查 pending │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **发送即展示**:用户发消息后 Coordinator 分解完成立即展示
|
||||
- **实时更新**:AiSubTaskStatusChanged 驱动节点状态变化
|
||||
- **折叠工具卡**:每个 SubTask 下的工具卡片折叠归组(点击展开)
|
||||
- **层间箭头**:DAG 层级关系可视化
|
||||
|
||||
### 5.2 工具卡片分组(subtask_id 归组)
|
||||
|
||||
```
|
||||
┌─ 🔵 [coder·重构代码] ──────────────┐
|
||||
│ ▸ read_file main.rs ✓ done │
|
||||
│ ▸ patch_file utils.rs ✓ done │
|
||||
└────────────────────────────────────┘
|
||||
┌─ 🟢 [architect·分析结构] ──────────┐
|
||||
│ ▸ list_directory ✓ done │
|
||||
│ ▸ 分析结论: 模块耦合度偏高... │
|
||||
└────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 每个 SubTask 一个折叠容器,带 persona 颜色徽章
|
||||
- 工具卡片归入对应 SubTask 容器
|
||||
- 默认折叠,有审批/错误时自动展开
|
||||
|
||||
### 5.3 冲突展示(徽章非阻塞)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ ✅ 执行完成 ⚠ 2 处冲突待处理 │
|
||||
├─────────────────────────────────────────┤
|
||||
│ 合并输出: │
|
||||
│ ... │
|
||||
├─────────────────────────────────────────┤
|
||||
│ ⚠ main.rs — Agent A vs Agent B │
|
||||
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||
│ ⚠ utils.rs — Agent A vs Agent B │
|
||||
│ [查看 diff] [接受 A] [接受 B] [手动] │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- 冲突用**徽章**提示,不打断阅读流
|
||||
- 点击展开 diff 对比 + resolution 按钮
|
||||
- reviewer Agent 可自动给出推荐(`resolved_by=reviewer`),用户确认即可
|
||||
|
||||
---
|
||||
|
||||
## 六、取消与中断
|
||||
|
||||
| 操作 | 行为 |
|
||||
|------|------|
|
||||
| 用户点停止 | 停止所有并行 SubTask(整体取消) |
|
||||
| 单 SubTask 超时 | 只标记该 SubTask error,不影响其他 |
|
||||
| 会话切换 | 后台 SubTask 继续执行(F-09 并发语义一致) |
|
||||
| 会话删除 | 所有关联 SubTask 停止 + Plan 标记 error |
|
||||
|
||||
---
|
||||
|
||||
## 七、实施分批
|
||||
|
||||
### 数据层 + Git worktree 隔离 + 并行执行
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | V36 迁移(3 新表 + 2 ALTER + subtasks.branch + conflicts.conflict_type) | migrations.rs |
|
||||
| 2 | PlanRepo / SubTaskRepo / ConflictRepo CRUD | 新 repo 文件 |
|
||||
| 3 | Git worktree 生命周期管理(create/commit/merge/remove) | 新 git_worktree.rs |
|
||||
| 4 | Coordinator.dispatch JoinSet 层内并行(每 SubTask 绑 worktree) | coordinator.rs |
|
||||
| 5 | Token 预算池(AtomicU64 CAS,超限降级串行) | coordinator.rs |
|
||||
| 6 | 子 Agent 独立 ContextManager + fork 快照 + worktree_path | coordinator.rs |
|
||||
| 7 | 层间 merge 到 plan 分支 + 下一层基于 plan 创建 worktree | coordinator.rs |
|
||||
| 8 | 4 个新事件 + 事件双写(emit + publish_event) | AiChatEvent |
|
||||
| 9 | 前端类型定义(PlanRecord/SubTaskRecord/ConflictRecord) | api/types.ts |
|
||||
| 10 | PlanProgress 接入真实状态 + 发送即展示 | PlanProgress.vue |
|
||||
| 11 | 工具卡按 subtask_id 折叠分组 + persona 徽章 | MessageList.vue |
|
||||
| 12 | 编译警告清理(coordinator_plan unused / audit 子模块 unused imports) | 各文件 |
|
||||
|
||||
### 仲裁合并 + 冲突 UI + 编译检查
|
||||
|
||||
| # | 任务 | 文件 |
|
||||
|---|------|------|
|
||||
| 1 | Coordinator.merge: git merge-tree 三方合并预检 | coordinator.rs + git_worktree.rs |
|
||||
| 2 | 冲突检测:同文件路径(file 类型) + 编译失败(semantic 类型) | coordinator.rs |
|
||||
| 3 | Reviewer Agent 仲裁(persona 扩展:读 diff → 推荐 resolution + 理由) | persona.rs |
|
||||
| 4 | 合并产出落回主对话(方案 C:可展开单条) | coordinator.rs + MessageList.vue |
|
||||
| 5 | ConflictResolver.vue(git diff 展示 + resolution 按钮) | 新组件 |
|
||||
| 6 | resolve_conflict IPC + AiConflictResolved 事件闭环 | AiChatEvent |
|
||||
| 7 | 命令互斥锁(run_command 对同目录 npm/cargo 加 mutex) | audit/approval.rs |
|
||||
| 8 | agentic/mod.rs 拆分(2222 行 → 提取 compress/title/knowledge_inject 独立模块) | agentic/*.rs |
|
||||
| 9 | AiChat 进度条补全(后端 AiAgentRound 补 max_rounds + 前端 completed 计数) | AiChat.vue |
|
||||
| 10 | context.rs 拆分(1956 行 → 提取 sanitize/budget/compress 独立模块) | context/*.rs |
|
||||
|
||||
---
|
||||
|
||||
## 八、子 Agent 上下文隔离
|
||||
|
||||
### 8.1 双重隔离:上下文 + 文件系统
|
||||
|
||||
多 Agent 并行执行时需要两层隔离:
|
||||
|
||||
| 层 | 隔离对象 | 机制 |
|
||||
|---|---------|------|
|
||||
| 上下文隔离 | messages / currentText / tool_results / pending_approvals | 独立 ContextManager 实例 |
|
||||
| 文件隔离 | 工程文件(worktree 内的源码) | Git worktree(见 §4.2) |
|
||||
|
||||
### 8.2 上下文隔离
|
||||
|
||||
```rust
|
||||
struct SubTaskContext {
|
||||
/// 从主对话 fork 的快照(只读父上下文 + 用户原始消息)
|
||||
parent_snapshot: Vec<ChatMessage>,
|
||||
/// 独立的 ContextManager(不写回主对话)
|
||||
messages: ContextManager,
|
||||
/// 分配的人设
|
||||
persona: AgentPersona,
|
||||
/// 子任务 id(产出消息标记 subtask_id)
|
||||
subtask_id: String,
|
||||
/// Git worktree 路径(工具操作的工作目录,见 §4.2)
|
||||
worktree_path: Option<PathBuf>, // None = 非 Git 工程降级串行
|
||||
}
|
||||
```
|
||||
|
||||
**隔离规则**:
|
||||
|
||||
| 维度 | 主对话 | 子 Agent |
|
||||
|------|--------|----------|
|
||||
| messages | 用户消息 + 合并产出 | fork 快照 + 独立执行轨迹 |
|
||||
| ContextManager | 主对话的 | 每个 SubTask 独立实例 |
|
||||
| pending_approvals | 主对话的 | 各 SubTask 独立(无并发写竞争) |
|
||||
| tool_results | 写入子 Agent 的 messages | 合并时提取摘要写入主对话 |
|
||||
| **文件系统** | 主工程目录 | **Git worktree 隔离**(§4.2) |
|
||||
| **工作目录** | 主工程路径 | **worktree_path**(工具的 cwd 重定向到此) |
|
||||
|
||||
### 8.3 合并产出落回主对话(方案 C:可展开单条)
|
||||
|
||||
```
|
||||
SubTask 完成 → ExecutionResult { output, success }
|
||||
↓
|
||||
Coordinator.merge(results) → MergeResult { merged_output, conflicts }
|
||||
↓
|
||||
主对话 push 单条可展开 assistant 消息
|
||||
```
|
||||
|
||||
**展示策略**:
|
||||
- **默认折叠**:主对话只展示一条 assistant 消息(merged_output 摘要)
|
||||
- **点击展开**:展开后按 SubTask 分组,每组显示完整执行轨迹(从 ai_messages WHERE subtask_id=? 拉取)
|
||||
- **冲突内联**:合并消息底部内联冲突列表(点击展开 diff + resolution 按钮)
|
||||
- **子 Agent 执行轨迹不堆入主对话流**:只在 ai_messages 表保留(带 subtask_id),展开时按需加载
|
||||
|
||||
---
|
||||
|
||||
## 九、前端类型定义
|
||||
|
||||
```typescript
|
||||
// api/types.ts 新增
|
||||
|
||||
/** Plan 执行状态 */
|
||||
export type PlanStatus = 'planning' | 'executing' | 'merging' | 'done' | 'error'
|
||||
|
||||
/** SubTask 执行状态 */
|
||||
export type SubTaskStatus = 'pending' | 'running' | 'done' | 'error'
|
||||
|
||||
/** 冲突解决状态 */
|
||||
export type ConflictResolution = 'pending' | 'a' | 'b' | 'merged' | 'manual'
|
||||
|
||||
/** Plan 记录 */
|
||||
export interface PlanRecord {
|
||||
id: string
|
||||
conversation_id: ConvId
|
||||
user_message_id: MessageId
|
||||
status: PlanStatus
|
||||
subtask_count: number
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
/** SubTask 记录 */
|
||||
export interface SubTaskRecord {
|
||||
id: string
|
||||
plan_id: string
|
||||
persona_id: string
|
||||
intent: string
|
||||
status: SubTaskStatus
|
||||
layer: number
|
||||
deps: string[] // JSON 解析后的数组
|
||||
/** Git worktree 分支名(NULL=非 Git 工程降级串行) */
|
||||
branch: string | null
|
||||
created_at: string
|
||||
completed_at: string | null
|
||||
}
|
||||
|
||||
/** 冲突类型 */
|
||||
export type ConflictType = 'file' | 'semantic'
|
||||
|
||||
/** 冲突记录 */
|
||||
export interface ConflictRecord {
|
||||
id: string
|
||||
plan_id: string
|
||||
/** 冲突文件路径(semantic 类型时为触发编译失败的入口文件) */
|
||||
file_path: string
|
||||
/** 冲突类型:file=同文件路径 / semantic=编译失败 */
|
||||
conflict_type: ConflictType
|
||||
subtask_a: string
|
||||
subtask_b: string
|
||||
/** git diff 输出(A 的改动) */
|
||||
diff_a: string
|
||||
/** git diff 输出(B 的改动) */
|
||||
diff_b: string
|
||||
resolution: ConflictResolution
|
||||
resolved_by: 'reviewer' | 'user' | null
|
||||
created_at: string
|
||||
resolved_at: string | null
|
||||
}
|
||||
|
||||
/** PlanProgress 展示用 DAG 层结构 */
|
||||
export interface PlanLayer {
|
||||
items: Array<{
|
||||
id: string
|
||||
persona: string
|
||||
intent: string
|
||||
status: SubTaskStatus
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、状态机
|
||||
|
||||
### 10.1 Plan 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> planning: Coordinator.decompose()
|
||||
planning --> executing: Plan 写入 DB + emit AiPlanCreated
|
||||
executing --> merging: 所有 SubTask done/error
|
||||
merging --> done: merge 完成 + 无冲突
|
||||
merging --> done: 所有冲突已解决
|
||||
merging --> error: merge 失败
|
||||
executing --> error: 全部 SubTask 失败
|
||||
done --> [*]
|
||||
error --> [*]
|
||||
```
|
||||
|
||||
### 10.2 SubTask 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: Plan.decompose 创建
|
||||
pending --> running: dispatch 分配 + JoinSet 启动
|
||||
running --> done: agentic loop 收敛(无 tool_calls)
|
||||
running --> error: 超时 / 全部工具失败
|
||||
done --> [*]
|
||||
error --> [*]
|
||||
```
|
||||
|
||||
### 10.3 Conflict 状态流转
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> pending: merge 检测到冲突
|
||||
pending --> resolved: reviewer 推荐 + 用户确认
|
||||
pending --> resolved: 用户手动选择 a/b/merged
|
||||
resolved --> [*]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、事件总线集成
|
||||
|
||||
4 个新事件均走现有双写机制(`app_handle.emit` + `ai_event_bus.publish_event`),
|
||||
与 AiToolCallStarted/AiCompleted 等关键状态变更事件一致:
|
||||
|
||||
```rust
|
||||
let ev = AiChatEvent::AiPlanCreated { ... };
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
```
|
||||
|
||||
- `emit`:桌面端前端监听
|
||||
- `publish_event`:tunnel subscriber 透传小程序
|
||||
|
||||
---
|
||||
|
||||
## 十二、不做的方向
|
||||
|
||||
| 方向 | 放弃理由 | 可能的时机 |
|
||||
|------|---------|-----------|
|
||||
| 工具级并行(同轮多 tool_call JoinSet) | 并发写 pending_approvals + LLM 对乱序 tool_result 行为不可预测 | 永不(LLM 已能单轮多 tool_call) |
|
||||
| 单 SubTask 取消 | MVP 简化,整体取消即可 | P1 以后按需 |
|
||||
| 双栏 diff 对比 UI | 违背 AI Native 定位(让用户做 AI 该做的合并) | 永不(reviewer Agent 仲裁替代) |
|
||||
| SubTask 级审批门控 | 审批在工具级(RiskLevel)已足够 | P2 审批政策配置 |
|
||||
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
290
docs/02-架构设计/专项设计/测试落地实施方案-2026-07-01.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# 测试落地实施方案 — 2026-07-01
|
||||
|
||||
> 关联:[后续批次测试用例设计-2026-07-01.md](后续批次测试用例设计-2026-07-01.md)
|
||||
> 用途:定义每个测试用例的具体落地方式、文件位置、执行命令
|
||||
|
||||
---
|
||||
|
||||
## 一、项目现有测试基建
|
||||
|
||||
| 层 | 现状 | 工具 |
|
||||
|----|------|------|
|
||||
| Rust 单元测试 | df-ai 370+ 测试,df-storage 集成测试,df-execute 集成测试 | `#[test]` / `#[tokio::test]` |
|
||||
| 前端测试 | **无**(package.json 无 vitest/jest) | 需引入 vitest |
|
||||
| 端到端 | 无自动化(手动验证 + 日志) | 手动 |
|
||||
|
||||
---
|
||||
|
||||
## 二、各层级落地方式
|
||||
|
||||
### 2.1 Rust 单元测试(`#[test]` / `#[tokio::test]`)
|
||||
|
||||
**适用用例**:MIG / REPO / DEC / PAR / TOK / MRG / CMP / ARB / TPL / POL
|
||||
|
||||
**文件位置**:与现有模式一致——`mod tests` 嵌入源文件底部。
|
||||
|
||||
| 用例类别 | 落地文件 | 测试数量 |
|
||||
|---------|---------|---------|
|
||||
| MIG-01~04 | `df-storage/src/migrations.rs` mod tests | 4 |
|
||||
| REPO-01~07 | `df-storage/src/crud/plan_repo.rs` mod tests | 7 |
|
||||
| DEC-01~06 | `df-ai/src/coordinator.rs` mod tests(已有 6 个,补全) | 6 |
|
||||
| PAR-01~07 | `df-ai/src/coordinator.rs` mod tests(mock executor) | 7 |
|
||||
| TOK-01~04 | `df-ai/src/coordinator.rs` mod tests | 4 |
|
||||
| MRG-01~05 | `df-ai/src/coordinator.rs` mod tests(mock merge) | 5 |
|
||||
| CMP-01~04 | `df-ai/src/coordinator.rs` mod tests(mock 编译结果) | 4 |
|
||||
| ARB-01~04 | `df-ai/src/persona.rs` mod tests | 4 |
|
||||
| TPL-01~06 | `df-workflow/src/template_loader.rs` mod tests | 6 |
|
||||
| POL-01~06 | `src-tauri/src/policy.rs` mod tests | 6 |
|
||||
|
||||
**执行命令**:
|
||||
|
||||
```bash
|
||||
# 全量
|
||||
cargo test --workspace
|
||||
|
||||
# 按 crate
|
||||
cargo test -p df-ai
|
||||
cargo test -p df-storage
|
||||
|
||||
# 按用例名
|
||||
cargo test -p df-ai coordinator::tests::decompose_read_then_write
|
||||
|
||||
# 只跑并行相关
|
||||
cargo test -p df-ai -- coordinator::tests::par_
|
||||
```
|
||||
|
||||
### 2.2 Rust 集成测试(`tests/` 目录)
|
||||
|
||||
**适用用例**:WT / SUB / NODE / LCK / CI(需要真实 Git/Shell/DB 环境)
|
||||
|
||||
| 用例类别 | 落地文件 | 说明 |
|
||||
|---------|---------|------|
|
||||
| WT-01~07 | `df-ai/tests/git_worktree.rs` | 真实 git worktree 操作(tempdir 临时仓库) |
|
||||
| LCK-01~03 | `df-ai/tests/command_lock.rs` | 真实 tokio 并发 + mutex 验证 |
|
||||
| SUB-01~03 | `df-nodes/tests/subflow_node.rs` | 真实 Executor + 嵌套模板 |
|
||||
| NODE-01~06 | `df-nodes/tests/nodes.rs` | GitNode/HTTPNode/NotifyNode 各自独立 |
|
||||
| CI-01~02 | `df-nodes/tests/docker_node.rs` | 需 Docker 环境(`#[ignore]` 标注,CI 环境跑) |
|
||||
|
||||
**WT 测试关键代码模式**(参考现有 `df-execute/tests/shell.rs`):
|
||||
|
||||
```rust
|
||||
// df-ai/tests/git_worktree.rs
|
||||
use tempfile::TempDir;
|
||||
use std::process::Command;
|
||||
|
||||
fn setup_git_repo() -> TempDir {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
Command::new("git").arg("init").current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["config", "user.email", "test@test.com"])
|
||||
.current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["config", "user.name", "test"])
|
||||
.current_dir(dir.path()).output().unwrap();
|
||||
// 写初始文件 + commit
|
||||
std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
|
||||
Command::new("git").args(["add", "."]).current_dir(dir.path()).output().unwrap();
|
||||
Command::new("git").args(["commit", "-m", "init"]).current_dir(dir.path()).output().unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wt_01_worktree_create_and_isolate() {
|
||||
let repo = setup_git_repo();
|
||||
let plan_id = "test-plan-001";
|
||||
// 创建 worktree
|
||||
let wt_path = repo.path().join(".devflow/wt").join(plan_id).join("A");
|
||||
let output = Command::new("git")
|
||||
.args(["worktree", "add", wt_path.to_str().unwrap(), "-b", "subtask/test-plan-001/A"])
|
||||
.current_dir(repo.path())
|
||||
.output().unwrap();
|
||||
assert!(output.status.success(), "worktree 创建失败");
|
||||
assert!(wt_path.exists(), "worktree 目录应存在");
|
||||
// 写文件到 worktree
|
||||
std::fs::write(wt_path.join("new.rs"), "// A 的改动").unwrap();
|
||||
// 主目录不应有 new.rs
|
||||
assert!(!repo.path().join("new.rs").exists(), "主目录不应有 A 的改动");
|
||||
// 清理
|
||||
Command::new("git").args(["worktree", "remove", wt_path.to_str().unwrap(), "--force"])
|
||||
.current_dir(repo.path()).output().unwrap();
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 前端测试(引入 vitest)
|
||||
|
||||
**现状**:项目无前端测试基建。需要引入 vitest。
|
||||
|
||||
**引入步骤**(纳入数据层批次实施):
|
||||
|
||||
```bash
|
||||
bun add -d vitest @vue/test-utils jsdom
|
||||
```
|
||||
|
||||
`vitest.config.ts`:
|
||||
```typescript
|
||||
import { defineConfig } from 'vitest/config'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`package.json` 加 `"test": "vitest"` 脚本。
|
||||
|
||||
**适用用例**:EVT / OUT / REF(前端)
|
||||
|
||||
| 用例类别 | 落地文件 | 说明 |
|
||||
|---------|---------|------|
|
||||
| EVT-01~06 | `src/components/ai/__tests__/PlanProgress.test.ts` | 组件事件驱动渲染 |
|
||||
| OUT-01~04 | `src/components/ai/__tests__/MessageList.test.ts` | 合并产出展示/折叠 |
|
||||
| REF-03~05 | `src/components/ai/__tests__/MessageList.test.ts` | 拆分后行为不变 |
|
||||
|
||||
**执行命令**:
|
||||
|
||||
```bash
|
||||
bun run test # watch 模式
|
||||
bun run test -- --run # 单次跑
|
||||
bun run test -- PlanProgress # 按文件名过滤
|
||||
```
|
||||
|
||||
### 2.4 端到端手动验证(测试脚本 + 日志)
|
||||
|
||||
**适用用例**:E2E / EDGE
|
||||
|
||||
**落地方式**:测试用 DB + 日志分析脚本,非自动化框架。
|
||||
|
||||
**手动验证检查清单格式**(每个批次完成时填写):
|
||||
|
||||
```markdown
|
||||
## E2E-01 简单并行任务
|
||||
- [ ] 发送消息:"先读取 main.rs 再修改 utils.rs"
|
||||
- [ ] PlanProgress 展示 2 层 DAG
|
||||
- [ ] Layer 0 (read) 完成 → Layer 1 (write) 启动
|
||||
- [ ] 最终输出包含合并结果
|
||||
- [ ] ai_subtasks 表有 2 条记录,status=done
|
||||
- [ ] ai_messages 表有 subtask_id 非空的记录
|
||||
- [ ] worktree 目录已清理(.devflow/wt/ 下无残留)
|
||||
```
|
||||
|
||||
**验证用 SQL 查询**(批次完成后在 dev 数据库执行):
|
||||
|
||||
```sql
|
||||
-- 检查 Plan 记录
|
||||
SELECT id, status, subtask_count, created_at FROM ai_plans ORDER BY created_at DESC LIMIT 5;
|
||||
|
||||
-- 检查 SubTask 状态
|
||||
SELECT s.id, s.persona_id, s.status, s.layer, s.branch
|
||||
FROM ai_subtasks s
|
||||
JOIN ai_plans p ON s.plan_id = p.id
|
||||
WHERE p.id = ? -- 替换为实际 plan_id
|
||||
ORDER BY s.layer, s.created_at;
|
||||
|
||||
-- 检查冲突记录
|
||||
SELECT c.file_path, c.conflict_type, c.resolution, c.resolved_by
|
||||
FROM ai_conflicts c WHERE c.plan_id = ?;
|
||||
|
||||
-- 检查消息归属
|
||||
SELECT m.id, m.role, m.subtask_id, substr(m.content, 1, 60) as preview
|
||||
FROM ai_messages m WHERE m.conversation_id = ? AND m.subtask_id IS NOT NULL
|
||||
ORDER BY m.seq DESC LIMIT 20;
|
||||
```
|
||||
|
||||
**worktree 残留检查**:
|
||||
|
||||
```bash
|
||||
# Plan 完成后应无残留
|
||||
ls -la .devflow/wt/ # 应为空或不存在
|
||||
|
||||
# 异常残留时的清理
|
||||
git worktree list
|
||||
git worktree prune
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、落地优先级
|
||||
|
||||
| 优先级 | 范围 | 时机 |
|
||||
|--------|------|------|
|
||||
| **P0 随编码落地** | Rust 单元测试(DEC/PAR/TOK/MRG/ARB) | 每个批次编码时同步写 |
|
||||
| **P0 随编码落地** | Rust 集成测试(WT) | worktree 模块编码时同步写 |
|
||||
| **P1 批次末尾** | 前端测试基建引入 + EVT/OUT | 数据层批次末尾引入 vitest |
|
||||
| **P2 批次完成后** | E2E 手动验证 | 每个批次完成后用检查清单验证 |
|
||||
| **P3 按需** | CI 集成测试(Docker/PR) | Git/CI 批次完成后 |
|
||||
|
||||
---
|
||||
|
||||
## 四、CI 自动化(远期)
|
||||
|
||||
当 vitest + cargo test 覆盖率足够后,接入 CI:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/test.yml(或 Gitea Actions)
|
||||
jobs:
|
||||
rust-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: cargo test --workspace
|
||||
|
||||
frontend-test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: oven-sh/setup-bun@v1
|
||||
- run: bun install
|
||||
- run: bun run test -- --run
|
||||
```
|
||||
|
||||
当前阶段以**本地 `cargo test` + 手动 E2E** 为主,CI 自动化等测试覆盖率达标后接入。
|
||||
|
||||
---
|
||||
|
||||
## 五、Mock 策略
|
||||
|
||||
### 5.1 Rust 侧
|
||||
|
||||
| 依赖 | Mock 方式 |
|
||||
|------|----------|
|
||||
| LLM 调用 | 闭包注入(`Coordinator.dispatch<F>` 已支持泛型 executor) |
|
||||
| Git 操作 | 集成测试用真实 git(tempdir 临时仓库) |
|
||||
| 数据库 | 内存 SQLite(`Database::open_in_memory()`) |
|
||||
| 编译检查 | mock 返回 exit_code(单元测试)/ 真实 cargo(集成测试) |
|
||||
|
||||
### 5.2 前端侧
|
||||
|
||||
| 依赖 | Mock 方式 |
|
||||
|------|----------|
|
||||
| Tauri IPC | `@vue/test-utils` mock `invoke` |
|
||||
| 事件监听 | 手动 emit 测试事件 |
|
||||
| i18n | 插件 mock(返回 key 本身) |
|
||||
| Store | 真实 Pinia store(测试 store 逻辑本身) |
|
||||
|
||||
---
|
||||
|
||||
## 六、每个批次的测试验证流程
|
||||
|
||||
```
|
||||
编码完成
|
||||
↓
|
||||
cargo test -p df-ai(对应模块单元测试全绿)
|
||||
↓
|
||||
cargo test -p df-storage(迁移+Repo 测试全绿)
|
||||
↓
|
||||
cargo test -p df-ai --test git_worktree(worktree 集成测试全绿)
|
||||
↓
|
||||
cargo check --workspace(编译无警告)
|
||||
↓
|
||||
npx vue-tsc --noEmit(前端类型检查)
|
||||
↓
|
||||
npx vite build(前端构建)
|
||||
↓
|
||||
手动 E2E 检查清单(对照测试用例文档逐项验证)
|
||||
↓
|
||||
SQL 查询验证(DB 数据正确性)
|
||||
↓
|
||||
提交(代码+测试同一提交)
|
||||
```
|
||||
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
132
docs/02-架构设计/构想审查/AI-Native方向与路线图-2026-06-29.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# AI Native 方向与路线图 — 2026-06-29
|
||||
|
||||
> 性质:方向定义 / 优先级矩阵 / 推进路线图
|
||||
> 关联: [Agent架构说明-2026-06-14.md](../专项设计/Agent架构说明-2026-06-14.md)(当前 Agent 能力边界)
|
||||
> 关联: [三层模型-流程模板与人设体系-2026-06-28.md](../专项设计/三层模型-流程模板与人设体系-2026-06-28.md)(模板/工作流/人设三层抽象)
|
||||
> 关联: [架构债迁移设计-2026-06-29.md](../专项设计/架构债迁移设计-2026-06-29.md)(#8/#15/#18/#19 迁移路径)
|
||||
> 关联: [产品定位调整-2026-06-12.md](./产品定位调整-2026-06-12.md)(「想法到创作」产品定位)
|
||||
> 用途:作为后续所有功能/架构决策的优先级参考
|
||||
|
||||
---
|
||||
|
||||
## 一、AI Native 与传统范式的区别
|
||||
|
||||
DevFlow 的定位是 **AI 原生**——AI 不是辅助工具,而是系统的主要操作者。人的角色从「操作者」转变为「决策者 + 政策制定者」。
|
||||
|
||||
```
|
||||
传统 DevOps 流程:
|
||||
需求 → 设计 → 编码 → PR → 审查 → CI → 部署
|
||||
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||
人 人 AI 人 人 自动 人
|
||||
辅助写 创建PR 审查
|
||||
|
||||
AI Native 流程:
|
||||
想法 → AI 分析 → AI 设计 → AI 编码并行 → AI 自审 → AI 测试 → AI 发布 ← 人类审批
|
||||
↑ ↑ ↑ ↑ ↑ ↑ ↑
|
||||
人类 AI驱动 AI驱动 多Agent分工 AI审计 AI验证 自动编排
|
||||
输入
|
||||
```
|
||||
|
||||
**关键变化**:
|
||||
|
||||
| 维度 | 传统 | AI Native |
|
||||
|------|------|-----------|
|
||||
| 人类角色 | 执行者(写代码、建 PR、跑测试) | 决策者(立项、审批、定策略) |
|
||||
| AI 角色 | 辅助(补全、建议、生成片段) | 执行者(分析、设计、编码、测试、发布) |
|
||||
| 流程驱动 | 人类手工推进(点按钮、改状态) | AI 自动推进(工作流 + 事件驱动) |
|
||||
| 质量保障 | 人工审查 + CI 门禁 | AI 自审 + 人工审批 + 质量门禁 |
|
||||
| 产出物 | 代码 PR | 可发布的全流程资产(代码 + 测试 + 文档 + 变更日志) |
|
||||
|
||||
---
|
||||
|
||||
## 二、优先级矩阵
|
||||
|
||||
按「奠定 AI Native 基础」的依赖关系排序。P0 是 P1 的前提,P1 是 P2 的前提。
|
||||
|
||||
```
|
||||
P0 ─── 人设系统 ─── 多 Agent 协作
|
||||
(角色划分) (分工执行)
|
||||
│
|
||||
P1 ─── 模板系统 ─── Git/CI 集成
|
||||
(流程预设) (现实接轨)
|
||||
│
|
||||
P2 ─── 审批政策 ─── 算法验证
|
||||
(规则配置) (特定场景)
|
||||
```
|
||||
|
||||
### P0:智能体人设 + 多 Agent 协作
|
||||
|
||||
这两项构成 AI Native 的基础——没有角色划分就谈不上分工,没有分工就谈不上协作。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **人设系统** | AgentPersona 结构体 + PersonaRegistry + 内置 5 人设(coder/reviewer/architect/tester/analyst) | `crates/df-ai/src/persona.rs`(新建) | `df-ai` crate 已有 |
|
||||
| **人设注入** | AINode 执行时从 NodeContext 读 persona_id,拼接 system_prompt + 过滤工具集 | `crates/df-nodes/src/ai_node.rs` + `crates/df-workflow/src/node.rs` | 人设系统就绪 |
|
||||
| **Coordinator 填实** | 拆解任务 → 分配人设 → 并行 Agent → 汇总合并 | `crates/df-ai/src/coordinator.rs` | 人设系统 + SubflowNode |
|
||||
| **Planner 接入 Loop** | `intent/plan_hint/planner` 三个纯函数模块接入主 loop | `src-tauri/src/commands/ai/agentic/mod.rs` | Coordinator 就绪 |
|
||||
|
||||
**验证标准**:单次 AI Chat 能调用多个子 Agent 并行工作(如同时生成三个模块的代码),最后合并为一个完整的 PR。
|
||||
|
||||
### P1:模板系统 + Git/CI 集成
|
||||
|
||||
AI 产出的成果需要能推送到现实协作流程(Git PR、CI Pipeline)。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **模板加载器** | YAML 模板 → DagDef 实例化 | `crates/df-workflow` 新增 | SubflowNode |
|
||||
| **SubflowNode** | 嵌套子工作流节点 | `crates/df-nodes/src/subflow_node.rs` | 模板系统 |
|
||||
| **GitNode** | 分支创建/PR/合并操作 | `crates/df-nodes/src/git_node.rs` | libgit2 就绪 |
|
||||
| **DockerNode** | 容器内构建/测试 | `crates/df-nodes/src/docker_node.rs` | workpod API |
|
||||
| **Config 统一** | AppConfig struct + env override | `df-types/src/config.rs` + AppState | 本迭代推进 |
|
||||
|
||||
**验证标准**:从 YAML 模板加载工作流 → 创建 Git 分支 → 生成代码 → 提交 PR → 跑 CI。
|
||||
|
||||
### P2:审批政策 + 算法验证
|
||||
|
||||
当 AI 成为主要执行者后,「什么时候需要人批」「算法怎么验证」需要系统化的可配置机制。
|
||||
|
||||
| 方向 | 内容 | 代码落点 | 前置依赖 |
|
||||
|------|------|---------|---------|
|
||||
| **审批政策配置** | 按人设/节点/风险级别定制审批策略 | `HumanNode` + 配置层 | 人设系统就绪 |
|
||||
| **算法验证循环** | 基准测试 → 对比 → 迭代优化 → 早停 | 新建 `df-algo` crate | 模板系统 + 人设 |
|
||||
|
||||
---
|
||||
|
||||
## 三、对既有工作的对齐
|
||||
|
||||
| 已有资产 | 与本路线图的关系 |
|
||||
|---------|----------------|
|
||||
| `intent.rs` / `planner.rs` / `plan_hint.rs` | P0 的预制件——三个纯函数模块算法已完备,只差接入主 loop |
|
||||
| `coordinator.rs` | P0 的关键缺项——当前空壳,需要填实为真正的 Agent 调度器 |
|
||||
| 三层模型设计文档 | P1 的架构设计——模板/工作流/人设三层抽象已设计完成,待编码 |
|
||||
| 走查报告 | 安全/架构债在 P0-P2 推进中同步修复 |
|
||||
| 架构债迁移设计 | #15 AI 状态机抽离在 P0+P1 完成后自然推进 |
|
||||
|
||||
---
|
||||
|
||||
## 四、不做的方向
|
||||
|
||||
以下方向经过评估后,**当前阶段不投入**:
|
||||
|
||||
| 方向 | 放弃理由 | 可能的时机 |
|
||||
|------|---------|-----------|
|
||||
| 全自动无人值守 | 当前审批机制(HumanNode)是有意的设计取舍,AI 最终决策需人类把关 | 长效目标,非当前阶段 |
|
||||
| 第三方系统集成(云效/禅道) | 核心 Agent 能力未就绪前,集成的价值有限 | P2 完成后评估 |
|
||||
| MCP 外部工具接入 | 工具集封闭是安全取舍,开放后将引入新的攻击面 | Phase 5 或之后 |
|
||||
| Web/移动端 | AI 执行逻辑焊死在桌面端(#15)解决之前,跨端无意义 | #15 完成后 |
|
||||
|
||||
---
|
||||
|
||||
## 五、下一步行动
|
||||
|
||||
**当前建议方向**(按此顺序落地):
|
||||
|
||||
```
|
||||
本周 → 人设系统(Persona 数据结构 + 5 内置人设 + 注册表)
|
||||
↓
|
||||
下周 → Coordinator 填实 + Planner 接入(多 Agent 骨架)
|
||||
↓
|
||||
下月 → 节点补齐(Subflow/Docker/Git)+ 模板加载器 + YAML 模板
|
||||
```
|
||||
|
||||
每步完成即可独立验证,不必等全部做齐再交付。
|
||||
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
192
docs/05-代码审查/aichat-会话实测分析-2026-07-07.md
Normal file
@@ -0,0 +1,192 @@
|
||||
# AI Chat 真实会话实测分析(系统消息污染 + 死循环重调)
|
||||
|
||||
> 日期:2026-07-07 | 方法:从生产 DB(`%APPDATA%/top.1216.devflow/devflow.db`)实测最近两个会话全量消息反推,Python 只读统计
|
||||
> 基线会话:`9357c27c`(HaoGamePlatfPro,prompt 644,586 / completion 3,865)+ `43b75e49`(wk-hszd,prompt 601,028 / completion 4,134)
|
||||
> 产物:**2 P0 / 3 P1 / 2 P2**
|
||||
> 性质:**实测数据反推**(非静态代码审查)。finding 的源码根因为"假设",须独立 grep/read 核验后再定论
|
||||
> 关联:[[aichat-session-analysis-2026-06-22]] [[aichat-techdebt-audit-2026-06-21]] [[devflow-generating-statemachine]] [[devflow-compress-ui-no-fold]]
|
||||
|
||||
---
|
||||
|
||||
## 会话概况
|
||||
|
||||
| 项 | 9357c27c | 43b75e49 |
|
||||
|---|---|---|
|
||||
| 标题 | HaoGamePlatfPro | 查看 E 盘 wkhszd 目录内容 |
|
||||
| 跨度 | 2026-06-30 14:31 ~ 15:04(~33 分钟) | 2026-07-01 ~ 07-03(中间大间隔) |
|
||||
| 消息数 | 97 | 77 |
|
||||
| prompt / completion tokens | 644,586 / 3,865 | 601,028 / 4,134 |
|
||||
| completion/prompt 比 | **0.60%** | **0.69%** |
|
||||
| 工具调用次数 | list_directory 22 / read_file 22 / search_files 13 / list_projects 2 / update_project 1 / get_task_count 1 | list_directory 16 / read_file 17 / list_projects 2 / list_project_modules 3 / bind_directory 3 / create_project 1 / update_project 3 |
|
||||
| 工具结果总量 | 195.4 KB | 62.3 KB |
|
||||
| 用户输入字数 | **34 字**(2 条) | 51 字(5 条) |
|
||||
| 用户催促 | 0 | 1(核对) |
|
||||
| 末尾收尾 | assistant(seq96,正常) | assistant(seq76,正常) |
|
||||
| task | 浏览 HaoGamePlatfProject 代码补充项目信息 | 把 wk-hszd 注册为 DevFlow 多工程项目 |
|
||||
|
||||
两会话共同特征:**极少用户输入(34~51 字)产生 60 万级 prompt**,completion/prompt 比 < 1%,极端输入重。
|
||||
|
||||
---
|
||||
|
||||
## P0(2 项)
|
||||
|
||||
### [P0-1] seq0 system 消息污染:assistant 思考流被截断重复拼接 41 次 + role 错位 + 时序倒挂
|
||||
|
||||
- **现象**(9357c27c seq0):
|
||||
- `role=system`,但内容是 assistant 语义:`"我已经充分了解了项目结构。让我快速查看几个关键文件..."`
|
||||
- 同一短语 `"现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。"` **重复 41 次**,拼成 1879 字符
|
||||
- seq0 timestamp `1782971672713` 比 seq1 `1782971504351` **晚 168 秒**(时序倒挂)
|
||||
- **db 证据**:`ai_messages` seq0 row,role=system,status=active,content 长 1879
|
||||
- **根因假设**(强):系统 prompt 注入路径把"上一轮 assistant 流式片段"误当 system 上下文写入;且写入库时陷入重复拼接循环(疑似字符串 builder 在流式 chunk 累积时无去重/无终止条件)。这是 [[aichat-session-analysis-2026-06-22]] P2-1 的**复发且恶化**——上次只见 1 条 assistant 语义残留,本次是 41 次死循环拼接。
|
||||
- **影响**:
|
||||
1. system 消息会作为 prompt 头部送入 LLM,1879 字符无意义噪声污染每一轮上下文
|
||||
2. role 错位会误导模型(system 应是约束指令,却变成"我说的"内容)
|
||||
3. 时序倒挂破坏压缩/排序/计费
|
||||
- **待核验源码点**:
|
||||
- system prompt 注入路径(`ai_messages` 写入 system 行的位置,`src-tauri/src/commands/ai/`)
|
||||
- 流式 chunk 累积逻辑(查 `for` / `push_str` / `+=` 类字符串拼接,是否有循环退出条件)
|
||||
- timestamp 赋值逻辑(为何 system 行用了"插入时刻"而非"会话起始时刻")
|
||||
- **关联**:印证 [[aichat-techdebt-audit-2026-06-21]] P2 区"system 消息混入 assistant 内容"项;与 [[aichat-session-analysis-2026-06-22]] P2-1 同源(前次未根治)
|
||||
|
||||
### [P0-2] 工具死循环重调:LLM 无"已调用过"记忆,同参同路径重复触发
|
||||
|
||||
- **现象**(9357c27c):
|
||||
- `read_file(application.xml)` **读了 17 次**,其中两次返回完全相同的 8457c 全文(同一文件无变化)
|
||||
- `search_files(pattern='.java', path=...src)` **连调 13 次**,每次返回结构几乎一致(items=0, has_more=True,5.5~5.8KB),LLM 完全没意识到在重复
|
||||
- `list_directory(...src)` 2 次,第 2 次返回 30,516c(第 1 次 6,144c,深度参数不一致但 LLM 未说明理由)
|
||||
- **db 证据**:13 条 search_files 调用 `arguments` 完全相同,`result` 长度方差 < 5%
|
||||
- **根因假设**:
|
||||
1. **无工具结果缓存层**:会话级无"同参工具调用 → 返回缓存命中提示"机制,LLM 每次发起都真执行
|
||||
2. **prompt 未告知 LLM 已读内容**:read_file 返回的文件指纹/路径未在后续 prompt 中显式标注"已读",LLM 失忆反复读
|
||||
3. **search_files has_more=True 的分页语义被 LLM 误解**:可能 LLM 以为重调能翻页,但实际每次返第一页
|
||||
- **影响**:
|
||||
- **prompt 64 万的直接元凶之一**:13 次 search × ~5.7KB + 17 次 read × 平均 2.7KB ≈ **75KB 仅重复工具结果**(占 prompt 字符量的主要部分)
|
||||
- 死循环消耗 30+ 轮 LLM 调用,用户等待感极差
|
||||
- **待核验源码点**:
|
||||
- 工具调度层(`tool_registry.rs` / `agentic/mod.rs`)——是否有 `seen_tool_calls` 去重
|
||||
- read_file 是否返回 file_hash 供 LLM 引用(对照 [[aichat-techdebt-audit-2026-06-21]] P1「expected_hash schema 契约破裂」)
|
||||
- search_files 分页契约(为何 has_more=True 但无 page/cursor 参数)
|
||||
- **关联**:印证 [[aichat-session-analysis-2026-06-22]] P2-2(同文件反复 read)+ 新增 search_files 死循环变体
|
||||
|
||||
---
|
||||
|
||||
## P1(3 项)
|
||||
|
||||
### [P1-1] ~~压缩实质失效:compressed 消息平均长度比 active 还长(205%)~~ 【误报,2026-07-07 核验撤销】
|
||||
|
||||
- **原现象**(9357c27c):58/97 条 status=compressed,但 compressed 平均 2641c/条,active 平均 1287c/条——压缩后比未压缩还长 2 倍。
|
||||
- **核验撤销**:进一步按 role 分组统计发现,compressed tool 消息长(4608c)是因为会话前期是大工具调用(list_directory/read_file 大输出),active tool 消息短(1714c)是因为后期转为小操作(update_project)。这是会话业务节奏的自然结果,不是压缩机制问题。
|
||||
- **prompt_tokens 语义澄清**:`prompt_tokens=644,586` 是**会话级累积值**(每轮 prompt 累加,见 `agentic/mod.rs:1366 tokens.add()`),不是最后一轮值。97 条消息 × 平均 6645 tokens/轮 ≈ 644K,完全合理。每轮实际 prompt 只 ~6.6K(active content 49KB ≈ 13K token,压缩后只进 active)。
|
||||
- **结论**:06-22 报告 P0-1 修复(is_active 白名单让 compressed 不进 prompt)**生效**。本项撤销,无需修复。
|
||||
|
||||
### [P1-2] LLM 自检缺失 + 宣称完成即停,用户被迫人工纠错
|
||||
|
||||
- **现象**(43b75e49 wk-hszd):
|
||||
- seq71 assistant 自检发现 bug:`path` 被最后一次 `bind_directory` 覆盖成 `hszd-test`,`stack` 变成 `["Go"]`
|
||||
- 但这是 assistant **自己**在 seq74"再次核对"时才发现——说明 seq68 用户催"核对"前,assistant 已认为完成
|
||||
- 3 次 `bind_directory` 把 path 从 `hszd-api` → `hszd-admin` → `hszd-test`,每次都覆盖前值,assistant 没意识到多工程 monorepo 的 path 字段不该是单子工程
|
||||
- **db 证据**:bind_directory 3 次调用 args 显示 path 被依次覆盖,最终 assistant 自己回滚用 `update_project(path='E:/wk-hszd')` 修正
|
||||
- **根因假设**:
|
||||
1. agent 无"宣称完成前的强制自检"(对照 [[aichat-session-analysis-2026-06-22]] P1-1)
|
||||
2. `bind_directory` 的 `path` 字段语义对多工程 monorepo 有歧义(LLM 以为是"当前操作的子工程",实际是"项目根")
|
||||
- **影响**:用户对 agent 完成度零信任,需人工核对每个字段
|
||||
- **待核验/落点**:
|
||||
- agent 行为策略(prompt 加自检 checklist)
|
||||
- `bind_directory` schema 注释补"monorepo 应用项目根路径,非子工程"
|
||||
- **关联**:[[aichat-decision-capability]] [[aichat-b-route-parallel-multiround]]
|
||||
|
||||
### [P1-3] 工具拒绝消息破坏 JSON 协议:raw 文本"用户拒绝了此操作"
|
||||
|
||||
- **现象**(9357c27c seq95):`update_project` 被用户拒绝,tool role 消息 content = `'用户拒绝了此操作'`(8 字符裸文本),非 JSON
|
||||
- **db 证据**:61 条 tool 消息中 60 条 JSON-parseable,1 条 raw-text(就是这条拒绝)
|
||||
- **根因假设**:工具拒绝路径(`ai_reject` / 审批拒绝)走的是错误字符串直接返回,未包装成 `{"status":"rejected","reason":"..."}` 结构
|
||||
- **影响**:
|
||||
- 若 LLM provider 严格解析 tool role 内容(部分 provider 要求 JSON),会触发协议错误
|
||||
- 与 [[aichat-techdebt-audit-2026-06-21]] P1「审批状态字符串双轨(executed/completed)」同家族——工具返回协议不统一
|
||||
- **待核验源码点**:审批拒绝 handler(`commands.rs` 拒绝分支)的返回序列化
|
||||
|
||||
---
|
||||
|
||||
## P2(2 项)
|
||||
|
||||
### [P2-1] system 消息 timestamp 用"插入时刻"而非"会话时刻",时序倒挂
|
||||
|
||||
- **现象**(9357c27c):seq0 system ts=1782971672713,seq1 user ts=1782971504351,seq0 比 seq1 晚 168 秒
|
||||
- **根因假设**:system prompt 注入是在会话进行中"补写"的(可能是压缩/续轮时插入),用了补写时刻
|
||||
- **影响**:排序、压缩窗口计算、计费全受污染
|
||||
- **关联**:与 P0-1 同源(都是 seq0 system 行的写入 bug)
|
||||
|
||||
### [P2-2] bind_directory 多工程语义错配:单值 path 字段无法表达 monorepo
|
||||
|
||||
- **现象**(43b75e49):wk-hszd 是 Go+Vue+Test 三子工程 monorepo,assistant 3 次 bind_directory 分别绑三个子工程路径,前两个被第三个覆盖
|
||||
- **根因假设**:`projects.path` 是单值字段,而 DevFlow 的"多工程"概念(`project_modules` 表)与"项目根 path"语义未在 schema 注释中澄清
|
||||
- **影响**:多工程项目的 path 字段长期处于"最后绑的子工程"错误状态,直到 LLM 自检或人工纠正
|
||||
- **待核验/落点**:`bind_directory` IPC 的 schema 文档 + 多工程 monorepo 的 path 字段规范(可能应允许 null 或 readonly,工程信息走 `project_modules`)
|
||||
|
||||
---
|
||||
|
||||
## 落点建议(未实施,遵守 [[session-role-diagnose-only]])
|
||||
|
||||
| 项 | 优先级 | 落点 |
|
||||
|---|---|---|
|
||||
| P0-1 system 消息污染 + 重复拼接 | P0 | system prompt 注入路径 + 流式 chunk 累积循环(查 push_str/+= 类拼接) |
|
||||
| P0-2 工具死循环重调 | P0 | 工具结果缓存层(会话级 seen_tool_calls)+ read_file 返回 file_hash + search_files 分页契约 |
|
||||
| P1-1 压缩产出反向膨胀 | P0/P1 | compress_old_messages 产出从拼接改真摘要 |
|
||||
| P1-2 自检 + bind_directory 语义 | P1 | prompt 自检 checklist + bind_directory schema 注释 |
|
||||
| P1-3 拒绝消息 JSON 协议 | P1 | 审批拒绝 handler 返回结构化 JSON |
|
||||
| P2-1 timestamp 时序 | P2 | system 行 timestamp 用会话起始时刻 |
|
||||
| P2-2 多工程 path 语义 | P2 | bind_directory/path schema 文档 |
|
||||
|
||||
**最高杠杆**:P0-1(系统消息污染直接污染每一轮 prompt)+ P0-2(工具死循环是 prompt 64 万的主因)。两者叠加,即使只修这两项,类似会话的 prompt 可降一个量级。
|
||||
|
||||
---
|
||||
|
||||
## 2026-07-07 修复落地(本次实施)
|
||||
|
||||
| 项 | 位置 | 修复 |
|
||||
|---|---|---|
|
||||
| P0-1 LLM 退化输出污染 system 消息 | `commands/ai/compress.rs` | `clean_summary` 加 `is_degenerated_repetition` 退化检测,同一片段重复 ≥5 次返空 → 调用方据空值降级走关键词兑底 |
|
||||
| P0-2 工具死循环重调 | `commands/ai/audit/cache.rs` + `audit/mod.rs` | 新增 `find_cached_readonly_result`(只读幂等工具白名单:read_file/read_symbol/list_directory/search_files/grep),Low 风险执行前查会话历史,同参同成功(completed)调用直接回填缓存跳过真执行 |
|
||||
| P1-3 拒绝消息破 JSON 协议 | `commands/ai/commands/chat.rs` + `audit/mod.rs` | 三处拒绝消息(用户拒绝此操作/路径授权拒绝/路径黑名单拒绝)改结构化 JSON `{status,reason,message}` |
|
||||
| **额外**:Windows 上 .ssh/.aws 黑名单绕过 | `state/allowed_dirs.rs` | `check_path_authorization` 黑名单检查提前到路径规范化之前(对原始路径也判一次)。根因:Windows 上 `/home/user/.ssh/config` 的 `is_absolute()` 返 false,persistent 为空时旧逻辑直接返 NeedsAuthorization 跳过黑名单 |
|
||||
| P1-2 bind_directory 多工程语义错配 | `commands/ai/tool_registry.rs` | 工具描述补充 monorepo 语义:path 应为项目根路径(非子工程),多次调用会覆盖,子工程信息走 list_project_modules |
|
||||
| ~~P1-1 压缩产出反向膨胀~~ | — | **误报撤销**:prompt_tokens 是会话级累积值(非单轮值),按 role 分组统计后压缩机制正常 |
|
||||
|
||||
**测试**:`cargo test -p devflow --lib` 通过(compress 11 + audit 19 + allowed_dirs 20,含新增 6 个回归测试)。原 `test_grep_blacklisted_path_denied`(此前失败)现已通过。
|
||||
|
||||
---
|
||||
|
||||
## 复现方法
|
||||
|
||||
```bash
|
||||
# 只读查询生产 DB
|
||||
python -c "
|
||||
import sqlite3, os
|
||||
db = os.path.expandvars(r'%APPDATA%/top.1216.devflow/devflow.db')
|
||||
con = sqlite3.connect(f'file:{db}?mode=ro', uri=True)
|
||||
# seq0 system 污染证据
|
||||
print(con.execute('select seq, role, length(content), timestamp from ai_messages where conversation_id=? and seq<2 order by seq',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63',)).fetchall())
|
||||
# search_files 13 次重复
|
||||
print(con.execute('select count(*), min(length(result)), max(length(result)) from ai_tool_executions where conversation_id=? and tool_name=?',
|
||||
('9357c27c-aeba-488f-9f4f-12c2d0506f63','search_files')).fetchall())
|
||||
"
|
||||
# 表:ai_conversations / ai_messages / ai_tool_executions
|
||||
```
|
||||
|
||||
数据快照:2026-07-07 抓取自 `devflow.db`(生产库,非 dev)。
|
||||
|
||||
---
|
||||
|
||||
## 与 06-22 报告的对比
|
||||
|
||||
| 项 | 06-22 报告 | 07-07 实测 | 趋势 |
|
||||
|---|---|---|---|
|
||||
| 工具返回裸文本(raw) | patch_file/read_file 大量 raw(6204c) | 仅 1 条(拒绝消息) | ✅ **已修复**(P0-2 性质修正有效) |
|
||||
| 重复 tool 结果落库 | seq 11/12/13/14 同结果 4 份 | 0 条 tool_call_id 重复 | ✅ **已修复**(P0-3 撤销成立) |
|
||||
| system 消息混 assistant 语义 | seq0 残留 1 句 | seq0 重复 41 次拼接 | ❌ **恶化**(P2-1 → P0-1) |
|
||||
| 同文件反复 read | mysql_guide read 4× | application.xml read 17× | ❌ **恶化**(P2-2 → P0-2) |
|
||||
| 上下文管理实质失效 | compressed 未降 prompt(已隐式修复) | compressed 产出反向膨胀 205% | ⚠️ **新变体**(P0-1 → P1-1) |
|
||||
| 中断(tool 后无 assistant) | seq85 停尸 | 无(两会话均 assistant 收尾) | ✅ **已修复** |
|
||||
|
||||
**结论**:06-22 后修复的"工具返回协议/落库去重/中断"三项**确实生效**;但"system 污染"和"工具死循环"两类问题**反而恶化**——前者从残留 1 句变成 41 次拼接,后者从 4× 变成 17×。说明这两类是未根治的根因,须单独立项。
|
||||
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
144
docs/05-代码审查/aichat-对话功能走查-2026-07-17.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# aichat 对话功能深度走查报告(2026-07-17)
|
||||
|
||||
> 触发:用户反馈「对话功能现在存在很多问题,深入检查」。
|
||||
> 方法:5 个 general-purpose 代理并行走查 5 维度(发送/流式/渲染/会话侧栏顶栏/审批上下文)→ 主代理独立 grep/read 核验所有 P0 + 关键 P1(不信代理结论,每条标注核验状态)。
|
||||
> 性质:**走查任务,未改任何代码**。本报告为问题清单 + 修复方向,待用户确认后实施。
|
||||
|
||||
## 健康度总评:B-
|
||||
|
||||
最近一批发改动(BUG-2026-07-17:状态机收敛方向正确)引入 **2 个 P0 回归**;其余 P1 多源自 **F-09 per-conv 改造未完成**的单例债(queue / legacy watchdog / _approvalTimers / modelOverride / _lastDelta 仍是全局单例,多会话并发下串话)。
|
||||
|
||||
---
|
||||
|
||||
## P0(功能 bug,建议立即修)
|
||||
|
||||
### P0-1 流式 watchdog legacy 路径全局误杀并发会话 + 删 running 保护致长工具误杀 ✅核验成立
|
||||
|
||||
**位置**:`useAiStream.ts:57-110, 73-78` / `useAiSend.ts:156,226`(doSend reset 同款)/ `audit/mod.rs:125-128`
|
||||
|
||||
**双重回归**(本次改动引入):
|
||||
|
||||
① **legacy 路径全局 clear**:doSend / regenerate(`useAiSend.ts:156`) / editMessage(`:226`) 调 `resetStreamWatchdog()` **无 convId** → 走 `_legacyWatchdog` fallback(`useAiStream.ts:128-132`)。45s 到期 `onStreamTimeout()` 无参 → `convStates.clear()`(`:77`)清掉**所有并发会话**的生成态。其他会话停止按钮失效、UI 与后端长期不一致。
|
||||
|
||||
② **删 running 保护**:`onStreamTimeout`(`:69`「不管 stillGenerating 检测」)去掉了原 stillGenerating + running 工具检测。长工具执行(cargo build / 测试套件 / 大文件 patch >45s)必然触发。后端心跳首 tick **30s**(`audit/mod.rs:126` 弃首 tick),AiHeartbeat 到达时前端 `resetStreamWatchdog(convId)` 只动 per-conv Map、**不清 legacy**,保护不了 doSend 挂的 legacy timer。
|
||||
|
||||
**证据**:`useAiStream.ts:69`「不管 stillGenerating 检测(即使已经 false 也再清理一次)」+ `:77` `convStates.clear()` + `audit/mod.rs:125-128` 心跳 30s。
|
||||
|
||||
**影响**:多会话并发下其他会话被误杀 + 任何 >45s 的工具执行被前端误判断流(回到 BUG-260624-03 修复前的用户报障状态)。
|
||||
|
||||
**修复方向**:
|
||||
- legacy 路径 `convStates.clear()` → `convStates.delete(state.activeConversationId)`(对齐 legacy 单 timer 只跟踪当前会话语义)
|
||||
- 恢复 `onStreamTimeout` 的 running 工具检测(工具执行中跳过 + reset 续等),或 `STREAM_TIMEOUT_MS` 回调 ≥75s
|
||||
- 根治:doSend / regenerate / editMessage 的 `resetStreamWatchdog` 传 convId 走 per-conv(F-09 迁移)
|
||||
|
||||
### P0-2 审批计时器切/删会话不清 + 注释与代码矛盾 ✅核验成立
|
||||
|
||||
**位置**:`useAiConversations.ts:92-257`(switch 无 clearApprovalTimer)/ `:260-272`(delete 无清)/ `aiShared.ts:118`(默认 15min)vs `useAiConversations.ts:244-246`(注释「Infinity 已决策不超时」)
|
||||
|
||||
**证据**:
|
||||
- switchConversation 全程未调 clearApprovalTimer / clearAllApprovalTimers(已核 90-257)
|
||||
- deleteConversation 仅 `clearConvStreamState(id)`,不清 `_approvalTimers` 中属于该会话的 timer
|
||||
- `startApprovalTimer` 到点回调(`aiShared.ts:147-163`)调 `aiApi.approve(id,false)` + push 错误气泡到 `getMessages()`(**当前 active 视图**)
|
||||
- `:244-246` 注释「APPROVAL_TIMEOUT_MS=Infinity 已决策,不会超时自动拒绝」与 `:118` `APPROVAL_TIMEOUT_DEFAULT_MS=900_000`(15min)**直接矛盾**
|
||||
|
||||
**影响**:① 切走会话后 15min 到点 → 误拒后台 pending 工具 + 错误气泡 push 到当前(错误)会话视图;② 用户重启恢复审批后离开,回来发现审批已被无声自动拒绝。
|
||||
|
||||
**修复方向**:switchConversation 开头 `clearAllApprovalTimers()`(对齐 pendingApprovals 单例语义);deleteConversation 清该 conv 的 timer;修正 `:244` 注释。
|
||||
|
||||
---
|
||||
|
||||
## P1(明显缺陷)
|
||||
|
||||
### P1-1 [发送] queue=[] 跨会话丢消息 ✅核验成立
|
||||
`useAiSend.ts:155,225` `state.queue = []`,queue 是 store 全局单例。B 会话 regenerate/editMessage 清掉 A 排队的用户消息,静默永久丢失。
|
||||
|
||||
### P1-2 [发送] drainQueue 无并发互斥 ✅核验成立
|
||||
`useAiSend.ts:257-281` fire-and-forget 无锁,AiCompleted 连发 / stop+Completed 竞态致并发 doSend,前端 user/ai 占位双 push(后端 generating 守卫挡大部分但视图已污染)。
|
||||
|
||||
### P1-3 [会话] TopBar watch(activeConversationId) 无条件重置 modelOverride ✅核验成立
|
||||
`TopBar.vue:325-327` 切会话无条件 `modelOverride = enabledModels[0]`,切会话往返丢失用户手选模型;与 `:335` `watch(enabledModels,{immediate})` 叠加语义脆弱。modelOverride 是模块级 ref,不持久化、不 per-conv。
|
||||
|
||||
### P1-4 [发送] tryForceSend 跨会话错位 + 队列续发 spans 丢失 ✅核验成立
|
||||
`useAiSend.ts:323-328` queue item 无 convId / spans 字段,切会话后 forceSend 发到当前 active 而非原会话;mention chip 续发降级为纯文本。
|
||||
|
||||
### P1-5 [流式] _lastDelta 单值跨会话/跨轮误丢合法 delta ⚠️部分成立
|
||||
`useAiEvents.ts:46,292` 单值重复检测,跨会话首 delta 撞同 / 同轮合法重复(Markdown 表格分隔符等)被误丢。难复现。
|
||||
|
||||
### P1-6 [流式] state.currentText += delta 无 streaming 守卫 ⚠️部分成立
|
||||
`useAiEvents.ts:300`,P0-1 触发后超时收尾仍到达的 delta 污染 currentText。修 P0-1 即间接修复。
|
||||
|
||||
### P1-7 [渲染] MessageItem.vue system/assistant/error 死代码 ✅核验成立
|
||||
`MessageItem.vue:94-170`,MessageList 仅 `role==='user'` 引用 MessageItem(`:663-673`),AI 渲染全在 MessageList 内联。MessageItem 内 `.ai-cursor-blink` 是孤岛 class(全仓库无其他定义)。维护陷阱。
|
||||
|
||||
### P1-8 [渲染] _blockCache 无失效机制 ✅核验成立
|
||||
`useStreamRenderer.ts:117` 模块级 Map 无会话切换 / 配置变更失效,长会话膨胀 + Markdown 主题热改无效 + 瞬态错误固化。
|
||||
|
||||
### P1-9 [渲染] messages deep watch + JSON.stringify 每帧全量遍历 ✅核验成立
|
||||
`MessageList.vue:244` deep watch 每帧 O(N×content) 遍历,长会话流式掉帧。
|
||||
|
||||
### P1-10 [渲染] useToolApproval onApprove confirmDialog 无防重入 ✅核验成立
|
||||
`useToolApproval.ts:98-113` High 风险工具 confirmDialog await 期间 approving 未置 true,双击重复审批。
|
||||
|
||||
### P1-11 [会话] 删当前会话 active=null 无 fallback ✅核验成立
|
||||
`useAiConversations.ts:260-272` 删除当前会话后空白无引导,应回落相邻会话。
|
||||
|
||||
### P1-12 [审批] 审批卡无 conversationId,多会话路由靠 activeConversationId 推断 ⚠️部分成立
|
||||
`useAiApproval.ts:65`,F-09 多会话并发下 A 后台审批卡渲染到 B 视图时点击路由不准(依赖多会话并发 + 后台审批叠加才触发)。
|
||||
|
||||
---
|
||||
|
||||
## P2(健壮性/可读性)
|
||||
|
||||
- `useStreamRenderer.ts:139` tailSeq 局部失效 —— **功能没坏**(v-html 值变化兜底更新),仅注释「末块 key 每次不同」与实现(恒为 `tail-1`)相反,误导维护者。
|
||||
- `ApprovalOverlay.vue:61` `Date.now()` 在 computed 内,60s 冷却期不响应式过期。
|
||||
- `aiShared.ts:288-311` setConvStreaming / setConvCurrentText 收敛不对称,Map 项可能残留。
|
||||
- `ConversationSidebar.vue:255` 标题 flash watch 死代码(`if(oldTitle) return` 覆盖前一行)。
|
||||
- `useAiWindow.ts:309` maximized 分支 `targetHeight` 三元死代码(高度 vs 宽度比较)。
|
||||
- `useToolApproval.ts:29` `emit: any` 类型擦除。
|
||||
- `useAiContext.ts:29` `isCompressing` 模块单例,多窗口共享。
|
||||
|
||||
---
|
||||
|
||||
## ❌代理误报已否决(独立核验)
|
||||
|
||||
- **~~[会话 P0] switchConversation currentText accessor 重定向~~**:代理4 称 `:206 state.currentText=''` 在两次 await 间被 accessor 重定向。**否决**——`:113` 设 `activeConversationId=B` 后到 `:206` 是同步连续代码(无 await),`:206` 之后才 `:212 await`。accessor 不会中途重定向,写的是正确的目标会话。代理4 时序分析错误。
|
||||
|
||||
---
|
||||
|
||||
## 根源分析
|
||||
|
||||
所有 P0/P1 的共同根因:**F-09 per-conv 改造未完成**。
|
||||
|
||||
| 状态源 | per-conv 化状态 | 串话表现 |
|
||||
|---|---|---|
|
||||
| streaming / currentText | ✅ 已 per-conv(DEC-07a accessor) | — |
|
||||
| convStates(conv_state 真相源) | ✅ 已 per-conv(Map) | 但 watchdog legacy fallback 路径 `clear()` 全局误杀(P0-1) |
|
||||
| queue | ❌ 全局单例 | 跨会话丢消息(P1-1)、强发错位(P1-4) |
|
||||
| watchdog | ⚠️ 双轨(per-conv Map + legacy 单 timer) | legacy 路径不清 by 心跳、全局 clear(P0-1) |
|
||||
| _approvalTimers | ❌ 全局 Map | 切/删会话不清(P0-2) |
|
||||
| modelOverride | ❌ 模块级 ref | 切会话丢失手选(P1-3) |
|
||||
| _lastDelta | ❌ 模块级单值 | 跨会话误丢 delta(P1-5) |
|
||||
|
||||
---
|
||||
|
||||
## 建议修复优先级
|
||||
|
||||
1. **P0-1**(流式误杀,最高频可复现)—— legacy `clear()` 改 `delete(activeConversationId)` + 恢复 running 工具检测
|
||||
2. **P0-2**(审批误拒 + 错气泡)—— switch / delete 清 timer + 修正注释
|
||||
3. **P1-1 / P1-3 / P1-4**(单例债三连,可攒批修)
|
||||
4. 其余 P1(渲染性能/死代码/防重入)按维度推进
|
||||
5. P2 择机清理
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `src/composables/ai/useAiStream.ts`(P0-1 核心)
|
||||
- `src/composables/ai/useAiSend.ts`(P0-1 reset / P1-1,2,4)
|
||||
- `src/composables/ai/streamingGuard.ts`(P0-1 forceReset 同款 clear)
|
||||
- `src-tauri/src/commands/ai/audit/mod.rs`(P0-1 后端心跳 30s)
|
||||
- `src/composables/ai/useAiConversations.ts`(P0-2 / P1-11)
|
||||
- `src/composables/ai/aiShared.ts`(P0-2 _approvalTimers / P2 收敛)
|
||||
- `src/components/ai/TopBar.vue`(P1-3 双 watch)
|
||||
- `src/components/ai/MessageList.vue`(P1-7,9)
|
||||
- `src/components/ai/MessageItem.vue`(P1-7 死代码)
|
||||
- `src/composables/ai/useStreamRenderer.ts`(P1-8 / P2 tailSeq)
|
||||
- `src/composables/ai/useToolApproval.ts`(P1-10)
|
||||
188
docs/Batch-Overview.md
Normal file
188
docs/Batch-Overview.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# DevFlow 批次可视化总览
|
||||
|
||||
> 基于 [Batch.md](../Batch.md) 生成 | 更新: 2026-07-01
|
||||
|
||||
---
|
||||
|
||||
## 一、批次时间线(已完成)
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title DevFlow 批次推进时间线
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %m-%d
|
||||
|
||||
section 审批与工具卡
|
||||
Batch 1 审批超时后端兜底 :done, b1, 2026-06-14, 1d
|
||||
Batch 2 工具卡片拆分核验 :done, b2, after b1, 1d
|
||||
Batch 3 条件表达式 UI 翻译 :done, b3, after b2, 1d
|
||||
|
||||
section 数据与状态
|
||||
Batch 4 知识提炼切读消息表 :done, b4, after b3, 1d
|
||||
Batch 5 关闭已完成待办项 :done, b5, after b4, 1d
|
||||
Batch 6 应用状态拆分+路径授权 :done, b6, after b5, 1d
|
||||
|
||||
section 跨端与消息
|
||||
Batch 7 小程序配置持久化 :done, b7, after b6, 1d
|
||||
Batch 8 消息分页懒加载 :done, b8, after b7, 1d
|
||||
Batch 9 多工程数据层 :done, b9, after b8, 1d
|
||||
|
||||
section 文件浏览器
|
||||
Batch 10 文件浏览器 UI :done, b10, after b9, 1d
|
||||
Batch 11 Git 只读 AI 工具 :done, b11, after b10, 1d
|
||||
Batch 12 Git 写操作 AI 工具 :done, b12, after b11, 1d
|
||||
Batch 13 文件浏览器增强 :done, b13, after b12, 1d
|
||||
|
||||
section 收尾与重构
|
||||
Batch 14 浏览器收尾 :done, b14, after b13, 1d
|
||||
Batch 15 多工程管理 :done, b15, after b14, 1d
|
||||
Batch 16 提交历史作者 :done, b16, after b15, 1d
|
||||
|
||||
section 崩溃修复与 UX
|
||||
渲染崩溃修复 TDZ+窗口权限 :done, fix1, after b16, 1d
|
||||
Batch 17 Git 能力闭环 :done, b17, after fix1, 1d
|
||||
Batch 18 小程序审批超时 :done, b18, after b17, 1d
|
||||
|
||||
section UX 与架构
|
||||
Batch 19 任务管理 UX 重构 :done, b19, after b18, 1d
|
||||
Batch 20 coordinator.rs 删除 :done, b20, after b19, 1d
|
||||
任务快捷操作菜单 :done, menu1, after b20, 1d
|
||||
|
||||
section DAG 与依赖图
|
||||
TDZ 防御 :done, tdz1, after menu1, 1d
|
||||
Plan 执行器骨架 :done, plan1, after tdz1, 1d
|
||||
Batch 25 工程依赖图数据层 :done, b25, after plan1, 1d
|
||||
|
||||
section 增强
|
||||
Batch 26 dagre 布局 :done, b26, after b25, 1d
|
||||
Batch 28 架构债收尾 :done, b28, after b26, 1d
|
||||
Batch 29 环检测+PNG+总数 :done, b29, after b28, 1d
|
||||
|
||||
section 待推进
|
||||
Batch 30 代码质量收尾 :b30, after b29, 3d
|
||||
Batch 31 MessageList 拆分 :b31, after b30, 3d
|
||||
Batch 32 ChatInput 拆分 :b32, after b31, 3d
|
||||
Batch 33 多 ReAct 对接 :b33, after b32, 5d
|
||||
Batch 34 产品完善 :b34, after b33, 3d
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、功能领域分布
|
||||
|
||||
```mermaid
|
||||
pie title 批次功能领域分布(按完成批次数)
|
||||
"文件浏览器/Git" : 8
|
||||
"任务管理 UX" : 3
|
||||
"工程系统" : 4
|
||||
"AI/Agent" : 4
|
||||
"架构债/重构" : 4
|
||||
"小程序" : 2
|
||||
"Bug 修复" : 3
|
||||
"依赖图可视化" : 4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、批次依赖关系图
|
||||
|
||||
> 仅展示批次级别依赖,子任务明细见下方表格。
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
B6["B6 状态拆分"] --> B9["B9 多工程数据层"]
|
||||
B9 --> B10["B10 文件浏览器"]
|
||||
B9 --> B25["B25 依赖图数据"]
|
||||
B10 --> B13["B13 浏览器增强"]
|
||||
B13 --> B14["B14 收尾"]
|
||||
B13 --> B15["B15 多工程管理"]
|
||||
B13 --> B16["B16 提交作者"]
|
||||
B13 --> B17["B17 Git 闭环"]
|
||||
B25 --> B26["B26 dagre布局"]
|
||||
B26 --> B29["B29 环检测+PNG"]
|
||||
B9 --> B11["B11 Git只读"]
|
||||
B11 --> B12["B12 Git写操作"]
|
||||
PLAN["Plan执行器"] --> B33["B33 多ReAct"]
|
||||
B30["B30 代码质量"] --> B31["B31 MsgList拆分"]
|
||||
B31 --> B32["B32 ChatInput拆分"]
|
||||
|
||||
style B6 fill:#2d5a2d,color:#fff
|
||||
style B9 fill:#2d5a2d,color:#fff
|
||||
style B10 fill:#2d5a2d,color:#fff
|
||||
style B11 fill:#2d5a2d,color:#fff
|
||||
style B12 fill:#2d5a2d,color:#fff
|
||||
style B13 fill:#2d5a2d,color:#fff
|
||||
style B14 fill:#2d5a2d,color:#fff
|
||||
style B15 fill:#2d5a2d,color:#fff
|
||||
style B16 fill:#2d5a2d,color:#fff
|
||||
style B17 fill:#2d5a2d,color:#fff
|
||||
style B25 fill:#2d5a2d,color:#fff
|
||||
style B26 fill:#2d5a2d,color:#fff
|
||||
style B29 fill:#2d5a2d,color:#fff
|
||||
style PLAN fill:#2d5a2d,color:#fff
|
||||
style B30 fill:#5a4a2d,color:#fff
|
||||
style B31 fill:#5a4a2d,color:#fff
|
||||
style B32 fill:#5a4a2d,color:#fff
|
||||
style B33 fill:#5a4a2d,color:#fff
|
||||
```
|
||||
|
||||
绿色 = 已完成 | 橙色 = 待推进
|
||||
|
||||
---
|
||||
|
||||
## 四、批次子任务明细
|
||||
|
||||
| 批次 | 状态 | 子任务 |
|
||||
|------|------|--------|
|
||||
| Batch 9 | ✅ | project_modules 表 · ModuleRepo CRUD · 创建项目适配 · AI 工具注册 |
|
||||
| Batch 10 | ✅ | 文件树组件 · 文件预览 · Git 状态标记 · 面包屑导航 |
|
||||
| Batch 13 | ✅ | 行号/图标 · Diff 视图 · Git 变更面板 · 窗口分离 · 分页提交历史 |
|
||||
| Batch 17 | ✅ | Diff 行号解析 · 分支只读展示 · 提交详情 · 写入后自动刷新 |
|
||||
| Batch 19 | ✅ | 搜索框 · 筛选改下拉 · 排序选择器 · 分组可折叠 · 快捷操作菜单 |
|
||||
| Batch 25 | ✅ | module_dependencies 表 · DependencyRepo CRUD · 依赖边渲染 · 小地图 |
|
||||
| Batch 26 | ✅ | dagre 布局 · 添加依赖弹窗 · 边类型着色 |
|
||||
| Batch 29 | ✅ | 环形检测 IPC · PNG 导出 · count_tasks IPC · 真实 total |
|
||||
| Batch 30 | ⏸️ | 搜索索引核验 · 编译警告清理 · i18n 核验 · String 替 newtype |
|
||||
| Batch 31 | ⏸️ | 提取 MessageItem.vue · 提取 useMessageScroll.ts |
|
||||
| Batch 32 | ⏸️ | 提取 SkillMention.vue · 提取 ImageInput.vue |
|
||||
| Batch 33 | ⏸️ | PlanExecutor 接入 loop · plan_hint 生成 DAG · 执行进度展示 |
|
||||
|
||||
---
|
||||
|
||||
## 四、关键指标
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 已完成批次 | 29 |
|
||||
| 待推进批次 | 5 |
|
||||
| 总提交数(Batch 1-29) | ~35 |
|
||||
| 涉及 Rust 文件 | ~25 |
|
||||
| 涉及 Vue/TS 文件 | ~40 |
|
||||
| 新增数据库迁移版本 | V35 |
|
||||
| 新增 IPC 命令 | ~15 |
|
||||
| 新增 npm 依赖 | @antv/x6 / dagre / highlight.js |
|
||||
| 当前最大文件 | MessageList.vue(1552行,待拆分) |
|
||||
|
||||
---
|
||||
|
||||
## 五、待推进批次优先级矩阵
|
||||
|
||||
| 批次 | 价值 | 风险 | 象限 | 建议 |
|
||||
|------|------|------|------|------|
|
||||
| Batch 30 代码质量 | 中 | 低 | 高价值低风险 | ✅ 优先做 |
|
||||
| Batch 31 MessageList 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||
| Batch 32 ChatInput 拆分 | 高 | 高 | 高价值高风险 | ⚠️ 需谨慎,要测试覆盖 |
|
||||
| Batch 33 多 ReAct 对接 | 很高 | 很高 | 高价值高风险 | ⚠️ 核心架构改动 |
|
||||
| Batch 34 产品完善 | 中 | 中 | 中价值中风险 | 🟡 可延后 |
|
||||
|
||||
---
|
||||
|
||||
## 六、批次提交密度
|
||||
|
||||
```mermaid
|
||||
xychart-beta
|
||||
title "批次提交密度(按阶段)"
|
||||
x-axis ["阶段1", "阶段2", "阶段3", "阶段4", "阶段5", "阶段6", "阶段7", "阶段8", "阶段9"]
|
||||
y-axis "批次数" 0 --> 8
|
||||
bar [3, 3, 2, 3, 3, 3, 3, 3, 3]
|
||||
```
|
||||
@@ -108,7 +108,7 @@ docs/
|
||||
├── 08-用户指南/
|
||||
│ ├── 使用手册-2026-06-12.md # 用户手册(对齐 06-15 代码基线)
|
||||
│ ├── AI文件操作工具手册.md # AI 工具 API 全量参考(10 工具,2026-06-16 更新)
|
||||
│ └── patch_file使用指南.md # patch_file 专项使用指南
|
||||
│ ├── patch_file使用指南.md # patch_file 专项使用指南
|
||||
└── 09-问题排查/
|
||||
└── aichat-apikey-401排查-2026-06-15.md
|
||||
```
|
||||
|
||||
12
docs/todo.md
12
docs/todo.md
@@ -312,3 +312,15 @@ graph TD
|
||||
> ✅ 全部修复完成(2026-06-27 核验)
|
||||
|
||||
- [~] **IDEA-FIX-10 [P2🟠]** — **前端 filter + 后端分页漏数据**。团队决策:`hot`/`pending` 保留前端 filter(扩多值属性过度设计);keyword/order_by 已下沉后端。
|
||||
|
||||
---
|
||||
|
||||
### 🔍 2026-07-17 aichat 对话功能深度走查待办
|
||||
|
||||
> 详单:[05-代码审查/aichat-对话功能走查-2026-07-17.md](./05-代码审查/aichat-对话功能走查-2026-07-17.md)
|
||||
|
||||
- [x] **AIC-FIX-17-P0-1** — 流式 watchdog legacy 路径 `convStates.clear()` 全局误杀 → `delete(state.activeConversationId)` ✅
|
||||
- [x] **AIC-FIX-17-P0-2** — 审批计时器切/删会话不清 → switch/delete 入口 `clearAllApprovalTimers()` + 注释修正 ✅
|
||||
- [ ] **AIC-FIX-17-P1-1~11** — P1 队列/互斥/modelOverride/死代码/缓存/防重入/删除回落(待后续批次)
|
||||
- [ ] **AIC-FIX-17-P2** — P2 健壮性项(待后续批次)
|
||||
- [ ] **AIC-FIX-17-根因** — F-09 per-conv 收尾(queue / modelOverride / _approvalTimers / _lastDelta 单例化)
|
||||
|
||||
@@ -20,6 +20,7 @@ tauri-plugin-window-state = "2"
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
tokio.workspace = true
|
||||
dashmap.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
@@ -56,9 +57,10 @@ regex = { workspace = true }
|
||||
# 根因见 docs/09-问题排查/aichat-apikey-401排查-2026-06-15.md
|
||||
keyring = { workspace = true }
|
||||
# http_request AI 工具:结构化 HTTP(GET/POST/...)。
|
||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。默认 native-tls(与 df-ai 一致),
|
||||
# 启用 json(响应解析)/gzip/brotli(透明解压,常见 API 必备)。重定向手动接管(见 http.rs SSRF)。
|
||||
reqwest = { version = "0.12", features = ["json", "gzip", "brotli"] }
|
||||
# 复用 df-ai 同款 reqwest 0.12(同版本锁定,避免双 TLS 后端)。
|
||||
# 使用 rustls-tls(非 native-tls),避免 Windows SChannel 同步阻塞 tokio 工作线程
|
||||
# 致 BUG-2026-07-17(aichat 流式调用永久 hang,外层 tokio timeout 亦无法推进计时器)。
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "gzip", "brotli", "rustls-tls"] }
|
||||
# AST 代码智能(read_symbol 三态,信息密度驱动,见 docs/02-架构设计/专项设计/AST符号解析-设计-2026-06-24.md):
|
||||
# 治 aichat read_file 全文回灌 prompt 爆(e46f5605 360K/8dfe0b94 5M)。tree-sitter 语法层精准提取
|
||||
# 符号骨架/下钻/全文,替代物理读全文件。静态编译 + 集中 grammar_for(ext) lookup(不动态加载/不抽 trait,YAGNI)。
|
||||
|
||||
134
src-tauri/src/commands/ai/agentic/command_lock.rs
Normal file
134
src-tauri/src/commands/ai/agentic/command_lock.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! 命令执行互斥锁 — 防止多 Agent 并行时同目录的同名命令资源竞争
|
||||
//!
|
||||
//! 设计依据:docs/02-架构设计/专项设计/多Agent并行执行与仲裁合并设计-2026-07-01.md §4.3
|
||||
//!
|
||||
//! 场景:多 Agent 并行时,两个 SubTask 同时跑 `npm install` 争抢 node_modules。
|
||||
//! 方案:按 (working_dir, command_name) 加 mutex,同 key 串行化。
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// 全局命令锁注册表
|
||||
/// key = "{working_dir}::{command_first_word}"
|
||||
/// 每个 key 一个 Semaphore(1),同 key 并发请求串行化
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommandLockRegistry {
|
||||
inner: Arc<Mutex<HashMap<String, Arc<Semaphore>>>>,
|
||||
}
|
||||
|
||||
impl CommandLockRegistry {
|
||||
#[allow(dead_code)]
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取(或创建)指定 key 的信号量。
|
||||
/// 返回 Arc<Semaphore>,调用方 await acquire 后执行命令,drop permit 后释放。
|
||||
#[allow(dead_code)]
|
||||
pub fn get_or_create(&self, working_dir: &str, command: &str) -> Arc<Semaphore> {
|
||||
let cmd_first = command.split_whitespace().next().unwrap_or(command);
|
||||
let key = format!("{}::{}", working_dir, cmd_first);
|
||||
let mut guard = self.inner.lock().unwrap();
|
||||
guard
|
||||
.entry(key)
|
||||
.or_insert_with(|| Arc::new(Semaphore::new(1)))
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 当前注册的锁数量(调试/监控用)
|
||||
#[allow(dead_code)]
|
||||
pub fn lock_count(&self) -> usize {
|
||||
self.inner.lock().unwrap().len()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CommandLockRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn lck_01_same_dir_same_cmd_serialized() {
|
||||
let registry = CommandLockRegistry::new();
|
||||
let sem1 = registry.get_or_create("/project", "npm install");
|
||||
let sem2 = registry.get_or_create("/project", "npm install");
|
||||
|
||||
// 同 key 应返回同一个 Semaphore
|
||||
assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+cmd 应返回同一个 Semaphore");
|
||||
|
||||
// 获取第一个 permit
|
||||
let permit1 = sem1.acquire().await.unwrap();
|
||||
// 第二个应等待(非阻塞测试:try_acquire 应失败)
|
||||
let try_result = sem2.try_acquire();
|
||||
assert!(try_result.is_err(), "同 key 第二个应被阻塞");
|
||||
|
||||
drop(permit1);
|
||||
// 释放后第二个应能获取
|
||||
let permit2 = sem2.acquire().await;
|
||||
assert!(permit2.is_ok(), "释放后应可获取");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lck_02_different_cmds_parallel() {
|
||||
let registry = CommandLockRegistry::new();
|
||||
let npm_sem = registry.get_or_create("/project", "npm install");
|
||||
let cargo_sem = registry.get_or_create("/project", "cargo build");
|
||||
|
||||
// 不同命令应是不同的 Semaphore
|
||||
assert!(!Arc::ptr_eq(&npm_sem, &cargo_sem), "不同 cmd 应是不同 Semaphore");
|
||||
|
||||
// 两个都能同时获取
|
||||
let p1 = npm_sem.acquire().await.unwrap();
|
||||
let p2 = cargo_sem.acquire().await.unwrap();
|
||||
drop(p1);
|
||||
drop(p2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lck_03_different_dirs_parallel() {
|
||||
let registry = CommandLockRegistry::new();
|
||||
let sem1 = registry.get_or_create("/project-a", "npm install");
|
||||
let sem2 = registry.get_or_create("/project-b", "npm install");
|
||||
|
||||
// 不同目录应是不同的 Semaphore
|
||||
assert!(!Arc::ptr_eq(&sem1, &sem2), "不同 dir 应是不同 Semaphore");
|
||||
|
||||
// 两个都能同时获取
|
||||
let p1 = sem1.acquire().await.unwrap();
|
||||
let p2 = sem2.acquire().await.unwrap();
|
||||
drop(p1);
|
||||
drop(p2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lck_04_command_extracted_first_word() {
|
||||
let registry = CommandLockRegistry::new();
|
||||
let sem1 = registry.get_or_create("/proj", "npm install --force");
|
||||
let sem2 = registry.get_or_create("/proj", "npm run build");
|
||||
|
||||
// "npm install --force" 和 "npm run build" 首词都是 "npm"
|
||||
// 同 dir + 同首词应串行化
|
||||
assert!(Arc::ptr_eq(&sem1, &sem2), "同 dir+同首词应串行化");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lck_05_lock_count_tracks_keys() {
|
||||
let registry = CommandLockRegistry::new();
|
||||
assert_eq!(registry.lock_count(), 0);
|
||||
registry.get_or_create("/a", "npm");
|
||||
assert_eq!(registry.lock_count(), 1);
|
||||
registry.get_or_create("/a", "cargo");
|
||||
assert_eq!(registry.lock_count(), 2);
|
||||
registry.get_or_create("/a", "npm"); // 已存在
|
||||
assert_eq!(registry.lock_count(), 2);
|
||||
}
|
||||
}
|
||||
228
src-tauri/src/commands/ai/agentic/context_lifecycle.rs
Normal file
228
src-tauri/src/commands/ai/agentic/context_lifecycle.rs
Normal file
@@ -0,0 +1,228 @@
|
||||
//! Agentic 循环上下文生命周期:F-15 自动压缩(智能裁剪)。
|
||||
//!
|
||||
//! 从 `mod.rs::run_agentic_loop` 机械抽取(L1017-1193),行为零变更。
|
||||
//!
|
||||
//! 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//!
|
||||
//! 流程(对齐阶段2 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 裁剪。
|
||||
//!
|
||||
//! 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
||||
//! 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口;
|
||||
//! 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
||||
//! 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
||||
//! 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
||||
//! 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
||||
//! (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
||||
//!
|
||||
//! 安全(FR-S1):复用 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)。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::context_helpers::extract_keyword_summary;
|
||||
use df_ai::provider::{ChatMessage, LlmProvider};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
|
||||
use crate::state::{AppState, LlmConcurrency};
|
||||
|
||||
// compress_via_llm 位于 commands::ai::compress(mod.rs 的 super::compress)。
|
||||
use super::super::compress::compress_via_llm;
|
||||
use super::{AiChatEvent, AiSession};
|
||||
|
||||
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
|
||||
/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组)
|
||||
/// 不参与压缩,避免压缩正在使用的活跃消息。
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
||||
///
|
||||
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
||||
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
||||
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
|
||||
/// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
///
|
||||
/// 从 `run_agentic_loop` 抽取,行为零变更。返回值:
|
||||
/// - `true`:conv 已删除,调用方应立即 `return`(退出整个 loop)。
|
||||
/// - `false`:正常结束(无论是否触发压缩、压缩是否成功),调用方继续后续流程。
|
||||
///
|
||||
/// 参数沿用原内联代码引用的局部:
|
||||
/// - `provider`:loop 顶部 build 的 provider(F-04b 切换 candidate 后由调用方传入最新)。
|
||||
/// - `provider_config`:同上,compress 沿用主 candidate(非 fallback 范围)。
|
||||
/// - `iteration`:当前轮次(仅用于日志)。
|
||||
pub(super) async fn maybe_auto_compress(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
app_handle: &AppHandle,
|
||||
provider: &Box<dyn LlmProvider>,
|
||||
provider_config: &AiProviderRecord,
|
||||
llm_concurrency: &LlmConcurrency,
|
||||
iteration: usize,
|
||||
) -> bool {
|
||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||
if !prev_compressing {
|
||||
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
||||
let (should_compress, protect_start, pre_compress_tokens) = {
|
||||
let session = session_arc.lock().await;
|
||||
let mgr = match session.conv_read(&conv_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
let mgr = &mgr.messages;
|
||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||
let history_tokens = mgr.history_tokens();
|
||||
let budget = mgr.budget_limit();
|
||||
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
|
||||
let should = protect_start > 0
|
||||
&& (budget as u64) * 6 / 10 < history_tokens as u64
|
||||
&& mgr.has_compressible_messages(protect_start);
|
||||
(should, protect_start, history_tokens)
|
||||
};
|
||||
|
||||
if should_compress {
|
||||
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
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::<AppState>().ai_event_bus.publish_event(ev);
|
||||
let (active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.set_compressing(true);
|
||||
// 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。
|
||||
let active_msgs: Vec<ChatMessage> = conv.messages.messages_mut()
|
||||
[..protect_start]
|
||||
.iter()
|
||||
.filter(|t| t.message.is_active())
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let lang = conv.agent_language.clone()
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
(active_msgs, lang)
|
||||
};
|
||||
|
||||
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
|
||||
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
||||
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
||||
extract_keyword_summary(&active_msgs)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
||||
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
compress_via_llm(
|
||||
provider.as_ref(),
|
||||
provider_config,
|
||||
active_msgs,
|
||||
&lang,
|
||||
conv_id,
|
||||
llm_concurrency,
|
||||
).await.map(Some)
|
||||
};
|
||||
|
||||
match compress_outcome {
|
||||
Ok(Some(summary)) => {
|
||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
||||
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
|
||||
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
|
||||
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
conv.messages.set_compressing(false);
|
||||
}
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 自动压缩成功,摘要已插首位"
|
||||
);
|
||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
let ev = AiChatEvent::AiAutoCompressed {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
summary,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Ok(None) => {
|
||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
||||
//
|
||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
||||
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
|
||||
// 消息状态不变,降级 build_for_request 裁剪)。
|
||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
keyword_fallback_len = keyword_fallback.len(),
|
||||
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||
KEYWORD_FALLBACK_ENABLED,
|
||||
);
|
||||
if KEYWORD_FALLBACK_ENABLED {
|
||||
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
||||
let inserted = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
if !keyword_fallback.is_empty() {
|
||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if inserted {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -191,6 +191,112 @@ impl std::fmt::Display for InvalidTransition {
|
||||
|
||||
impl std::error::Error for InvalidTransition {}
|
||||
|
||||
// ============================================================
|
||||
// ConvStateStore — 无锁并发 ConvState 存储(session 锁重构方案 B-Phase0)
|
||||
//
|
||||
// 背景:AiSession 全局 Mutex 把 ConvState(高频读 + 敏感脏读)与 messages/pending_approvals
|
||||
// (长持锁源)同锁串行化,致 guard.reset 等 lock 竞争 800ms fallback(AiCompleted 延迟 / 工具后
|
||||
// 中断 / 第二条进队列同源根因)。本 Store 把 ConvState 提到独立 DashMap,guard.reset/new/drop
|
||||
// 直接 transition(同步无 await,不竞争 session lock),ai_is_generating 直接读(零锁竞争)。
|
||||
//
|
||||
// 设计:
|
||||
// - 基于 dashmap::DashMap<String, ConvState>(行级锁,不同 conv 不互斥,无 tokio runtime 阻塞)
|
||||
// - 全方法同步无 await(transition 内仅 copy + write enum,纳秒级)
|
||||
// - transition 经 ConvState::transition_to 守卫(复用状态机语义,非法转换拒绝)
|
||||
// - get 对不存在的 conv_id 返 Idle(惰性默认,对齐 PerConvState 新建语义)
|
||||
//
|
||||
// 方案 B 分阶段迁移完成。
|
||||
// - Phase0~1: ConvStateStore 骨架 + AppState 接入
|
||||
// - Phase2~3: 写/读侧迁移至无锁 ConvStateStore,PerConvState.conv_state 字段已删
|
||||
// - Phase4: conversation_delete 同步清理 conv_states 条目
|
||||
// ============================================================
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
/// ConvState 的无锁并发存储(方案 B 核心)。
|
||||
///
|
||||
/// 经 `Arc<ConvStateStore>` 共享(app_state.conv_states)。所有方法同步无 await,可在任意
|
||||
/// async 上下文直接调(不竞争 session lock,不阻塞 tokio runtime)。
|
||||
///
|
||||
/// 注:`transition` 用 DashMap entry 原子(get + transition_to + write 一致,无 TOCTOU 窗口)。
|
||||
pub struct ConvStateStore {
|
||||
inner: DashMap<String, ConvState>,
|
||||
}
|
||||
|
||||
impl ConvStateStore {
|
||||
/// 创建空 Store。
|
||||
pub fn new() -> Self {
|
||||
Self { inner: DashMap::new() }
|
||||
}
|
||||
|
||||
/// 读 conv_id 的 ConvState(不存在返 Idle 默认,对齐 PerConvState 新建语义)。
|
||||
pub fn get(&self, conv_id: &str) -> ConvState {
|
||||
self.inner.get(conv_id).map(|r| *r.value()).unwrap_or(ConvState::Idle)
|
||||
}
|
||||
|
||||
/// 读 conv_id 是否活跃生成态(Generating/Compressed)—— ai_is_generating 零锁读。
|
||||
pub fn is_active(&self, conv_id: &str) -> bool {
|
||||
self.get(conv_id).is_active()
|
||||
}
|
||||
|
||||
/// 读 conv_id 是否可接受新请求(Idle/Error)—— can_accept_request 零锁读。
|
||||
pub fn can_accept_request(&self, conv_id: &str) -> bool {
|
||||
self.get(conv_id).can_accept_request()
|
||||
}
|
||||
|
||||
/// 原子迁移 conv_id 的 ConvState 到 target(经 transition_to 守卫)。
|
||||
///
|
||||
/// DashMap entry 原子(get + transition + write 一致,无 TOCTOU)。不存在的 conv_id 视为
|
||||
/// Idle(对齐新建语义),Idle→target 经守卫。返回 Ok(新态) 或 Err(InvalidTransition)。
|
||||
pub fn transition(
|
||||
&self,
|
||||
conv_id: &str,
|
||||
target: ConvState,
|
||||
) -> Result<ConvState, InvalidTransition> {
|
||||
// get_mut 持写锁原子迁移(Occupied);Vacant 时 insert(Idle 起步)。
|
||||
// 注:transition 调用点(guard.new/reset/drop)同 conv 单 loop 不并发,TOCTOU 风险低;
|
||||
// 跨 conv 各自条目行级锁不互斥(对齐 DashMap 设计)。
|
||||
if let Some(mut r) = self.inner.get_mut(conv_id) {
|
||||
let cur = *r.value();
|
||||
match cur.transition_to(target) {
|
||||
Ok(ns) => {
|
||||
*r.value_mut() = ns;
|
||||
Ok(ns)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
} else {
|
||||
match ConvState::Idle.transition_to(target) {
|
||||
Ok(ns) => {
|
||||
self.inner.insert(conv_id.to_string(), ns);
|
||||
Ok(ns)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 删除 conv_id 的条目(会话删除时同步清,防已删 conv 残留 Generating 致 id 复用脏状态)。
|
||||
pub fn remove(&self, conv_id: &str) {
|
||||
self.inner.remove(conv_id);
|
||||
}
|
||||
|
||||
/// 所有活跃生成态的 conv_id 快照(L0 握手批量 stop / 恢复生成态用)。
|
||||
pub fn active_convs(&self) -> Vec<String> {
|
||||
self.inner
|
||||
.iter()
|
||||
.filter(|r| r.value().is_active())
|
||||
.map(|r| r.key().clone())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ConvStateStore {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(纯逻辑无 IO)
|
||||
// ============================================================
|
||||
@@ -458,4 +564,60 @@ mod tests {
|
||||
assert!(msg.contains("Idle"), "Display 应含 from: {}", msg);
|
||||
assert!(msg.contains("Stopping"), "Display 应含 to: {}", msg);
|
||||
}
|
||||
|
||||
// ---- ConvStateStore(方案 B-Phase0,无锁并发存储)----
|
||||
|
||||
#[test]
|
||||
fn test_store_get_default_idle() {
|
||||
let s = ConvStateStore::new();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle, "不存在 conv 应返 Idle 默认");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_transition_occupied() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Generating);
|
||||
s.transition("conv-1", ConvState::Idle).unwrap();
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_transition_guard_rejects() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
// Generating → Stopping 合法
|
||||
s.transition("conv-1", ConvState::Stopping).unwrap();
|
||||
// Stopping → Generating 非法(须先回 Idle 再起)
|
||||
assert!(s.transition("conv-1", ConvState::Generating).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_remove() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
s.remove("conv-1");
|
||||
assert_eq!(s.get("conv-1"), ConvState::Idle, "remove 后应返 Idle 默认");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_is_active_and_can_accept() {
|
||||
let s = ConvStateStore::new();
|
||||
assert!(!s.is_active("conv-1"), "Idle 不活跃");
|
||||
assert!(s.can_accept_request("conv-1"), "Idle 可接");
|
||||
s.transition("conv-1", ConvState::Generating).unwrap();
|
||||
assert!(s.is_active("conv-1"), "Generating 活跃");
|
||||
assert!(!s.can_accept_request("conv-1"), "Generating 不可接");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_store_active_convs() {
|
||||
let s = ConvStateStore::new();
|
||||
s.transition("a", ConvState::Generating).unwrap();
|
||||
s.transition("b", ConvState::Idle).unwrap();
|
||||
s.transition("c", ConvState::Generating).unwrap();
|
||||
let mut active = s.active_convs();
|
||||
active.sort();
|
||||
assert_eq!(active, vec!["a".to_string(), "c".to_string()], "仅活跃 conv");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,218 +1,88 @@
|
||||
//! B-260615-09: generating 状态 RAII guard —— 从 agentic/mod.rs 抽离(重构第一批,纯结构搬迁)。
|
||||
//! generating 状态 RAII guard。
|
||||
//!
|
||||
//! 行为零变更:仅文件位置移动,逻辑/字段/语义完全保留。
|
||||
//! 调用方(agentic/mod.rs run_agentic_loop)经 `use super::guard::GeneratingGuard;` 复用。
|
||||
|
||||
use std::sync::Arc;
|
||||
//! B-Phase2: ConvState 切无锁 ConvStateStore(不竞争 session lock,删 800ms fallback)。
|
||||
//! 治卡死连环:AiCompleted 延迟 / 工具后中断 / 第二条进队列同源根因。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::ai::{AiChatEvent, AiSession};
|
||||
use crate::commands::ai::AiChatEvent;
|
||||
|
||||
// 双轨收口批2:CONV_STATE_ENABLED 常量删除后,模块路径(self)不再使用,仅导入 ConvState 类型。
|
||||
use super::conv_state::ConvState;
|
||||
|
||||
/// generating 复位 RAII guard,取代散布的手动 `session.generating = false`。
|
||||
/// generating 复位 RAII guard。
|
||||
///
|
||||
/// 两路复位:
|
||||
/// - 正常路径:exit 点显式 `reset().await` 即时复位(emit 前调,保证"复位→emit"顺序,
|
||||
/// 前端收事件时后端已可接下一条)。
|
||||
/// - 异常路径(panic/未走正常 return):Drop 兜底 spawn 复位,防 generating 永真卡死前端。
|
||||
///
|
||||
/// 注:try_continue_agent_loop 不用 guard——其 should_continue=false 路径需保持
|
||||
/// generating=true(审批等待态),全函数 guard 会误复位;该函数单点 provider-Err 复位保持手动。
|
||||
///
|
||||
/// F-260616-09 B 批4:guard 持 `conv_id`,复位改写 `session.conv(&conv_id).generating = false`
|
||||
/// (per-conv 唯一真相源)。顶层 `session.generating` 字段已在批4 删除,reset/Drop 仅写 per_conv;
|
||||
/// IPC(ai_is_generating/ai_chat_send 等)亦改读 per_conv,无需双写桥接。
|
||||
///
|
||||
/// L2 统一状态机收尾(2026-06-22):本地 `state: Option<ConvState>` 视图层冗余字段已删除。
|
||||
///
|
||||
/// 背景:批1 曾在 guard 内嵌 `state` 作「写收敛视图层」(双轨过渡),批2 已把 `ConvState` 落到
|
||||
/// `PerConvState.conv_state` 字段持久化(读侧真相源)。批2 同时在 `reset`/`drop` 内读
|
||||
/// `conv.conv_state` 持久化字段做迁移 + emit AiConvStateChanged——故本地 `state` 成纯冗余
|
||||
/// (双源,且本地 state 在 Drop 无 await 上下文无法持久化,语义本就不完整)。
|
||||
///
|
||||
/// 收尾删除后:
|
||||
/// - `new`:读 `conv.conv_state` 持久化字段,若 Idle→Generating 迁移成功则 emit 通知前端
|
||||
/// (入口 `run_agentic_loop` 已独立写持久化迁移但**不 emit**,故此处补 emit 防前端漏收生成态)。
|
||||
/// - `reset`/`drop`:迁移 + emit 均读 `conv.conv_state` 持久化字段(批2 已落地,本批仅删冗余本地块)。
|
||||
/// - `disarm`:不动 ConvState(审批等待是 Generating 内的暂停点)。
|
||||
///
|
||||
/// 批3 双轨收口:`CONV_STATE_ENABLED` 开关与 `generating` bool 已退役,enum 迁移 + emit
|
||||
/// 无条件执行(单一真相源,无 off 降级分支)。
|
||||
/// ConvState 经无锁 [`ConvStateStore`](super::conv_state::ConvStateStore) 迁移(不竞争 session
|
||||
/// lock)。正常退出显式 `reset()`;异常退出 `Drop` 兜底迁移 Idle。`disarm` 解除 Drop 兜底
|
||||
/// (审批等待保持 Generating,续跑新 guard.new 重新迁移)。
|
||||
pub(super) struct GeneratingGuard {
|
||||
session: Arc<Mutex<AiSession>>,
|
||||
/// guard 所属会话(loop 启动时快照的 conv_id,来自 run_agentic_loop 入参)。
|
||||
conv_id: String,
|
||||
done: bool,
|
||||
/// L2 批2 1b:emit AiConvStateChanged 用的 AppHandle(Clone 廉价,run_agentic_loop 入参传入)。
|
||||
/// 批3 收口:emit 无条件执行(开关已退役),此字段恒被消费。
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl GeneratingGuard {
|
||||
pub(super) fn new(session: Arc<Mutex<AiSession>>, conv_id: String, app_handle: AppHandle) -> Self {
|
||||
// L2 收尾:入口 `run_agentic_loop` 已独立完成 Idle→Generating 的持久化迁移(写 conv.conv_state),
|
||||
// 但**不 emit** AiConvStateChanged。此处补 emit 通知前端进入生成态——读 conv.conv_state
|
||||
// 持久化字段(真相源)做迁移校验,迁移成功则 emit,非法记 warn 不阻断。
|
||||
//
|
||||
// 注:new 是同步函数(无 await),无法持 session 锁读 conv.conv_state;此处经
|
||||
// tauri::async_runtime::spawn 异步补 emit(非阻塞 guard 创建,入口持久化已发生故 emit 有据)。
|
||||
// spawn 失败(运行时不可用,理论场景)仅漏一次 emit,前端 watchdog 兜底(对齐失败不阻断核心生成)。
|
||||
// 批3 双轨收口:删除 CONV_STATE_ENABLED 门控,迁移 + emit 无条件执行(enum 单一真相源)。
|
||||
// 注:clone 给闭包 move,原始 session/app_handle 留给 Self 字段(对齐原 if 块外 Self 构造)。
|
||||
let session_for_spawn = session.clone();
|
||||
let app_handle_for_spawn = app_handle.clone();
|
||||
let cid = conv_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let new_state = {
|
||||
let mut session = session_for_spawn.lock().await;
|
||||
let conv = session.conv(&cid);
|
||||
match conv.conv_state.transition_to(ConvState::Generating) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
ns
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %cid,
|
||||
error = %e,
|
||||
"[ai] guard.new ConvState→Generating 非法(持久化层,不阻断核心生成)"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
// 持久化层迁移成功后 emit 前端(同步,失败忽略)。锁已随作用域 drop,可安全 emit。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: new_state,
|
||||
conversation_id: Some(cid),
|
||||
};
|
||||
let _ = app_handle_for_spawn.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle_for_spawn.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
});
|
||||
Self { session, conv_id, done: false, app_handle }
|
||||
pub(super) fn new(conv_id: String, app_handle: AppHandle) -> Self {
|
||||
let guard = Self {
|
||||
conv_id,
|
||||
done: false,
|
||||
app_handle: app_handle.clone(),
|
||||
};
|
||||
guard.transition(ConvState::Generating);
|
||||
guard
|
||||
}
|
||||
|
||||
/// 显式复位生成态。emit 前调用保证顺序。幂等。
|
||||
///
|
||||
/// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
/// L2:同步迁移 `conv.conv_state → Idle`(持久化层,迁移 + emit 无条件执行)。
|
||||
/// 复位 Idle(emit 前调,保证前端收事件时后端已 Idle)。幂等。
|
||||
pub(super) async fn reset(&mut self) {
|
||||
if !self.done {
|
||||
let mut session = self.session.lock().await;
|
||||
let conv = session.conv(&self.conv_id);
|
||||
// L2 批2:ConvState 持久化(Generating→Idle 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => {
|
||||
conv.conv_state = ns;
|
||||
// L2 批2 1b:持久化层 Idle 收敛成功后 emit 前端(同步,失败忽略)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
};
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self.app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard.reset ConvState→Idle 非法(持久化层,不阻断核心复位)"
|
||||
),
|
||||
}
|
||||
self.done = true;
|
||||
self.transition(ConvState::Idle);
|
||||
}
|
||||
}
|
||||
|
||||
/// 解除 Drop 兜底复位但不复位 generating。审批等待 return 路径调用:
|
||||
/// 保持 generating=true 留 try_continue 续生成,同时 Drop 因 done=true 跳过复位 spawn。
|
||||
/// (B-260615-26: 修复审批执行后对话不续生成回归)
|
||||
///
|
||||
/// L2:disarm 不迁移 ConvState(审批等待是 Generating 内的暂停点,ConvState 仍 Generating,
|
||||
/// 与「generating 保持 true」语义一致)。续跑时新 guard.new 重新迁移(Generating 自环幂等)。
|
||||
/// 解除 Drop 兜底(审批等待保持 Generating)。
|
||||
pub(super) fn disarm(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
/// 迁移 ConvState + emit AiConvStateChanged(经无锁 ConvStateStore,同步无 await)。
|
||||
fn transition(&self, target: ConvState) {
|
||||
let conv_states = self
|
||||
.app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.conv_states
|
||||
.clone();
|
||||
let ev = match conv_states.transition(&self.conv_id, target) {
|
||||
Ok(ns) => Some(AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(self.conv_id.clone()),
|
||||
}),
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
conv_id = %self.conv_id,
|
||||
error = %e,
|
||||
"[ai] guard ConvState 迁移非法"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ev) = ev {
|
||||
let _ = self.app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = self
|
||||
.app_handle
|
||||
.state::<crate::state::AppState>()
|
||||
.ai_event_bus
|
||||
.publish_event(ev);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for GeneratingGuard {
|
||||
fn drop(&mut self) {
|
||||
// 未 done 即异常退出(panic/未走正常 return):核心生成态经 spawn 复位,
|
||||
// ConvState 持久化迁移 + emit 也在同一 spawn 内异步完成(无 await 上下文,对齐核心复位路径)。
|
||||
// done=true(正常 reset/disarm 已走) → 跳过。批3 收口后无开关 off 分支。
|
||||
if !self.done {
|
||||
let session = self.session.clone();
|
||||
let conv_id = self.conv_id.clone();
|
||||
// L2 批2 1b:Drop 无 await 上下文,持久化迁移 + emit 在 spawn 内异步完成(对齐核心复位 spawn 路径)。
|
||||
// app_handle Clone 廉价,随 session/conv_id 一起 move 进 async 块。
|
||||
let app_handle = self.app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut s = session.lock().await;
|
||||
let conv = s.conv(&conv_id);
|
||||
// L2 批2:ConvState 持久化(异常退出兜底→Idle)。批3 删除开关门控 + generating bool 写,迁移无条件执行。
|
||||
if let Ok(ns) = conv.conv_state.transition_to(ConvState::Idle) {
|
||||
conv.conv_state = ns;
|
||||
// 1b:持久化 Idle 收敛后 emit 前端(异常退出兜底也需通知前端退出生成态)。
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiConvStateChanged {
|
||||
conv_state: ns,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
});
|
||||
self.transition(ConvState::Idle);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 单元测试(guard ConvState 迁移语义,纯逻辑路径验证)
|
||||
// ============================================================
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// guard.new 路径:持久化层 Idle→Generating 迁移(读 conv.conv_state)。
|
||||
///
|
||||
/// 注:不构造真实 AiSession(lock/Mutex 建会话状态成本高),仅校验迁移语义——
|
||||
// guard 内部 transition_to 是纯函数,直接断言逻辑路径。
|
||||
#[test]
|
||||
fn test_guard_new_migrates_to_generating() {
|
||||
// 批3 收口后无开关断言:Idle → Generating(读侧持久化字段迁移路径,无条件执行)。
|
||||
let mut s = ConvState::Idle;
|
||||
s = s.transition_to(ConvState::Generating).unwrap();
|
||||
assert_eq!(s, ConvState::Generating);
|
||||
}
|
||||
|
||||
/// reset 迁移 Generating→Idle(正常收敛),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_reset_migrates_to_idle() {
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// 异常 Drop 路径同样 Generating→Idle(兜底),验证迁移合法。
|
||||
#[test]
|
||||
fn test_guard_drop_path_migrates_to_idle() {
|
||||
// guard.drop 异常路径(!done)迁移 Generating→Idle(读持久化字段)。
|
||||
let mut s = ConvState::Generating;
|
||||
s = s.transition_to(ConvState::Idle).unwrap();
|
||||
assert_eq!(s, ConvState::Idle);
|
||||
}
|
||||
|
||||
/// disarm 不迁移状态(审批等待保持 Generating)。
|
||||
/// 验证:disarm 后 enum 仍 Generating(下次 new 时 Generating 自环幂等通过)。
|
||||
#[test]
|
||||
fn test_guard_disarm_keeps_generating_then_self_loop_on_new() {
|
||||
let s = ConvState::Generating;
|
||||
// disarm 不动 enum;续跑新 guard.new 时 Generating→Generating 自环通过。
|
||||
assert_eq!(s.transition_to(ConvState::Generating), Ok(ConvState::Generating));
|
||||
}
|
||||
}
|
||||
// ConvState 迁移语义单测见 conv_state.rs(ConvStateStore + ConvState::transition_to)。
|
||||
// guard struct 委托 ConvStateStore,无独立逻辑,不重复单测。
|
||||
|
||||
232
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
232
src-tauri/src/commands/ai/agentic/helpers.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
//! 审批后循环恢复 — 从 agentic/mod.rs 拆分。
|
||||
//! 原代码被嵌套在 run_agentic_loop 内部导致 pub(crate) 不可见。
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use crate::state::AppState;
|
||||
use super::approval_timeout;
|
||||
use crate::commands::ai::prompt::{build_system_prompt, get_active_provider};
|
||||
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 字段的一次性快照。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
pinned_goals_snapshot: Vec<GoalEntry>,
|
||||
}
|
||||
|
||||
/// 检查是否所有待审批已处理,如果是则恢复 agentic 循环
|
||||
pub async fn try_continue_agent_loop(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
let has_pending = session.session_state(conv_id, &state.conv_states) == SessionState::AwaitingApproval;
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(is_active),不再占 session lock 读 conv_state。
|
||||
let is_generating = state.conv_states.is_active(conv_id);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
ContinueSnapshot {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
|
||||
if !should_continue {
|
||||
if snap.is_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位");
|
||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// B-Phase3:不再持锁操作 conv_state(ConvStateStore 无锁单源)。
|
||||
if let Err(e2) = state.conv_states.transition(conv_id, ConvState::Idle) {
|
||||
tracing::warn!(conv_id = %conv_id, error = %e2, "conv_states→Idle 非法");
|
||||
}
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(), error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e, error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
let system_prompt = build_system_prompt(state, &lang).await;
|
||||
|
||||
let session_arc = state.ai_session.clone();
|
||||
let tools_arc = state.ai_tools.clone();
|
||||
let db = state.db.clone();
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
|
||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||
|
||||
let max_iterations = state.agent_max_iterations.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let max_retries = state.agent_max_retries.load(std::sync::atomic::Ordering::SeqCst);
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争,无需 session lock)。
|
||||
let still_generating = state.conv_states.is_active(conv_id);
|
||||
if !still_generating {
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
let _ = app.state::<crate::state::AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0, conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
super::run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 LLM 本轮调用的工具中推理目标描述(本轮可能多个,全部提取)。
|
||||
/// 工具调用是结构化数据(工具名+参数),比从文本中提取更可靠。
|
||||
pub(crate) fn infer_goal_from_tool_calls(tool_calls: &std::collections::HashMap<u32, crate::commands::ai::ToolCallDraft>) -> Vec<String> {
|
||||
if tool_calls.is_empty() { return vec![]; }
|
||||
let mut goals: Vec<String> = Vec::new();
|
||||
for tc in tool_calls.values() {
|
||||
let goal = match tc.name.as_str() {
|
||||
"patch_file" | "write_file" | "edit_file" | "append_file" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("修改 {}", f)
|
||||
}))
|
||||
}
|
||||
"read_file" | "file_info" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("查看 {}", f)
|
||||
}))
|
||||
}
|
||||
"search_files" | "grep" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("pattern").or_else(|| args.get("query")).and_then(|v| v.as_str()).map(|q| {
|
||||
let s = if q.len() > 25 { &q[..25] } else { q };
|
||||
format!("搜索 {}", s)
|
||||
}))
|
||||
.or_else(|| Some("搜索文件".to_string()))
|
||||
}
|
||||
"run_command" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("command").and_then(|v| v.as_str()).map(|c| {
|
||||
let s = if c.len() > 25 { &c[..25] } else { c };
|
||||
format!("执行 {}", s)
|
||||
}))
|
||||
.or_else(|| Some("执行命令".to_string()))
|
||||
}
|
||||
"create_project" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("name").and_then(|v| v.as_str()).map(|n| format!("创建项目 {}", n)))
|
||||
}
|
||||
"create_idea" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("title").and_then(|v| v.as_str()).map(|t| format!("捕获灵感 {}", t)))
|
||||
}
|
||||
"run_workflow" => Some("执行工作流".to_string()),
|
||||
"delete_file" | "rename_file" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| {
|
||||
let f = p.rsplit('/').next().or_else(|| p.rsplit('\\').next()).unwrap_or(p);
|
||||
format!("处理 {}", f)
|
||||
}))
|
||||
}
|
||||
"list_directory" => {
|
||||
serde_json::from_str::<serde_json::Value>(&tc.args).ok()
|
||||
.and_then(|args| args.get("path").and_then(|v| v.as_str()).map(|p| format!("浏览 {}", p)))
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(g) = goal { if !goals.contains(&g) { goals.push(g); } }
|
||||
}
|
||||
goals
|
||||
}
|
||||
|
||||
/// G2 探索熔断:判定工具结果是否为「空结果」(空成功,非失败)。
|
||||
pub(crate) fn is_empty_tool_result(content: &str) -> bool {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.is_empty() { return true; }
|
||||
const EMPTY_MARKERS: &[&str] = &[
|
||||
"\"total\":0", "\"entries\":[]", "\"matches\":[]", "\"results\":[]", "\"files\":[]",
|
||||
];
|
||||
for marker in EMPTY_MARKERS {
|
||||
if trimmed.contains(marker) { return true; }
|
||||
}
|
||||
const EMPTY_TEXT: &[&str] = &[
|
||||
"未找到", "没有找到", "无匹配", "没有匹配", "未匹配", "未发现", "无记录",
|
||||
"No matches", "no matches", "0 results", "0 matches", "没有数据", "没有符合",
|
||||
];
|
||||
for marker in EMPTY_TEXT {
|
||||
if trimmed.contains(marker) { return true; }
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn test_empty_tool_result() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
assert!(is_empty_tool_result(" "));
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt"]}"#));
|
||||
}
|
||||
}
|
||||
2
src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs
Normal file
2
src-tauri/src/commands/ai/agentic/knowledge_lifecycle.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
//! Agentic 循环知识生命周期(文档占位)。
|
||||
//! 知识提炼实现位于 `super::super::knowledge_inject` 模块。
|
||||
@@ -12,8 +12,9 @@ use df_ai::context::TokenEstimator;
|
||||
use df_ai::context::ContextManager;
|
||||
// 改进3 B: 压缩失败兜底关键词摘要(纯函数 extract_keyword_summary)。
|
||||
// 改进4: 工具结果 view-only 摘要(should_summarize_tool_result / extract_key_info)。
|
||||
// extract_keyword_summary 随压缩逻辑迁至 context_lifecycle.rs(改进3 B 关键词兑底)。
|
||||
use df_ai::context_helpers::{
|
||||
extract_key_info, extract_keyword_summary, should_summarize_tool_result,
|
||||
extract_key_info, should_summarize_tool_result,
|
||||
PLACEHOLDER_INTEGRITY_ENABLED,
|
||||
};
|
||||
// 改进2 B:意图收敛工具(LLM 可见 tool_defs 按 intent 过滤,执行路径仍走完整 registry)
|
||||
@@ -21,6 +22,8 @@ use df_ai::context_helpers::{
|
||||
// 收敛的扁平子集之上叠加 plan_hint 编排(并行组同批聚拢/顺序依赖源在前),供 LLM 看到
|
||||
// 一份按编排意图排序的工具列表。feature flag PLANNING_ENABLED(false 默认关)门控接入。
|
||||
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),避免重写退避逻辑。
|
||||
@@ -40,14 +43,12 @@ use df_storage::models::AiProviderRecord;
|
||||
use crate::state::{AppState, LlmConcurrency};
|
||||
|
||||
use super::audit::process_tool_calls;
|
||||
use super::compress::compress_via_llm;
|
||||
// compress_via_llm 已随压缩逻辑迁至 context_lifecycle.rs(maybe_auto_compress 内调用)。
|
||||
use super::conversation::{save_conversation, TokenAccumulator};
|
||||
use super::knowledge_inject::{inject_knowledge_into_prompt, maybe_spawn_extraction};
|
||||
use super::prompt::{build_system_prompt, get_active_provider};
|
||||
use super::knowledge_inject::{maybe_spawn_extraction};
|
||||
use super::stream_recv::{stream_llm, StreamResult};
|
||||
use super::title::{ensure_conversation_title, spawn_ensure_title};
|
||||
|
||||
use super::{AiChatEvent, AiSession, ErrorType, SessionState};
|
||||
use super::{AiChatEvent, AiSession, ErrorType};
|
||||
// ConvState 经本文件内 `pub mod conv_state;` 同 crate 直接访问(conv_state::ConvState)。
|
||||
|
||||
/// L1 补丁:run_agentic_loop 入口 provider 解析超时保护的内部错误类型。
|
||||
@@ -68,12 +69,9 @@ enum ProviderResolveError {
|
||||
/// 热改下次发消息生效(与 llm_concurrency 传 Arc 实时反映的区别)。
|
||||
pub const DEFAULT_MAX_AGENT_ITERATIONS: usize = 10;
|
||||
|
||||
/// 压缩保护区条数(对齐 clear/compress IPC 的 PROTECT_COUNT=6)。
|
||||
/// protect_start = len.saturating_sub(PROTECT_COUNT):保护区内的最近 6 条(含本轮三元组)
|
||||
/// 不参与压缩,避免压缩正在使用的活跃消息。
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
|
||||
/// 流式对话失败自动重试默认次数(F-260616-07 / 决策 a1)
|
||||
/// 压缩保护区条数 + 压缩关键词兑底开关已随压缩逻辑迁至 context_lifecycle.rs
|
||||
/// (PROTECT_COUNT / KEYWORD_FALLBACK_ENABLED,在该模块内部为私有 const)。
|
||||
///
|
||||
///
|
||||
/// 默认 3 次(初次 + 2 次重试)。复用 retry::backoff_delay 退避(1s→2s→4s+jitter) +
|
||||
/// retry::is_status_retryable Fatal 分类(4xx 非429 立即放弃) + 30s 总预算。
|
||||
@@ -81,14 +79,9 @@ const PROTECT_COUNT: usize = 6;
|
||||
/// partial_text)不重试——保文入库 + AiCompleted(incomplete=true) + 系统提示网络中断。
|
||||
pub const DEFAULT_MAX_AGENT_RETRIES: usize = 3;
|
||||
|
||||
/// 改进3 B 常量开关:压缩失败时是否启用关键词摘要兜底(默认 true)。
|
||||
/// 改进3 B 常量开关 KEYWORD_FALLBACK_ENABLED 已随压缩逻辑迁至 context_lifecycle.rs
|
||||
/// (在该模块内部为私有 const,文档见 context_lifecycle.rs 顶部)。
|
||||
///
|
||||
/// true(默认):LLM 压缩失败 → compress_old_messages 标 compressed(释放 token)
|
||||
/// + insert_at(0, system 关键词摘要),保留用户反复提及的主题词作续接锚点。
|
||||
/// false(回退):回退原裸裁剪行为(不插关键词摘要,仅 build_for_request 裁剪保最近 6 条)。
|
||||
/// 排障/对比用:置 false 即可观察无兜底时的裁剪效果。
|
||||
pub const KEYWORD_FALLBACK_ENABLED: bool = true;
|
||||
|
||||
/// 改进4 常量开关:tool_result 大输出是否做 view-only 摘要压缩(默认 true)。
|
||||
///
|
||||
/// true(默认):build_for_request 后送 stream 前,遍历 history 中 tool_result,
|
||||
@@ -110,8 +103,9 @@ 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 尾部。
|
||||
// 机制:run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
// 存 PerConvState.pinned_goals(Vec<GoalEntry>,内容态字段,绕过 sanitize step0 is_active 过滤),
|
||||
// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部。
|
||||
// system_prompt 是 loop 不变量 + build_for_request 不裁剪,故目标天然免疫压缩/裁剪,
|
||||
// 彻底治 R1(目标消息物理出局)/R5(prompt 说教无锚点),无 insert_at(0) 的连续 System
|
||||
// 1214/首位锚点稀释/小预算被裁三重风险。
|
||||
@@ -119,10 +113,12 @@ pub const TOPIC_MARKER_ENABLED: bool = true;
|
||||
|
||||
/// G1 总开关:目标钉扎是否启用(默认 true)。
|
||||
///
|
||||
/// true(默认):chat.rs 两处 push 后提取首条 user 目标存 pinned_goal + loop 入口拼 system_prompt。
|
||||
/// false(回退):两处提取跳过 + loop 入口拼接跳过,pinned_goal 永远 None,system_prompt 零变化,
|
||||
/// true(默认):run_agentic_loop 每轮从 LLM 工具调用推理目标(infer_goal_from_tool_calls),
|
||||
/// 存入 PerConvState.pinned_goals(Vec<GoalEntry>) + loop 入口拼 active 目标进 system_prompt。
|
||||
/// false(回退):提取跳过 + loop 入口拼接跳过,pinned_goals 永远空 Vec,system_prompt 零变化,
|
||||
/// 完全退回改动前行为(目标靠压缩摘要 + topic marker + 聚焦准则 prompt 续命)。单点回退,
|
||||
/// 不影响 G2/G4/G5(对齐 KEYWORD_FALLBACK_ENABLED 模式:每改配开关 + 兜底降级旧行为)。
|
||||
/// 2026-07-03 改:提取从 chat.rs 用户消息规则 → run_agentic_loop 工具调用推理(helpers.rs)。
|
||||
pub const GOAL_PIN_ENABLED: bool = true;
|
||||
|
||||
/// G1 banner 开关:目标拼进 system_prompt 时是否加「## 当前目标」分隔标题(默认 true)。
|
||||
@@ -248,6 +244,21 @@ use guard::GeneratingGuard;
|
||||
/// 审批超时取消(由 try_continue_agent_loop 入口调用)
|
||||
mod approval_timeout;
|
||||
|
||||
// ============================================================
|
||||
// 重构:上下文/标题/知识生命周期抽取(行为零变更)。
|
||||
//
|
||||
// - context_lifecycle:F-15 自动压缩逻辑,从 run_agentic_loop 内联块抽取为
|
||||
// maybe_auto_compress 函数(mod.rs 调用,返回 bool 表达原 early-return)。
|
||||
// - title_lifecycle / knowledge_lifecycle:标题/知识逻辑早已模块化(title.rs /
|
||||
// knowledge_inject.rs),mod.rs 仅单行调用,无可抽取的内联代码,文件作文档占位。
|
||||
// ============================================================
|
||||
mod context_lifecycle;
|
||||
use context_lifecycle::maybe_auto_compress;
|
||||
#[allow(unused_imports)]
|
||||
mod title_lifecycle;
|
||||
#[allow(unused_imports)]
|
||||
mod knowledge_lifecycle;
|
||||
|
||||
// ============================================================
|
||||
// L2 统一状态机(ConvState enum + 转换守卫,单一真相源)。
|
||||
// 设计:generating状态机加固-2026-06-15.md §3 + aichat体验与agent能力系统化重构-2026-06-21.md §3。
|
||||
@@ -261,6 +272,11 @@ mod approval_timeout;
|
||||
// - 兜底:状态机层迁移失败(非法转换)记 warn 不 panic,核心生成态复位仍经 enum 迁移收敛。
|
||||
// ============================================================
|
||||
pub mod conv_state;
|
||||
pub mod command_lock;
|
||||
pub(crate) mod helpers;
|
||||
pub(crate) use helpers::try_continue_agent_loop;
|
||||
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 辅助
|
||||
@@ -333,14 +349,12 @@ async fn stream_one_provider(
|
||||
// 注:primary 候选的启动 Auth 早失败已在外层 run_agentic_loop 顶部处理(emit + return),
|
||||
// 本函数到达时 primary 的 key 已验证过;此处 Err 多见于 secondary 配置不一致,
|
||||
// 保守归 Fatal 立即放弃(不浪费预算试下一 provider,因 key 错非瞬态)。
|
||||
let provider: Box<dyn LlmProvider> = match super::secret::build_provider_for(candidate) {
|
||||
let provider: Arc<dyn LlmProvider> = Arc::from(match super::secret::build_provider_for(candidate) {
|
||||
Ok(p) => p,
|
||||
Err(msg) => {
|
||||
// UX-260618-15: Fatal 分支 emit 移到外层(stream_one_provider 调用方 Fatal 分支统一 emit)。
|
||||
// 此处仅返回 error 文本,避免 stream_one_provider 内 emit 与外层 emit 重复(Fatal 终态单 emit)。
|
||||
return StreamOutcome::Fatal { error: msg };
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// resolved_model 在本 candidate.model_configs 上重算(F-260614-04b 核心:provider 切换后
|
||||
// 模型池不同,必须重选;否则拿主 provider 的 model_id 去打次 provider 会吃 400/404)。
|
||||
@@ -371,7 +385,7 @@ async fn stream_one_provider(
|
||||
reasoning_content: last_reasoning_content.clone(),
|
||||
};
|
||||
|
||||
match stream_llm(&*provider, retry_request, app_handle, stop_flag, notify, conv_id).await {
|
||||
match stream_llm(Arc::clone(&provider), retry_request, app_handle, stop_flag, notify, conv_id).await {
|
||||
StreamResult::Complete { text, tool_calls, usage, reasoning_content } => {
|
||||
return StreamOutcome::Success {
|
||||
text, tool_calls, usage,
|
||||
@@ -491,7 +505,34 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 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 推前端。
|
||||
let mut guard = GeneratingGuard::new(session_arc.clone(), conv_id.clone(), app_handle.clone());
|
||||
let mut guard = GeneratingGuard::new(conv_id.clone(), app_handle.clone());
|
||||
|
||||
// 治本:LLM 生成间隙向前端发 AiHeartbeat,防前端 watchdog 误断流。
|
||||
// 后端 emit AiHeartbeat → useAiEvents.ts resetStreamWatchdog(convId) → per-conv timer 重置。
|
||||
// 解决:LLM 重试循环(~60s)期间无事件到达前端 → watchdog 45s/90s 到期误杀。
|
||||
// shutdown_tx 在函数作用域结束(Drop)时通知 heartbeat 任务退出。
|
||||
let (_heartbeat_tx, mut heartbeat_rx) = tokio::sync::watch::channel(());
|
||||
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()),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// F-260616-09 B 批2 入口桥接:loop 启动前确保 per_conv 存在(已存在则保留累积,不存在则建)。
|
||||
//
|
||||
@@ -510,23 +551,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
// 单 loop 安全性:批2 阶段是单 active_conversation_id(决策 e 真并发批3+ 落地),无并发 loop 抢
|
||||
// per_conv 覆盖。批3+ 多 loop 并发时,每 conv 各自 per_conv 条目,互不干扰(本桥接无需改)。
|
||||
//
|
||||
// L2 状态机写收敛:ConvState 的写收敛已收敛到 `guard`(GeneratingGuard::new 已在上方 L399 创建,
|
||||
// new 内部 Idle→Generating 迁移 + reset/drop Generating→Idle 迁移)。
|
||||
// 批3 双轨收口:generating bool 已退役,此处仅做持久化层 Idle→Generating 迁移(单一真相源),
|
||||
// emit 由 guard.new 异步补发(入口迁移但不 emit,guard 补 emit 防前端漏收生成态)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
// L2 批2:ConvState 持久化(Idle→Generating 写收敛)。批3 删除开关门控,迁移无条件执行。
|
||||
match conv.conv_state.transition_to(conv_state::ConvState::Generating) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] 入口 ConvState→Generating 非法(状态机持久化层,不阻断核心生成)"
|
||||
),
|
||||
}
|
||||
}
|
||||
// 入口:ConvState 迁移由 guard.new 在无锁 ConvStateStore 上完成。
|
||||
|
||||
// F-260614-04 / F-260614-04b: 多 Provider 负载均衡池 — 选主 + fallback 候选列表。
|
||||
//
|
||||
@@ -787,19 +812,26 @@ pub(crate) async fn run_agentic_loop(
|
||||
// pinned_goals 永远空 Vec(单点回退等价改动前)。拼接在 sys_tokens 估算前。
|
||||
//
|
||||
// 注:仅 run_agentic_loop 入口注入。手动压缩/标题/提炼等路径不注入目标。
|
||||
//
|
||||
// 目标提取方式(2026-07-03 改):从 LLM 本轮工具调用推理目标描述(工具名+路径),
|
||||
// 不再从用户消息规则提取,也不依赖 LLM 输出结构化标记。
|
||||
// 推理结果存入 pinned_goals: Vec<GoalEntry>,含 text+status 状态追踪。
|
||||
// 新目标加入时自动标记之前的 active 为 completed。
|
||||
let mut system_prompt = system_prompt;
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goals: Vec<String> = {
|
||||
let goals: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
.map(|c| c.pinned_goals.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if !goals.is_empty() {
|
||||
let goal_lines: Vec<String> = goals.iter().enumerate().map(|(i, g)| {
|
||||
let g = g.trim();
|
||||
let truncated: String = g.chars().take(GOAL_MAX_CHARS).collect();
|
||||
// 只把 active 目标注入 system_prompt(completed 不干扰 LLM 注意力)
|
||||
let active_goals: Vec<&super::GoalEntry> = goals.iter().filter(|g| matches!(g.status, super::GoalStatus::Active)).collect();
|
||||
if !active_goals.is_empty() {
|
||||
let goal_lines: Vec<String> = active_goals.iter().enumerate().map(|(i, g)| {
|
||||
let trimmed = g.text.trim();
|
||||
let truncated: String = trimmed.chars().take(GOAL_MAX_CHARS).collect();
|
||||
let truncated = if truncated.chars().count() >= GOAL_MAX_CHARS {
|
||||
format!("{}…", truncated)
|
||||
} else {
|
||||
@@ -819,7 +851,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
count = goals.len(),
|
||||
first_goal = %goals.first().map(|g| &g[..std::cmp::min(120, g.len())]).unwrap_or(""),
|
||||
first_goal = %goals.first().map(|g| &g.text[..std::cmp::min(120, g.text.len())]).unwrap_or(""),
|
||||
"[ai] G1 目标钉扎:已把 {} 个 pinned_goals 拼进 system_prompt",
|
||||
goals.len()
|
||||
);
|
||||
@@ -836,8 +868,10 @@ pub(crate) async fn run_agentic_loop(
|
||||
let behavior_prompt = "\n## AI 定位\n你是 DevFlow 的 AI 助手,拥有完整的工具链。用户只负责提需求和审批,所有执行由你完成——读写文件、运行命令、创建项目、搜索代码等都是你直接调用工具完成的。**绝不输出“请在终端执行以下命令”这类指令——你自己用 run_command 工具执行即可。**\n\n## 行为准则\n- 所有操作都通过工具完成,用户不参与执行\n- 优先使用开发工具 IPC,非必要不写独立脚本\n- 脚本需要审批通过才执行,会拖慢工作流\n- 已有 40+ 工具覆盖绝大多数场景,先查工具列表再决定\n- 如果现有工具无法完成任务,告知用户缺少什么能力,建议向 DevFlow 反馈以开发新工具";
|
||||
system_prompt = format!("{}\n\n{}\n\n{}", system_prompt, env_prompt, behavior_prompt);
|
||||
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带,前端直接读取 pinned_goals 无需等 loadConversations
|
||||
let pinned_goals_snapshot: Vec<String> = {
|
||||
// ── 多 Agent 并行执行:Coordinator 分解(plan_execution_enabled 时) ──
|
||||
// 对话透明化 L1:拍快照供 AiCompleted 事件携带(coordinator 路径出口也用)
|
||||
// 只取 text(前端不需要状态信息)
|
||||
let pinned_goals_snapshot: Vec<super::GoalEntry> = {
|
||||
let session = session_arc.lock().await;
|
||||
session
|
||||
.conv_read(&conv_id)
|
||||
@@ -845,6 +879,86 @@ pub(crate) async fn run_agentic_loop(
|
||||
.unwrap_or_default()
|
||||
};
|
||||
|
||||
if df_ai::plan_executor::plan_execution_enabled() && conf >= INTENT_CONF_THRESHOLD {
|
||||
let coord = Coordinator::new(PersonaRegistry::new());
|
||||
let decompose_result = coord.decompose(intent.as_str(), &user_text);
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
intent = intent.as_str(),
|
||||
subtask_count = decompose_result.subtasks.len(),
|
||||
has_plan = !decompose_result.plan.is_empty(),
|
||||
"[COORDINATOR] 意图分解完成"
|
||||
);
|
||||
|
||||
if !decompose_result.plan.is_empty() {
|
||||
// 串行执行子任务(Phase 1 简单路径,后续可升级并行 dispatch_with_budget)
|
||||
let results = coord.dispatch(&decompose_result.plan, |subtask, persona| async move {
|
||||
ExecutionResult {
|
||||
subtask_id: subtask.id.clone(),
|
||||
persona_id: persona.id.clone(),
|
||||
output: format!(
|
||||
"### 子任务: {}({})\n\n意图: {}",
|
||||
subtask.intent, persona.name, subtask.intent
|
||||
),
|
||||
success: true,
|
||||
}
|
||||
}).await;
|
||||
|
||||
// 合并子任务结果
|
||||
let merge_result = coord.merge(&results);
|
||||
|
||||
// 将合并产出推回主对话(单条可展开 assistant 消息)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(&conv_id).messages.push(
|
||||
ChatMessage::assistant(&format!(
|
||||
"## 多 Agent 执行完成\n\n共执行 {} 个子任务,成功 {} 个。\n\n{}",
|
||||
results.len(),
|
||||
results.iter().filter(|r| r.success).count(),
|
||||
merge_result.merged_output
|
||||
))
|
||||
);
|
||||
if !merge_result.conflicts.is_empty() {
|
||||
session.conv(&conv_id).messages.push(
|
||||
ChatMessage::assistant(&format!(
|
||||
"### 冲突检测\n\n检测到 {} 个文件冲突:\n{}",
|
||||
merge_result.conflicts.len(),
|
||||
merge_result.conflicts.iter()
|
||||
.map(|c| format!("- {}: {}", c.file, c.description))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 落库
|
||||
save_conversation(&session_arc, &db, &conv_id, None, None, true).await;
|
||||
|
||||
// generating 复位并 emit AiCompleted(对齐现有退出路径模式)
|
||||
guard.reset().await;
|
||||
let _ = app_handle.emit("ai-chat-event", AiChatEvent::AiCompleted {
|
||||
total_tokens: tokens.total(),
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(
|
||||
AiChatEvent::AiCompleted {
|
||||
total_tokens: tokens.total(),
|
||||
prompt_tokens: tokens.prompt(),
|
||||
completion_tokens: tokens.completion(),
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// F-260616-13: system_prompt 是 run_agentic_loop 的不变参数(整个 loop 期间文本不变),
|
||||
// 其 token 估算在 loop 外算一次缓存复用,避免每轮/每次重试重复 estimate_text(低收益优化,行为不变)。
|
||||
let sys_tokens = TokenEstimator::default().estimate_text(&system_prompt);
|
||||
@@ -994,182 +1108,22 @@ pub(crate) async fn run_agentic_loop(
|
||||
});
|
||||
}
|
||||
|
||||
// F-15 阶段3: 自动压缩(智能裁剪)——在 build_for_request 之前预处理。
|
||||
// F-15 阶段3: 自动压缩(智能裁剪)——已抽取至 context_lifecycle::maybe_auto_compress。
|
||||
//
|
||||
// 触发条件:history_tokens > budget*0.6 且 保护区外有可压缩消息 且 未在压缩中。
|
||||
//
|
||||
// 流程(对齐阶段2 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 裁剪。
|
||||
//
|
||||
// 口径决策(任务让"你判断"):选**延迟 mutate(成功才改)**而非"失败回滚 status"。
|
||||
// 理由:ContextManager.history_tokens 字段私有、无 set_history_tokens 公开接口;
|
||||
// 若先 compress_old_messages(扣 token)再 LLM,失败回滚需精确恢复 history_tokens,
|
||||
// 但 ChatMessage 克隆不含 token_count,无法等量加回——回滚 token 不精确。
|
||||
// 延迟 mutate 则失败时零副作用(消息状态/token 完全不变),语义最干净。
|
||||
// 注:延迟 mutate 的窗口(active_msgs 读出→LLM 出摘要期间)不持锁,但 loop 串行无并发
|
||||
// (本函数独占 session_arc,工具执行/审批分支在 stream 之后),故此窗口内 messages 不变。
|
||||
//
|
||||
// 安全(FR-S1):复用 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)。
|
||||
// conv_id 来源:run_agentic_loop 入参。
|
||||
let prev_compressing = session_arc.lock().await.conv_read(&conv_id).map(|c| c.messages.is_compressing()).unwrap_or(false);
|
||||
if !prev_compressing {
|
||||
// 读触发条件(history_tokens / budget / has_compressible_messages),持锁快照判断。
|
||||
let (should_compress, protect_start, pre_compress_tokens) = {
|
||||
let session = session_arc.lock().await;
|
||||
let mgr = match session.conv_read(&conv_id) {
|
||||
Some(c) => c,
|
||||
None => {
|
||||
tracing::warn!(stale_conv = %conv_id, "[ai] conv 已删除,loop 退出(压缩段入口)");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mgr = &mgr.messages;
|
||||
let protect_start = mgr.len().saturating_sub(PROTECT_COUNT);
|
||||
let history_tokens = mgr.history_tokens();
|
||||
let budget = mgr.budget_limit();
|
||||
// 触发阈值 0.6*budget(整数比避免浮点):budget*6/10 < history_tokens
|
||||
let should = protect_start > 0
|
||||
&& (budget as u64) * 6 / 10 < history_tokens as u64
|
||||
&& mgr.has_compressible_messages(protect_start);
|
||||
(should, protect_start, history_tokens)
|
||||
};
|
||||
|
||||
if should_compress {
|
||||
// emit 压缩开始 + 置位防重入 + 读 active 克隆(不改 status)。
|
||||
let ev = AiChatEvent::AiCompressing {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
let (active_msgs, lang) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
conv.messages.set_compressing(true);
|
||||
// 读 active 克隆(不改 status):filter is_active,LLM 失败则消息状态完全不变。
|
||||
let active_msgs: Vec<ChatMessage> = conv.messages.messages_mut()
|
||||
[..protect_start]
|
||||
.iter()
|
||||
.filter(|t| t.message.is_active())
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let lang = conv.agent_language.clone()
|
||||
.unwrap_or_else(|| "zh-CN".to_string());
|
||||
(active_msgs, lang)
|
||||
};
|
||||
|
||||
// 改进3 B:在 active_msgs move 进 compress_via_llm 前,先算关键词摘要兜底文本。
|
||||
// LLM 压缩失败时仍想保留用户反复提及的主题词(续接锚点),避免裸裁剪丢主题。
|
||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过(回退原裸裁剪行为,排障/对比用)。
|
||||
let keyword_fallback: String = if KEYWORD_FALLBACK_ENABLED {
|
||||
extract_keyword_summary(&active_msgs)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// 压缩调用(复用 loop 顶部已 build 的 provider,api_key 经 secret 闭环)。
|
||||
// 成功 → Some(summary);失败 → Err;无 active 可压缩(active_msgs 空)→ 视为 noop。
|
||||
let compress_outcome: Result<Option<String>, String> = if active_msgs.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
compress_via_llm(
|
||||
provider.as_ref(),
|
||||
&provider_config,
|
||||
active_msgs,
|
||||
&lang,
|
||||
&conv_id,
|
||||
&llm_concurrency,
|
||||
).await.map(Some)
|
||||
};
|
||||
|
||||
match compress_outcome {
|
||||
Ok(Some(summary)) => {
|
||||
// LLM 成功 → 标 compressed(扣 token)+ 摘要 system 插首位 + set_compressing(false)。
|
||||
// compress_old_messages 幂等:此时 status 仍是 active(本流程未先标),它会把
|
||||
// [..protect_start] 内 active 标 compressed 并扣 token。返回的 cloned 与之前读的
|
||||
// active_msgs 等价(LLM 调用期间 messages 不变,见上方口径决策注)。
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
conv.messages.set_compressing(false);
|
||||
}
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
iteration,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 自动压缩成功,摘要已插首位"
|
||||
);
|
||||
// 治 Task#1:loop 自动压缩用 AiAutoCompressed(非手动变体),
|
||||
// 桌面端静默(仅复位 isCompressing,不弹 toast 不 switchConversation)。
|
||||
// miniapp 仍插摘要气泡(对端发生压缩告知用户)。
|
||||
let ev = AiChatEvent::AiAutoCompressed {
|
||||
conversation_id: Some(conv_id.clone()),
|
||||
summary,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
Ok(None) => {
|
||||
// 保护区外无 active 可压缩(已全 compressed/archived)→ noop,仅复位 is_compressing。
|
||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||
}
|
||||
Err(e) => {
|
||||
// LLM 失败 → 改进3 B:仍标 compressed 释放 token + 关键词摘要塞回首条(非裸裁剪)。
|
||||
//
|
||||
// 旧行为:消息状态完全不变,降级走 build_for_request 裁剪(丢主题)。
|
||||
// 新行为(KEYWORD_FALLBACK_ENABLED=true 默认):
|
||||
// - compress_old_messages 标 [..protect_start] active 为 compressed(释放 token,
|
||||
// 与成功路径一致,后续 build_for_request 不再把它们进 LLM 上下文);
|
||||
// - keyword_fallback 非空 → insert_at(0, system 关键词摘要)作续接锚点;
|
||||
// - keyword_fallback 空(无 user 消息/无可提取词)→ 不插,等价旧裁剪(保底)。
|
||||
// 持久化语义不变:compressed 仍软删可追溯(DB 全量保留),与成功路径一致。
|
||||
// KEYWORD_FALLBACK_ENABLED=false → 跳过兜底,等价旧行为(set_compressing(false) +
|
||||
// 消息状态不变,降级 build_for_request 裁剪)。
|
||||
session_arc.lock().await.conv(&conv_id).messages.set_compressing(false);
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
keyword_fallback_len = keyword_fallback.len(),
|
||||
"[ai] 自动压缩失败,降级走关键词摘要兜底(KEYWORD_FALLBACK_ENABLED={})",
|
||||
KEYWORD_FALLBACK_ENABLED,
|
||||
);
|
||||
if KEYWORD_FALLBACK_ENABLED {
|
||||
// 标 compressed 释放 token + 关键词摘要塞首位(若非空)。
|
||||
let inserted = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(protect_start);
|
||||
if !keyword_fallback.is_empty() {
|
||||
conv.messages.insert_at(0, ChatMessage::system(&keyword_fallback));
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
};
|
||||
if inserted {
|
||||
tracing::info!(
|
||||
conv_id = %conv_id,
|
||||
pre_tokens = pre_compress_tokens,
|
||||
"[ai] 压缩失败兜底:关键词摘要已插首位(compressed 标记已扣 token)"
|
||||
);
|
||||
}
|
||||
}
|
||||
// BUG-260624-05:压缩失败不发 AiError——前端 AiError case 无条件 setStreaming(false)
|
||||
// 误判生成结束(后端 loop 实际继续),致"压缩后停止"。降级为 warn(上方 line 957 已有)
|
||||
// + compress_old_messages 兜底释放 token + build_for_request 裁剪,loop 继续 stream_llm,
|
||||
// 用户自然看到后续回复。压缩降级对用户透明(非致命错误,不该停流)。
|
||||
}
|
||||
}
|
||||
}
|
||||
// 抽取自本函数原内联块(行为零变更)。返回 true = conv 已删除,loop 应立即 return
|
||||
// (对齐原内联块入口的 early-return);返回 false = 正常结束,继续后续流程。
|
||||
// 实现细节(触发条件 / 延迟 mutate 口径 / 关键词兜底 / FR-S1 安全)见
|
||||
// context_lifecycle.rs 顶部文档与函数体内注释(原样保留)。
|
||||
if maybe_auto_compress(
|
||||
&session_arc,
|
||||
&conv_id,
|
||||
&app_handle,
|
||||
&provider,
|
||||
&provider_config,
|
||||
&llm_concurrency,
|
||||
iteration,
|
||||
).await {
|
||||
return;
|
||||
}
|
||||
|
||||
// 构建请求消息(超预算时自动裁剪旧消息,保护工具调用三元组 + 最近 6 条)
|
||||
@@ -1511,7 +1465,55 @@ pub(crate) async fn run_agentic_loop(
|
||||
msg.reasoning_content = last_reasoning_content.clone();
|
||||
session.conv(&conv_id).messages.push(msg);
|
||||
}
|
||||
}
|
||||
// G1 目标提取: 从工具调用推理目标描述。
|
||||
// 工具调用是结构化数据(工具名+路径),比从文本中提取更可靠。
|
||||
// 无工具调用时不提取(已有目标保持不变,该轮无新目标)。
|
||||
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<super::GoalEntry> = 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<super::GoalEntry> = 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。
|
||||
}
|
||||
|
||||
// 停止信号:已生成文本入库后退出,不再执行后续工具调用
|
||||
if stop_flag.load(Ordering::SeqCst) {
|
||||
@@ -1539,13 +1541,12 @@ pub(crate) async fn run_agentic_loop(
|
||||
}
|
||||
|
||||
// 无工具调用 → 最终文本响应,正常收敛退出
|
||||
#[allow(unused_assignments)]
|
||||
if !has_tool_calls { converged = true; break; }
|
||||
let _ = converged;
|
||||
|
||||
// 处理工具调用(Low 自动执行 / Medium+High 待审批)
|
||||
let pending_count = {
|
||||
let mut session = session_arc.lock().await;
|
||||
process_tool_calls(&mut session, tool_calls_acc, &tools_arc, &db, &app_handle, &conv_id).await
|
||||
};
|
||||
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,
|
||||
// 避免污染 stderr(用户可见),保留排障能力(RUST_LOG=debug 可见)。
|
||||
tracing::debug!(
|
||||
@@ -1704,7 +1705,7 @@ pub(crate) async fn run_agentic_loop(
|
||||
String::new()
|
||||
} else {
|
||||
let goal_summary = goals.iter()
|
||||
.map(|g| g.trim())
|
||||
.map(|g| g.text.trim())
|
||||
.filter(|g| !g.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("; ");
|
||||
@@ -1851,353 +1852,4 @@ pub(crate) async fn run_agentic_loop(
|
||||
pinned_goals: pinned_goals_snapshot.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 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 永久卡。
|
||||
/// 各 return 点的语义判断:
|
||||
/// 1) should_continue=false(generating 已复位 / pending_approvals 非空):
|
||||
/// - generating=false → 用户点了停止(ai_chat_stop 复位)或会话已结束,emit AiCompleted 标当前轮收敛
|
||||
/// (streaming=true 由 AiCompleted 清理)
|
||||
/// - pending_approvals 非空 → 转入审批等待态(其他审批未决),emit AiCompleted 标当前轮结束
|
||||
/// (前端审批态 watchdog 已 clear,不卡)
|
||||
/// 2) get_active_provider Err → 无可用 provider(配置丢失/全删),无法续生成,emit AiError
|
||||
/// (语义:配置错误,用户需设 provider;非 generating 复位可恢复)
|
||||
///
|
||||
/// R-PD-6: conv_id 来源从全局 active_conversation_id 解耦到审批所属会话。
|
||||
/// 触发本函数的 ai_approve 已 remove 触发审批,但 pending_approvals 内剩余审批(若 has_pending)
|
||||
/// 仍各自携带 conversation_id(审批产生时由 process_tool_calls 写入,业务真相源)。
|
||||
/// 故 has_pending=true 分支(审批等待态)直接取剩余审批的 conversation_id 做 conv_id,
|
||||
/// 不读 active_conversation_id 全局单例——该字段在审批等待态(非 generating-only 期)可被
|
||||
/// ai_chat_stop/clear/switch 并发改写,属竞态耦合。has_pending=false(全部审批已处理,续生成)
|
||||
/// 分支:审批已被 remove,改为以剩余 pending_approvals 任一 conversation_id 做一致性校验
|
||||
/// (此处空,校验通过即沿用全局值,该期 generating=true 且 switch 为 readonly 不并发)。
|
||||
pub(crate) async fn try_continue_agent_loop(
|
||||
app: &AppHandle,
|
||||
state: &AppState,
|
||||
conv_id: &str,
|
||||
start_iteration: usize,
|
||||
) {
|
||||
// 审批超时检测:超时取消在本函数入口进行(本函数是所有审批恢复路径的入口——
|
||||
// ai_approve / ai_authorize_dir / ai_continue_loop / ai_chat_stop 都调它)。
|
||||
// 这里检测后把超时审批转为拒绝状态,避免用户离开后 pending 永久挂起死锁会话。
|
||||
// 0 表示禁用超时(不推荐,但保留用户选择权)。检测失败(如锁中毒)不阻断续跑。
|
||||
approval_timeout::cleanup_expired_approvals(app, state, conv_id).await;
|
||||
|
||||
// BUG-260617-05: 原 5 次独立 lock().await 造成 TOCTOU 竞态——should_continue=true 判出后、
|
||||
// spawn 前用户点 stop(ai_chat_stop 复位 generating=false),续跑仍按过时快照继续 spawn。
|
||||
// 修复:单次 lock 取结构化快照(所有续跑判定所需字段),无锁态判定;spawn 前单次 lock 原子
|
||||
// 重检 generating 仍为 true 才续跑,stop 后中途插入的直接收敛退出。
|
||||
//
|
||||
// F-260616-09 B 批2(设计 §4.4):conv_id 改显式入参(替代从 pending/active 推断),
|
||||
// has_pending 按 conv_id 过滤(决策 e 真并发准备);generating/agent_language/model_override
|
||||
// 读 per_conv(批2 桥接期与顶层等价,per_conv 未建 fallback 顶层)。
|
||||
// conv_id 来源:调用方 ai_approve 传 approval.conversation_id;ai_continue_loop/ai_stop_loop
|
||||
// 传 IPC 参数 conversation_id。
|
||||
let snap = {
|
||||
let session = state.ai_session.lock().await;
|
||||
// path_auth 审批链阶段1:has_pending 改调 session_state(conv_id) 收敛状态机判定,
|
||||
// 替代手写 path_auth+risk 两表 any 合并(mod.rs 已统一封装)。
|
||||
// 阶段3a 单真相源合并后:两表合一进 pending_approvals,session_state 单表 any 判定。
|
||||
// 两表语义不变——任一类挂起都阻塞续跑(agentic loop 在 path_auth 挂起时也已 return 等待,
|
||||
// 漏任一会致 loop 误续跑空转)。
|
||||
//
|
||||
// 兜底/快速回退:若需切回手写 has_pending,原双表组合保留如下(改一行即可):
|
||||
// let has_pending = session.pending_approvals.values()
|
||||
// .any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
let has_pending = session.session_state(conv_id) == SessionState::AwaitingApproval;
|
||||
// pending_conv_id 保留(should_continue=false 路径的 emit conv_id 回退逻辑)。
|
||||
// 阶段3a:单表 find_map(原两表合一)。
|
||||
let pending_conv_id = session.pending_approvals.values()
|
||||
.find_map(|a| a.conversation_id.clone());
|
||||
let conv = session.conv_read(conv_id);
|
||||
// F-09 B 批4:per_conv 唯一真相源,删顶层 fallback(conv 不存在则各字段默认值)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于 ai_can_continue 继续按钮是否可点判断,
|
||||
// is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为仍在生成)。
|
||||
let is_generating = conv.map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
let agent_language = conv.and_then(|c| c.agent_language.clone());
|
||||
let model_override = conv.and_then(|c| c.model_override.clone());
|
||||
let pinned_goals_snapshot = conv.map(|c| c.pinned_goals.clone()).unwrap_or_default();
|
||||
ContinueSnapshot {
|
||||
is_generating,
|
||||
has_pending,
|
||||
pending_conv_id,
|
||||
agent_language,
|
||||
model_override,
|
||||
pinned_goals_snapshot,
|
||||
}
|
||||
};
|
||||
let should_continue = snap.is_generating && !snap.has_pending;
|
||||
|
||||
if !should_continue {
|
||||
// generating=false(被 stop)或仍有审批(pending_approvals 非空):
|
||||
// 统一 emit AiCompleted 标当前轮收敛,清前端 streaming。
|
||||
// 轮 token 已在前序 AiCompleted/AiApprovalResult 流程落库,此处零 token 上报仅作收敛信号。
|
||||
if snap.is_generating {
|
||||
// pending_approvals 非空但 generating 仍 true:转审批态,前端审批态 watchdog 已 clear,不卡
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:仍有待审批,转审批等待态");
|
||||
} else {
|
||||
// generating 已复位(用户 stop 或前序循环已 emit Completed):补发 AiCompleted 防前端卡住
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 跳过:generating 已复位(被 stop/已结束),补发 AiCompleted 清前端 streaming");
|
||||
// R-PD-6: 优先用审批所属 conversation_id(审批等待态被 stop 触发,审批仍在 pending_approvals),
|
||||
// 仅当无任何审批(has_pending=false 且 generating=false)时回退入参 conv_id(批2 显式参数)。
|
||||
let emit_conv_id = match snap.pending_conv_id.clone() {
|
||||
Some(cid) => cid,
|
||||
None => conv_id.to_string(),
|
||||
};
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(emit_conv_id),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let provider_config = match get_active_provider(state).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// 无可用 provider(配置丢失/全删):无法续生成,emit AiError。
|
||||
// 语义:配置错误,用户需在 Settings 设 provider;generating 复位由 run_agentic_loop 内
|
||||
// build_provider_for Err 分支处理(同样 emit AiError),此处与之一致。
|
||||
// 不用 GeneratingGuard:try_continue 的 should_continue=false 路径需保生成态(审批等待态),
|
||||
// 全函数 guard 会误复位。此点单点 provider-Err 复位,语义独立。
|
||||
// 批3 双轨收口:generating bool 已退役,复位经 ConvState 迁移(Generating→Idle)单一表达。
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 持久化层 ConvState→Idle 复位(对齐 guard.reset 收敛路径,非配置错误不阻断)。
|
||||
match session.conv(conv_id).conv_state.transition_to(conv_state::ConvState::Idle) {
|
||||
Ok(ns) => session.conv(conv_id).conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
error = %e,
|
||||
"[ai] try_continue provider-Err ConvState→Idle 非法(不阻断 emit AiError)"
|
||||
),
|
||||
}
|
||||
drop(session);
|
||||
tracing::warn!(conv_id = %conv_id, error = %e, "[ai] try_continue 失败:无可用 provider");
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiError {
|
||||
error: e.clone(),
|
||||
// 无可用 provider(配置丢失/全删):用户需在 Settings 设 provider,归 ProviderConfig
|
||||
error_type: Some(ErrorType::ProviderConfig),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
// L3 emit 双写:try_continue provider-Err AiError publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiError {
|
||||
error: e,
|
||||
error_type: None,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
// F-09 B 批2:续生成路径 conv_id 用入参(显式,不再从 active 推断),语言/override 读 per_conv 快照。
|
||||
let lang = snap.agent_language.clone().unwrap_or_else(|| "zh-CN".to_string());
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
let system_prompt = build_system_prompt(state, &lang).await;
|
||||
|
||||
let session_arc = state.ai_session.clone();
|
||||
let tools_arc = state.ai_tools.clone();
|
||||
let db = state.db.clone();
|
||||
let app_handle = app.clone();
|
||||
let knowledge_config = state.knowledge_config.lock().await.clone();
|
||||
let llm_concurrency = state.llm_concurrency.clone();
|
||||
|
||||
// 知识注入:DRY(B):收敛至 inject_knowledge_into_prompt 单一入口。
|
||||
// P1 修复(审批恢复路径缺知识注入):try_continue 续跑轮此前用裸 build_system_prompt,
|
||||
// 不调 build_knowledge_context 致续跑轮丢知识库上下文。现与 chat.rs 四处同款走 helper。
|
||||
// 同消息取 text+id(②口径修复):原 last_user_text 过滤 is_active / user_message_id 走
|
||||
// last_user_message_id 不过滤 is_active,末条 user 压缩后两值取自不同消息;helper 单次
|
||||
// 反向扫描同一条消息取两值。复用上方已 clone 的 knowledge_config 快照(避免重复加锁)。
|
||||
let system_prompt = inject_knowledge_into_prompt(state, conv_id, system_prompt, &knowledge_config).await;
|
||||
|
||||
|
||||
// F-260616-01: loop 入口 load 快照,当前续生成 loop 锁定边界(热改下次发消息生效)
|
||||
let max_iterations = state.agent_max_iterations.load(Ordering::SeqCst);
|
||||
// F-260616-07: 流式失败重试次数快照
|
||||
let max_retries = state.agent_max_retries.load(Ordering::SeqCst);
|
||||
// F-01 阶段6: 续跑沿用同一主对话的 model_override(审批续跑/达 max 续跑保持一致)。
|
||||
let model_override = snap.model_override.clone();
|
||||
|
||||
// BUG-260617-05 续: provider 解析/build_system_prompt 期间用户可能点 stop。
|
||||
// spawn 前单次 lock 原子重检 generating——若已被 stop 复位,收敛退出而非覆盖用户的 stop。
|
||||
// (run_agentic_loop 入口 GeneratingGuard 会再次置 generating=true,若不重检会抹掉 stop。)
|
||||
// F-09 B 批4:重检 per_conv.generating(唯一真相源;conv 不存在则 false)。
|
||||
// L2 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。spawn 前单次 lock 原子重检是否仍在生成,
|
||||
// is_active() 与原 generating 语义一致。
|
||||
let still_generating = {
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
};
|
||||
if !still_generating {
|
||||
tracing::info!(conv_id = %conv_id, "[ai] try_continue 终止:spawn 前重检 generating 已被 stop 复位,补发 AiCompleted");
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
prompt_tokens: 0,
|
||||
completion_tokens: 0,
|
||||
incomplete: None,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
pinned_goals: snap.pinned_goals_snapshot.clone(),
|
||||
};
|
||||
let _ = app.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2)透传 miniapp
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复循环前通知前端新建 assistant 消息:审批(通过/拒绝)后新一轮文本
|
||||
// 不应追加到发起工具调用的旧消息,用 AiAgentRound 隔开
|
||||
let _ = app.emit("ai-chat-event", AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
// L3 emit 双写:try_continue 续跑 AiAgentRound publish 到事件总线(门控在 publish 内)。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(AiChatEvent::AiAgentRound {
|
||||
round: 0,
|
||||
conversation_id: Some(conv_id_owned.clone()),
|
||||
});
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run_agentic_loop(session_arc, tools_arc, db, app_handle, provider_config, system_prompt, conv_id_owned, knowledge_config, llm_concurrency, max_iterations, max_retries, start_iteration, model_override).await;
|
||||
});
|
||||
}
|
||||
|
||||
/// BUG-260617-05: try_continue_agent_loop 续跑判定所需 session 字段的一次性快照。
|
||||
/// 单次 lock 取出后无锁态判定,消除多 lock 间其他 IPC(ai_chat_stop/clear/switch)改写 session
|
||||
/// 致续跑判断基于过时快照的 TOCTOU 竞态。
|
||||
///
|
||||
/// F-260616-09 B 批2:active_conversation_id 字段移除(改用入参 conv_id,不再从 snap 读)。
|
||||
struct ContinueSnapshot {
|
||||
is_generating: bool,
|
||||
has_pending: bool,
|
||||
pending_conv_id: Option<String>,
|
||||
agent_language: Option<String>,
|
||||
model_override: Option<String>,
|
||||
/// 对话透明化 L1:快照 pinned_goals 供 AiCompleted emit 携带
|
||||
pinned_goals_snapshot: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ============================================================
|
||||
// G2 is_empty_tool_result 单测
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_empty_string() {
|
||||
assert!(is_empty_tool_result(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_whitespace() {
|
||||
assert!(is_empty_tool_result(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_total_zero() {
|
||||
assert!(is_empty_tool_result(r#"{"total":0}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_entries_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"entries":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_matches_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"matches":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_results_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"results":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_files_empty() {
|
||||
assert!(is_empty_tool_result(r#"{"files":[]}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_chinese_no_match() {
|
||||
assert!(is_empty_tool_result("未找到相关文件"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_tool_result_english_no_match() {
|
||||
assert!(is_empty_tool_result("No matches found"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result() {
|
||||
assert!(!is_empty_tool_result(r#"{"total":5}"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_non_empty_tool_result_with_content() {
|
||||
assert!(!is_empty_tool_result(r#"{"entries":["a.txt","b.txt"]}"#));
|
||||
}
|
||||
}
|
||||
|
||||
2
src-tauri/src/commands/ai/agentic/title_lifecycle.rs
Normal file
2
src-tauri/src/commands/ai/agentic/title_lifecycle.rs
Normal file
@@ -0,0 +1,2 @@
|
||||
//! Agentic 循环标题生命周期(文档占位)。
|
||||
//! 标题生成实现位于 `super::super::title` 模块。
|
||||
340
src-tauri/src/commands/ai/audit/approval.rs
Normal file
340
src-tauri/src/commands/ai/audit/approval.rs
Normal file
@@ -0,0 +1,340 @@
|
||||
//! F-#97 / AE-04 / 阶段4 审批门控逻辑。
|
||||
//!
|
||||
//! 审批策略(方案 B:Persona 维度 + 扩展点):
|
||||
//! - reviewer/analyst 人设的工具调用全 auto(只读/分析,审批无意义)
|
||||
//! - 其他人设走现有 auto_exec_mode 三档
|
||||
//! - Phase 2 扩展点:可在 should_auto_for_persona 内加工具维度 if 分支
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use df_ai::ai_tools::{AiToolRegistry, RiskLevel};
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::db::Database;
|
||||
|
||||
use super::cache::{find_cached_high_risk_result, pending_placeholder_for};
|
||||
use super::diff::build_write_file_diff;
|
||||
use super::reason::build_approval_reason;
|
||||
use super::record::audit_tool_call;
|
||||
use super::super::{AiChatEvent, AiSession, ApprovalKind, PendingApproval, ToolCallDraft, trust_key_for, TrustKey};
|
||||
|
||||
/// 按 risk_level + auto_exec_mode 判定是否自动执行。
|
||||
///
|
||||
/// F-#97 自动执行范围三档(2026-06-22):low/medium/all,默认 low。
|
||||
/// - low:仅 Low 自动(等价旧行为)
|
||||
/// - medium:Low+Medium 自动
|
||||
/// - all:全自动无审批(完全 AI 接管)
|
||||
///
|
||||
/// C-260627:patch_file 小改动(≤5 行)特例自动放行,不阻塞 AI 工作流。
|
||||
pub(super) fn classify_risk_and_auto(
|
||||
risk_level: RiskLevel,
|
||||
auto_exec_mode: &str,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> bool {
|
||||
let _ = args; // 预留扩展点(未来可加路径白名单/文件类型等维度)
|
||||
// patch_file 是 AI 编码主力工具,改文件是 AI Coding 的工作常态。
|
||||
// 原本“小改动(≤5 行)豁免、大改动审批”的设计在实际使用中频繁打断 AI 工作流
|
||||
// (AI 改文件往往远超 5 行),且文件已写入会落 audit 表可追溯,审批价值低。
|
||||
// 决策:patch_file 全档位自动放行(low/medium/all 均不审批),与 read_file 同级处理。
|
||||
if tool_name == "patch_file" {
|
||||
return true;
|
||||
}
|
||||
match risk_level {
|
||||
RiskLevel::Low => true,
|
||||
RiskLevel::Medium => auto_exec_mode == "medium" || auto_exec_mode == "all",
|
||||
RiskLevel::High => auto_exec_mode == "all",
|
||||
}
|
||||
}
|
||||
|
||||
/// 审批策略方案 B:Persona 维度判定。
|
||||
///
|
||||
/// 在 classify_risk_and_auto 之上叠加人设维度:
|
||||
/// - reviewer/analyst 人设:工具白名单已限制为只读,全部 auto(审批纯浪费)
|
||||
/// - 其他人设:走 classify_risk_and_auto 原逻辑
|
||||
///
|
||||
/// Phase 2 扩展点:在此函数内加工具维度 if 分支,例如:
|
||||
/// ```ignore
|
||||
/// if persona_id == "coder" && tool_name == "write_file" {
|
||||
/// if let Some(path) = args.get("path").and_then(|v| v.as_str()) {
|
||||
/// if path.ends_with(".md") { return true; } // 写文档 auto
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
#[allow(dead_code)] // 接线到 process_tool_calls 需要 SubTask 携带 persona_id,后续批次接入
|
||||
pub(super) fn should_auto_for_persona(
|
||||
persona_id: Option<&str>,
|
||||
risk_level: RiskLevel,
|
||||
auto_exec_mode: &str,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> bool {
|
||||
// 人设维度:reviewer/analyst 全 auto
|
||||
match persona_id {
|
||||
Some("reviewer") | Some("analyst") => {
|
||||
tracing::debug!(
|
||||
persona = ?persona_id,
|
||||
tool = %tool_name,
|
||||
"[POLICY-B] 只读人设全 auto"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// 兜底:现有三档 + patch_file 特例
|
||||
classify_risk_and_auto(risk_level, auto_exec_mode, tool_name, args)
|
||||
}
|
||||
|
||||
/// AE-2025-04 检查会话信任命中。
|
||||
///
|
||||
/// 首批信任工具:write_file / run_command。同会话已批准过同工具+同目录 →
|
||||
/// `TrustKey` 命中,返回 `Some(TrustKey)`;否则返回 `None`(走原审批流程)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,内部短 lock 读
|
||||
/// `session_trust` 后立即 drop,信任查询不持锁,与 process_tool_calls 持锁 await 反模式解耦。
|
||||
pub(super) async fn check_trust_hits(
|
||||
draft: &ToolCallDraft,
|
||||
args: &serde_json::Value,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
) -> Option<TrustKey> {
|
||||
let key = trust_key_for(&draft.name, args)?;
|
||||
// 短 lock 读 session_trust(仅 contains 判定,无 await,纳秒级),命中即返 key
|
||||
let hit = {
|
||||
let session = session_arc.lock().await;
|
||||
session.conv_read(conv_id)
|
||||
.map(|c| c.session_trust.contains(&key))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
if hit { Some(key) } else { None }
|
||||
}
|
||||
|
||||
/// 插入 pending 审批:生成 diff → 重试检测 → insert PendingApproval + 占位 tool_result
|
||||
/// → 拼 reason + emit AiApprovalRequired + 审计落 pending 纪录。
|
||||
///
|
||||
/// **注意**:调用方应在调用前先 +=1 `pending_count`(保持与原 `handle_approval_tool`
|
||||
/// 行为一致——重试 skip 分支也在 `pending_count += 1` 之后返回)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有慢操作
|
||||
/// (build_write_file_diff/detect_retry_count/build_approval_reason/audit_tool_call)
|
||||
/// 在锁外 await,仅 `pending_approvals.insert` + `messages.push` 两处纯写改短 lock 段。
|
||||
pub(super) async fn insert_pending_approval(
|
||||
draft: ToolCallDraft,
|
||||
args: serde_json::Value,
|
||||
risk_level: RiskLevel,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
app_handle: &AppHandle,
|
||||
db: &Arc<Database>,
|
||||
current_message_id: Option<&str>,
|
||||
) {
|
||||
// AE-2025-03(路径 B):write_file 挂起审批前预读旧文件生成 diff。
|
||||
// 仅 write_file(覆盖整文件,有完整新旧内容可对比);其他工具 diff=None。
|
||||
// 旧文件不存在(新建)→ diff=None,前端回退显新 content。
|
||||
// 读失败不阻断审批(容错:文件无读权限等极端情况降级为无 diff 预览)。
|
||||
let approval_diff: Option<String> = if draft.name == "write_file" {
|
||||
build_write_file_diff(&args).await
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// 阶段4(容错/恢复,开关 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,断死循环。
|
||||
// 兜底:flag 关或无审计记录 → retry_count=0,等价原行为。
|
||||
let retry_count = detect_retry_count(audit_repo, &draft.id).await;
|
||||
if retry_count >= 1 {
|
||||
let skip_msg = format!(
|
||||
"已跳过重试(同 tool_call_id={} 此前已审批执行过,防 LLM 死循环重试同卡死工具)",
|
||||
draft.id
|
||||
);
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &skip_msg));
|
||||
}
|
||||
// L3 emit 双写:重试 guard 跳过 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(skip_msg.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, "skipped_retry", risk_level, Some(skip_msg), Some("auto_retry_guard"), current_message_id).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// 短 lock 段:纯写 pending_approvals.insert + 占位 tool_result push
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.pending_approvals.insert(draft.id.clone(), PendingApproval {
|
||||
tool_call_id: draft.id.clone(),
|
||||
tool_name: draft.name.clone(),
|
||||
arguments: args.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
recovered: false,
|
||||
// 阶段3a:普通 RiskLevel 审批标 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)
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &pending_placeholder_for(&draft.id)));
|
||||
}
|
||||
// 慢操作锁外:拼 reason(DB 读)+ emit + 审计落 pending 纪录(DB 写)
|
||||
let reason = build_approval_reason(&draft.name, &args, risk_level, db).await;
|
||||
// L3 emit 双写:Med/High 风险审批挂起 AiApprovalRequired 双路发布(tunnel 透传 miniapp 弹审批窗)。
|
||||
let ev = AiChatEvent::AiApprovalRequired {
|
||||
id: draft.id.clone(),
|
||||
name: draft.name.clone(),
|
||||
args: args.clone(),
|
||||
reason,
|
||||
diff: approval_diff,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
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 重试计数。
|
||||
///
|
||||
/// 返回语义:
|
||||
/// - 0:审计表无该 tc_id 落定记录(或仅 pending),属首次审批执行,正常挂起。
|
||||
/// - ≥1:审计表已有该 tc_id 的落定记录(executed/failed/rejected/skipped_retry),即该
|
||||
/// tc_id 此前已被审批执行过一次,LLM 又用同 id 重试 → 调用方据 ≥1 跳过执行 + emit Completed,
|
||||
/// 断「超时/权限错→LLM 死循环重试同 id→重新挂起→用户被迫二次授权」循环。
|
||||
///
|
||||
/// 实现:查 `find_by_tool_call_id`,status 为 pending 视为"尚未落定"(返 0,首次挂起审批的
|
||||
/// 正常态);其余落定状态返 1。retry_count 当前仅取 0/1(断路器语义:第二次即跳过),
|
||||
/// 字段类型 u32 留给未来"允许多次重试"扩展(配置上限阈值)。
|
||||
///
|
||||
/// 兜底/回退:flag 关(文档标记)或审计查询失败 → 返 0,等价原行为(单次审批执行,无重试防护)。
|
||||
pub(super) async fn detect_retry_count(audit_repo: &AiToolExecutionRepo, tc_id: &str) -> u32 {
|
||||
// 审计查询失败不阻断主流程(DB 故障等降级为无重试防护,返回 0 走原审批流程)
|
||||
let rec = match audit_repo.find_by_tool_call_id(tc_id).await {
|
||||
Ok(opt) => match opt {
|
||||
Some(r) => r,
|
||||
None => return 0, // 无记录 = 首次
|
||||
},
|
||||
Err(e) => {
|
||||
tracing::warn!("[阶段4-retry] 查审计表 tc_id={} 失败(降级无重试防护): {}", tc_id, e);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
// pending = 首次挂起审批(尚未落定);其余落定状态 = 已执行过 → 计 1 次重试
|
||||
if rec.status == "pending" {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
/// 对单条工具调用执行审批门控决策:
|
||||
///
|
||||
/// 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 →
|
||||
/// `insert_pending_approval`(write_file diff + 挂起 + emit + 审计落 pending 记录)。
|
||||
///
|
||||
/// BUG-260624-03/P0 重构:签名改 `session_arc: &Arc<Mutex<AiSession>>`,所有 session 访问
|
||||
/// 都改短 lock 段(check_trust_hits/find_cached_high_risk_result 内部短 lock,命中后 push
|
||||
/// tool_result 短 lock 段)。慢操作(audit_tool_call/find_cached_high_risk_result 的 DB 查)
|
||||
/// 全在锁外 await,根治 process_tool_calls 持 session lock 期间 await 慢操作死锁反模式。
|
||||
pub(super) async fn handle_approval_tool(
|
||||
draft: ToolCallDraft,
|
||||
args: serde_json::Value,
|
||||
risk_level: RiskLevel,
|
||||
auto_exec_mode: &str,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
_tools_arc: &Arc<AiToolRegistry>,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
app_handle: &AppHandle,
|
||||
db: &Arc<Database>,
|
||||
low_risk: &mut Vec<(ToolCallDraft, serde_json::Value, RiskLevel)>,
|
||||
trust_hits: &mut Vec<(ToolCallDraft, serde_json::Value, String, RiskLevel)>,
|
||||
pending_count: &mut usize,
|
||||
current_message_id: Option<&str>,
|
||||
) {
|
||||
// ── Step 1: 自动执行判定 ──
|
||||
if classify_risk_and_auto(risk_level, auto_exec_mode, &draft.name, &args) {
|
||||
low_risk.push((draft, args, risk_level));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 2: 会话信任检查 ──
|
||||
// AE-2025-04 会话级信任(Session Trust):首批 write_file / run_command,
|
||||
// 同会话已批准过同工具+同目录 → TrustKey 命中 → 自动放行(跳过 pending + 二次确认)。
|
||||
// 命中后走与 F-05 去重命中相似的「直接执行 + 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 {
|
||||
let dir_label = match &key {
|
||||
TrustKey::Write { dir } | TrustKey::Execute { dir } => dir.clone(),
|
||||
};
|
||||
tracing::info!(
|
||||
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 即时反馈)。
|
||||
let ev = AiChatEvent::AiToolAutoApproved {
|
||||
id: draft.id.clone(),
|
||||
tool: draft.name.clone(),
|
||||
dir: dir_label.clone(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 收集后循环外 spawn 执行,对齐 Low risk 不持锁(原注释 L593 声称"锁外"但代码持锁,
|
||||
// run_command 慢命令会阻塞同会话所有触 state.ai_session 的 IPC,CR-51 修此)
|
||||
trust_hits.push((draft, args, dir_label, risk_level));
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Step 3: F-05 高危去重缓存(仅 High) ──
|
||||
if matches!(risk_level, RiskLevel::High) {
|
||||
// find_cached_high_risk_result 内部短 lock 读 messages + 锁外 await DB 查 status,
|
||||
// 返回 (cached_content, status) 时不持锁
|
||||
if let Some((cached, status)) = find_cached_high_risk_result(session_arc, conv_id, audit_repo, &draft.name, &args).await {
|
||||
// 命中:把缓存结果作为新 tool_call_id 的 tool_result 回传,跳过审批
|
||||
tracing::info!(
|
||||
tool = %draft.name,
|
||||
new_tool_call_id = %draft.id,
|
||||
"[F-05] 高危工具去重命中:LLM 重试同命令,复用缓存结果跳过审批(断循环)"
|
||||
);
|
||||
// 短 lock 段:push tool_result(纯写,无 await)
|
||||
{
|
||||
let mut session = session_arc.lock().await;
|
||||
session.conv(conv_id).messages.push(ChatMessage::tool_result(&draft.id, &cached));
|
||||
}
|
||||
// L3 emit 双写:高危去重命中复用缓存 emit Completed 双路发布。
|
||||
let ev = AiChatEvent::AiToolCallCompleted {
|
||||
id: draft.id.clone(),
|
||||
result: serde_json::Value::String(cached.clone()),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
// 审计:去重命中记一条(status 透传缓存来源 completed/rejected/failed,SW-260618-16;decided_by=auto_dedup),不进 pending
|
||||
audit_tool_call(audit_repo, conv_id, &draft.id, &draft.name, &draft.args, &status, risk_level, Some(cached), Some("auto_dedup"), current_message_id).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Step 4: 插入 pending 审批 ──
|
||||
*pending_count += 1;
|
||||
insert_pending_approval(
|
||||
draft, args, risk_level, session_arc, conv_id, audit_repo, app_handle, db,
|
||||
current_message_id,
|
||||
).await;
|
||||
}
|
||||
@@ -4,8 +4,11 @@
|
||||
//! + `sort_object_keys` + `PENDING_APPROVAL_PLACEHOLDER`(去重与 process_tool_calls
|
||||
//! 共用占位常量)。三函数内部闭环(find_cached 调 canonical→sort),无外部 helper 依赖。
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use df_ai::provider::ChatMessage;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::super::AiSession;
|
||||
|
||||
@@ -70,7 +73,7 @@ pub(crate) fn is_pending_placeholder(content: &str) -> bool {
|
||||
/// F-260616-09 B 批2:经`conv_id` 索引 per_conv.messages(顶层 messages 批2 后是死字段)。
|
||||
/// conv_id 来源:process_tool_calls 入参 → 由 agentic/mod.rs run_agentic_loop 入参透传。
|
||||
pub(crate) async fn find_cached_high_risk_result(
|
||||
session: &AiSession,
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
tool_name: &str,
|
||||
@@ -81,70 +84,78 @@ pub(crate) async fn find_cached_high_risk_result(
|
||||
// 规范化新调用的 args 为可比字符串(排序键,键序无关)
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// F-260616-09 B 批2:读 per_conv.messages。process_tool_calls 调用前 loop 入口已桥接建立 per_conv,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let conv = session.conv_read(conv_id)?;
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
// BUG-260624-03/P0 重构:短 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,
|
||||
// 故 conv_read 必命中;防御性 None 时返 None(无缓存命中,走原审批流程)。
|
||||
let Some(conv) = session.conv_read(conv_id) else { return None };
|
||||
// ContextManager::iter 返回 impl Iterator(非 DoubleEnded),collect 成 Vec 再反向遍历。
|
||||
// 单对话消息量小(百级),collect 开销可忽略。
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参的 High 工具调用 → 拿到旧 tool_call_id
|
||||
// 反向:循环是「最近一次超时→重试」,命中通常是末尾附近,反向先停省全扫。
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
continue;
|
||||
}
|
||||
// 旧调用的 args 是流式拼接的 JSON 字符串,解析失败跳过(不误判为命中)
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||
let old_id = prev_tool_call_id?;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result。注意:审批拒绝/超时失败也属「已落定」,
|
||||
// 其 tool_result 内容同样回传(LLM 看到原反馈自行决定,不再逼用户二次审批)。
|
||||
// pending 占位(「需要用户审批,等待确认」)不命中——仍在审批中,走原流程。
|
||||
let Some(old_id) = prev_tool_call_id else { return None };
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
return Some((old_id, msg.content.clone()));
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 命中旧 tool_result:排除 pending 占位(基础文本或带 __PENDING__:tc_id 标记)
|
||||
// 阶段2:占位带标记后不再精确等于基础文本,改用 is_pending_placeholder 匹配。
|
||||
// 加固(子串误伤):`__PENDING__` 标记是 audit/cache.rs 占位模板独占信号(权威判定);
|
||||
// 老占位分支已收紧为精确全文等值(非 starts_with 前缀),杜绝用户真实 tool_result 内容
|
||||
// 恰以"需要用户审批..."开头被误判占位致去重误吞(详见 context_helpers::is_pending_placeholder)。
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
// SW-260618-16: 查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
||||
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
return Some((msg.content.clone(), status));
|
||||
}
|
||||
None
|
||||
None
|
||||
}).await;
|
||||
// 锁外:查审计表拿缓存来源真实 status(completed/rejected/failed),透传给
|
||||
// audit_tool_call 而非固定 completed(审计语义与结果内容一致,防"rejected/failed 结果
|
||||
// 记 completed"误导安全追溯)。审计记录缺失/查询失败 fallback completed(不阻塞去重,降级原行为)。
|
||||
let (old_id, content) = cached?;
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
Some((content, status))
|
||||
}
|
||||
|
||||
/// 把 JSON args 规范化为可比字符串:对象键按字典序排序后序列化,
|
||||
@@ -156,6 +167,117 @@ fn canonical_args_key(args: &serde_json::Value) -> String {
|
||||
serde_json::to_string(&v).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// BUG-2026-07-07/P0-2:只读幂等工具结果缓存(治 LLM 死循环重调)。
|
||||
///
|
||||
/// **根因链(实测 9357c27c)**:LLM 无"已调用过"记忆,对同参只读工具反复触发:
|
||||
/// - `read_file(application.xml)` 读了 17 次(两次返回完全相同的 8457c 全文)
|
||||
/// - `search_files(pattern='.java')` 连调 13 次(每次返回 items=0/has_more=True 几乎一致)
|
||||
/// 每次都真执行 + 全文回灌 tool_result,是 prompt 64 万的直接元凶。
|
||||
///
|
||||
/// **治本**:对幂等只读工具,扫描会话历史,若发现**已成功完成**的同类同参调用,
|
||||
/// 返回其 tool_result 内容,调用方把缓存结果作为新 tool_call_id 的 tool_result 回传 LLM,
|
||||
/// 跳过真实执行(也跳过 emit Started/Completed——直接走缓存回填路径)。
|
||||
///
|
||||
/// **安全边界**(对照 find_cached_high_risk_result 严格收紧):
|
||||
/// - 仅匹配白名单只读幂等工具(READONLY_CACHE_TOOLS):read_file/list_directory/
|
||||
/// search_files/grep/read_symbol。写工具(write_file/patch_file/run_command 等)
|
||||
/// 永不缓存,避免误伤。
|
||||
/// - 仅匹配 **status=completed** 的成功结果(失败/拒绝不缓存,可能重试成功)。
|
||||
/// - 仅匹配 **非占位** 结果(排除 pending_placeholder 占位)。
|
||||
/// - args 走 JSON 规范化比较(键序无关),与 find_cached_high_risk_result 一致。
|
||||
/// - 返回 None 表示无缓存命中(走原执行流程)。
|
||||
///
|
||||
/// 与 find_cached_high_risk_result 区别:后者治"超时→重试→重新审批"循环( High risk),
|
||||
/// 本函数治"LLM 失忆→同参重调"死循环( Low risk 只读),问题形态相似但触发原因不同。
|
||||
pub(crate) async fn find_cached_readonly_result(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
conv_id: &str,
|
||||
audit_repo: &AiToolExecutionRepo,
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
) -> Option<String> {
|
||||
use df_ai::provider::MessageRole;
|
||||
|
||||
// 白名单:只读幂等工具才缓存。写工具/有副作用工具永不缓存。
|
||||
if !READONLY_CACHE_TOOLS.contains(&tool_name) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let new_args_key = canonical_args_key(args);
|
||||
|
||||
// BUG-260624-03/P0 重构:短 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 };
|
||||
let msgs: Vec<&ChatMessage> = conv.messages.iter().collect();
|
||||
|
||||
// 1) 反向扫描 assistant tool_calls,找最近一条同名同参调用 → 拿到旧 tool_call_id
|
||||
let mut prev_tool_call_id: Option<String> = None;
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Assistant) {
|
||||
continue;
|
||||
}
|
||||
let Some(tcs) = msg.tool_calls.as_ref() else { continue };
|
||||
for tc in tcs {
|
||||
if tc.function.name != tool_name {
|
||||
continue;
|
||||
}
|
||||
let Ok(old_args) = serde_json::from_str::<serde_json::Value>(&tc.function.arguments) else {
|
||||
continue;
|
||||
};
|
||||
if canonical_args_key(&old_args) == new_args_key {
|
||||
prev_tool_call_id = Some(tc.id.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if prev_tool_call_id.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 用旧 tool_call_id 找对应 tool_result
|
||||
let Some(old_id) = prev_tool_call_id else { return None };
|
||||
for msg in msgs.iter().rev() {
|
||||
if !matches!(msg.role, MessageRole::Tool) {
|
||||
continue;
|
||||
}
|
||||
if msg.tool_call_id.as_deref() != Some(old_id.as_str()) {
|
||||
continue;
|
||||
}
|
||||
// 排除 pending 占位(仍在审批中,未真执行)
|
||||
if is_pending_placeholder(&msg.content) {
|
||||
return None;
|
||||
}
|
||||
return Some((old_id, msg.content.clone()));
|
||||
}
|
||||
None
|
||||
}).await;
|
||||
// 锁外:查审计表确认是 completed 成功结果(失败/拒绝不缓存,给 LLM 重试机会)
|
||||
let (old_id, content) = cached?;
|
||||
let status = audit_repo
|
||||
.find_by_tool_call_id(old_id.as_str())
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|opt| opt.map(|rec| rec.status))
|
||||
.unwrap_or_else(|| "completed".to_string());
|
||||
if status != "completed" {
|
||||
return None;
|
||||
}
|
||||
Some(content)
|
||||
}
|
||||
|
||||
/// 可缓存的只读幂等工具白名单。
|
||||
/// 仅含纯读取/查询类工具:同参多次调用结果一致,缓存命中安全。
|
||||
/// 写工具/有副作用工具(write_file/patch_file/run_command/create_*/update_*/delete_* 等)
|
||||
/// 永不进此白名单——它们每次执行都可能改变状态,缓存会掩盖变化。
|
||||
const READONLY_CACHE_TOOLS: &[&str] = &[
|
||||
"read_file", // 读文件内容(同 path+offset+limit+search 参数 → 同结果)
|
||||
"read_symbol", // AST 符号读取(同 path+symbol+full → 同结果)
|
||||
"list_directory", // 列目录(同 path+recursive+max_depth → 同结果)
|
||||
"search_files", // 搜文件名(同 path+pattern+offset+limit → 同结果)
|
||||
"grep", // 搜文件内容(同 path+pattern+mode → 同结果)
|
||||
];
|
||||
|
||||
/// 递归对 JSON 对象的键做字典序排序(就地),数组成员也递归排序。
|
||||
fn sort_object_keys(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
|
||||
@@ -1,68 +1,12 @@
|
||||
//! 工具调用审计记录写/回填(audit_tool_call 写入 + audit_finalize 审批后状态回填)
|
||||
//! 审批后审计记录状态回填(audit_finalize)
|
||||
//!
|
||||
//! 第二批从 audit/mod.rs 抽出(行为零变更,纯 fn 搬迁)。re-export 经 audit/mod.rs
|
||||
//! `pub use finalize::{audit_tool_call, audit_finalize};` 保持 `commands::ai::audit::*`
|
||||
//! 路径透明(调用方零变更)。
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
|
||||
use df_types::types::new_id;
|
||||
//! `audit_tool_call` 已移至 record.rs(审计记录写入操作归 audit record 子模块)。
|
||||
//! re-export 经 audit/mod.rs `pub(crate) use finalize::audit_finalize;` 保持
|
||||
//! `commands::ai::audit::*` 路径透明(调用方零变更)。
|
||||
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::risk_str;
|
||||
|
||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||
///
|
||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||
pub(crate) async fn audit_tool_call(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
if let Err(e) = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||||
message_id: message_id.map(|s| s.to_string()),
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
result,
|
||||
status: status.to_string(),
|
||||
risk_level: risk_str(risk_level).to_string(),
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||||
conv_id,
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 审批后更新审计记录状态(按 tool_call_id 定位 pending 记录,回填 status/decided_by=human/executed_at/result)
|
||||
///
|
||||
/// 走专用 find_by_tool_call_id —— 通用 query 宏硬编码 ORDER BY created_at DESC,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
94
src-tauri/src/commands/ai/audit/path_auth.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! F-260619-03 Phase B/C: 路径授权预校验(文件工具路径提取 + 白/黑名单判定)。
|
||||
//!
|
||||
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||
//! - `extract_file_tool_paths`:从工具参数中提取文件路径
|
||||
//! - `FileToolAuthOutcome`:授权判定结果枚举
|
||||
//! - `check_file_tool_auth`:对单条文件工具调用做路径授权预校验
|
||||
//!
|
||||
//! process_tool_calls 通过 `use path_auth::check_file_tool_auth` 裸名调用。
|
||||
//! 测试模块通过 `super::check_file_tool_auth` 引用。
|
||||
|
||||
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)
|
||||
/// 取 args["path"]
|
||||
/// - rename_file 双路径取 args["from"] + args["to"](两个都需授权)
|
||||
/// - run_command 不返回(它只走 validate_path 黑名单,不限定 workspace,High risk 靠人工审批)
|
||||
pub(crate) fn extract_file_tool_paths(tool_name: &str, args: &serde_json::Value) -> Vec<String> {
|
||||
match tool_name {
|
||||
"rename_file" => {
|
||||
let mut v = Vec::new();
|
||||
if let Some(from) = args.get("from").and_then(|x| x.as_str()) {
|
||||
v.push(from.to_string());
|
||||
}
|
||||
if let Some(to) = args.get("to").and_then(|x| x.as_str()) {
|
||||
v.push(to.to_string());
|
||||
}
|
||||
v
|
||||
}
|
||||
"read_file" | "write_file" | "list_directory" | "patch_file" | "file_info"
|
||||
| "append_file" | "delete_file" | "search_files" | "grep" => {
|
||||
args.get("path").and_then(|x| x.as_str()).map(|s| vec![s.to_string()]).unwrap_or_default()
|
||||
}
|
||||
_ => Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 路径授权预校验结果。
|
||||
pub(crate) enum FileToolAuthOutcome {
|
||||
/// 全部已授权 → 走原 Low/Med/High 流程
|
||||
Authorized,
|
||||
/// 未授权 → 附带 PathAuthRequest,供挂起弹窗
|
||||
NeedsAuth(PathAuthRequest),
|
||||
/// 命中黑名单 → 硬拒
|
||||
Denied(String),
|
||||
}
|
||||
|
||||
/// F-260619-03 Phase B/C: 对单条文件工具调用做路径授权预校验,返回是否需挂起/拒绝/放行。
|
||||
///
|
||||
/// - 路径任一命中黑名单 → `Denied(reason)`:硬拒(工具返 Err tool_result,不挂起)
|
||||
/// - 路径任一未命中白名单(persistent + session_dirs)且非黑名单 → `NeedsAuth`:
|
||||
/// 附带 PathAuthRequest(规范化父目录 + 原始路径列表),供挂起弹窗
|
||||
/// - 全部已授权 → `Authorized`:走原 Low/Med/High 流程
|
||||
pub(crate) fn check_file_tool_auth(
|
||||
tool_name: &str,
|
||||
args: &serde_json::Value,
|
||||
allowed: &crate::state::AllowedDirs,
|
||||
) -> FileToolAuthOutcome {
|
||||
use crate::state::{check_path_authorization, PathAuthDecision};
|
||||
let paths = extract_file_tool_paths(tool_name, args);
|
||||
if paths.is_empty() {
|
||||
// 非文件工具或无路径参数(如缺 path 的异常调用)→ 放行,走原流程(handler 内会因缺参 Err)
|
||||
return FileToolAuthOutcome::Authorized;
|
||||
}
|
||||
// L1 补丁(rename_file 双路径漏校):收集**所有**未授权父目录(去重),
|
||||
// 不只首个。rename_file 的 from/to 若分属不同未授权目录,两个 dir 都需挂起授权。
|
||||
let mut pending_dirs: Vec<std::path::PathBuf> = Vec::new();
|
||||
for p in &paths {
|
||||
match check_path_authorization(p, allowed) {
|
||||
PathAuthDecision::Denied { reason } => return FileToolAuthOutcome::Denied(reason),
|
||||
PathAuthDecision::NeedsAuthorization { dir } => {
|
||||
if !pending_dirs.contains(&dir) {
|
||||
pending_dirs.push(dir);
|
||||
}
|
||||
}
|
||||
PathAuthDecision::Authorized => {}
|
||||
}
|
||||
}
|
||||
if pending_dirs.is_empty() {
|
||||
FileToolAuthOutcome::Authorized
|
||||
} else if tool_name == "search_files" {
|
||||
// 核心设计5:search_files 兜底 — 盲搜语义应拒不应询。
|
||||
// search_files 仅在已授权目录可用;用户已通过 @项目 提供项目上下文,无需搜索文件系统。
|
||||
// (黑名单已在上方 for 循环内短路返 Denied,走到此处均为 NeedsAuthorization 场景。)
|
||||
// 其他文件工具保持原 NeedsAuth 弹窗逻辑完全不变。
|
||||
FileToolAuthOutcome::Denied(
|
||||
"search_files 仅在已授权目录可用;请用 @[项目] 引用或提供绝对路径,不要盲搜文件系统。".to_string(),
|
||||
)
|
||||
} else {
|
||||
FileToolAuthOutcome::NeedsAuth(PathAuthRequest { dirs: pending_dirs, raw_paths: paths })
|
||||
}
|
||||
}
|
||||
164
src-tauri/src/commands/ai/audit/record.rs
Normal file
164
src-tauri/src/commands/ai/audit/record.rs
Normal file
@@ -0,0 +1,164 @@
|
||||
//! 审计记录写/查操作 + 审批历史 DTO + 审计面板查询 IPC。
|
||||
//!
|
||||
//! 第六批从 audit/mod.rs 抽离,行为零变更。包含:
|
||||
//! - `audit_tool_call`:写一条工具执行审计记录(insert)
|
||||
//! - `record_audit`:`audit_tool_call` 的别名,语义更清晰("记一条审计")
|
||||
//! - `query_audit_history`:按条件查询审计历史记录
|
||||
//! - `ToolExecutionDto`:传给前端的精简审计视图
|
||||
//! - `list_tool_executions`:审批历史面板查询 IPC
|
||||
//!
|
||||
//! 依赖 audit/utils.rs 的 `truncate_chars` 做参数/结果截断,通过 `super::truncate_chars` 引用。
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
use df_storage::crud::AiToolExecutionRepo;
|
||||
use df_storage::models::AiToolExecutionRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
use crate::commands::err_str;
|
||||
use crate::commands::now_millis;
|
||||
use crate::state::AppState;
|
||||
|
||||
use super::risk_str;
|
||||
|
||||
/// 写一条工具执行审计记录(insert 失败不阻断主流程,故 `let _ =`)
|
||||
///
|
||||
/// `decided_by` 有值(auto/human)= 已决策执行 → 记 executed_at;
|
||||
/// `None`(pending 待审批)→ executed_at 留空,待 audit_finalize 回填。
|
||||
pub(crate) async fn audit_tool_call(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
) {
|
||||
let executed_at = if decided_by.is_some() { Some(now_millis()) } else { None };
|
||||
if let Err(e) = repo
|
||||
.insert(AiToolExecutionRecord {
|
||||
id: new_id(),
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
// F-260619-04 P1 消息级溯源:message_id 由调用方(process_tool_calls)从
|
||||
// ContextManager 取当前 assistant 消息 id 传入(LLM 返回带 tool_calls 的
|
||||
// assistant 消息已 push 到 per_conv.messages,入口取末条 assistant id)。
|
||||
// None 表示无 assistant 消息(异常路径/老数据无 id),展示侧兼容。
|
||||
message_id: message_id.map(|s| s.to_string()),
|
||||
tool_call_id: tool_call_id.to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
arguments: arguments.to_string(),
|
||||
result,
|
||||
status: status.to_string(),
|
||||
risk_level: risk_str(risk_level).to_string(),
|
||||
requested_at: now_millis(),
|
||||
executed_at,
|
||||
decided_by: decided_by.map(|s| s.to_string()),
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
"audit_tool_call: 写审计记录失败(conv={}, tool_call_id={}, tool={}): {}",
|
||||
conv_id,
|
||||
tool_call_id,
|
||||
tool_name,
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `audit_tool_call` 的语义别名,功能完全相同。
|
||||
/// 命名更符合"记录一条审计"的调用意图,供新代码使用。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn record_audit(
|
||||
repo: &AiToolExecutionRepo,
|
||||
conv_id: &str,
|
||||
tool_call_id: &str,
|
||||
tool_name: &str,
|
||||
arguments: &str,
|
||||
status: &str,
|
||||
risk_level: RiskLevel,
|
||||
result: Option<String>,
|
||||
decided_by: Option<&str>,
|
||||
message_id: Option<&str>,
|
||||
) {
|
||||
audit_tool_call(repo, conv_id, tool_call_id, tool_name, arguments, status, risk_level, result, decided_by, message_id).await;
|
||||
}
|
||||
|
||||
/// 查询审计历史记录(分页,按 requested_at 倒序)。
|
||||
///
|
||||
/// 封装 `list_recent` 添加一层可读语义,方便未来扩展筛选条件。
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn query_audit_history(
|
||||
repo: &AiToolExecutionRepo,
|
||||
limit: u32,
|
||||
offset: u32,
|
||||
) -> Result<Vec<AiToolExecutionRecord>, String> {
|
||||
let limit = limit.min(200);
|
||||
repo.list_recent(limit, offset)
|
||||
.await
|
||||
.map_err(|e| format!("query_audit_history 查询失败: {}", e))
|
||||
}
|
||||
|
||||
/// 审批历史 DTO(传给前端的精简视图,敏感字段截断防泄露)
|
||||
///
|
||||
/// arguments/result 在落库时是完整 JSON(可能含项目名/路径/长结果),审计面板只展示摘要,
|
||||
/// 故截断到固定长度(参数 120 / 结果 160),既保留可读性又不泄露全量数据到前端 DOM。
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ToolExecutionDto {
|
||||
pub id: String,
|
||||
pub conversation_id: Option<String>,
|
||||
pub tool_call_id: String,
|
||||
pub tool_name: String,
|
||||
/// 参数摘要(截断 120 字符,完整原值仍留库)
|
||||
pub arguments_brief: String,
|
||||
/// 结果摘要(截断 160 字符,None → 空串便于前端展示)
|
||||
pub result_brief: Option<String>,
|
||||
/// pending/approved/rejected/executing/completed/failed
|
||||
pub status: String,
|
||||
/// low/medium/high
|
||||
pub risk_level: String,
|
||||
pub requested_at: String,
|
||||
pub executed_at: Option<String>,
|
||||
/// human/auto,None 表示尚未决策
|
||||
pub decided_by: Option<String>,
|
||||
}
|
||||
|
||||
/// 审批历史面板查询:按 requested_at 倒序(最新在前)分页返回工具调用审计记录。
|
||||
///
|
||||
/// 默认 limit=50 / offset=0(第一页)。limit 在 storage 层钳制 ≤200 防滥用。
|
||||
/// 敏感字段(arguments/result)截断成摘要返回,完整原值仍留库。
|
||||
#[tauri::command]
|
||||
pub async fn list_tool_executions(
|
||||
state: State<'_, AppState>,
|
||||
limit: Option<u32>,
|
||||
offset: Option<u32>,
|
||||
) -> Result<Vec<ToolExecutionDto>, String> {
|
||||
let limit = limit.unwrap_or(50);
|
||||
let offset = offset.unwrap_or(0);
|
||||
let records = state
|
||||
.ai_tool_executions
|
||||
.list_recent(limit, offset)
|
||||
.await
|
||||
.map_err(err_str)?;
|
||||
Ok(records
|
||||
.into_iter()
|
||||
.map(|r| ToolExecutionDto {
|
||||
id: r.id,
|
||||
conversation_id: r.conversation_id,
|
||||
tool_call_id: r.tool_call_id,
|
||||
tool_name: r.tool_name,
|
||||
arguments_brief: super::truncate_chars(&r.arguments, 120),
|
||||
result_brief: r.result.map(|s| super::truncate_chars(&s, 160)),
|
||||
status: r.status,
|
||||
risk_level: r.risk_level,
|
||||
requested_at: r.requested_at,
|
||||
executed_at: r.executed_at,
|
||||
decided_by: r.decided_by,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
@@ -24,6 +24,11 @@ use super::risk_from_str;
|
||||
/// 语义而非路由键)。conversation_id=None 的无主审批(R-9)不建 per_conv(无 conv_id 可挂),
|
||||
/// 仍进 pending_approvals 单层表,后续审批按 tool_call_id 路由,不影响正确性。
|
||||
pub async fn restore_pending_approvals(state: &AppState) {
|
||||
// 清理超 24 小时的残留 pending(旧会话遗留,不再有意义)
|
||||
if let Err(e) = state.ai_tool_executions.cleanup_stale_pending(86400).await {
|
||||
tracing::warn!("清理过期 pending 审批失败(非阻断): {}", e);
|
||||
}
|
||||
|
||||
let pending = match state.ai_tool_executions.list_pending().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
//!
|
||||
//! 第五批从 audit/mod.rs 抽离,行为零变更:
|
||||
//! - `risk_str` / `risk_from_str`:finalize / restore 通过 `super::` 引用(pub(crate))。
|
||||
//! - `truncate_chars`:仅 mod.rs 的 list_tool_executions DTO 装配用(pub(super))。
|
||||
//! - `truncate_chars`:mod.rs 的 process_tool_calls DTO 装配用 + record.rs 审计面板 IPC 用(pub(crate))。
|
||||
//!
|
||||
//! re-export 维持原路径透明:`super::risk_str` / `super::risk_from_str` / `super::truncate_chars`
|
||||
//! 在 audit/mod.rs 内通过 `use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
||||
//! 在 audit/mod.rs 内通过 `pub(crate) use` 引入后裸名可用,外部子模块的 `super::risk_*` 引用不变。
|
||||
|
||||
use df_ai::ai_tools::RiskLevel;
|
||||
|
||||
@@ -18,7 +18,7 @@ pub(crate) fn risk_str(r: RiskLevel) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过)
|
||||
/// 审计记录字符串 → RiskLevel(启动重建 pending_approvals 用,未知串返回 None 跳过)
|
||||
pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||
match s {
|
||||
"low" => Some(RiskLevel::Low),
|
||||
@@ -28,8 +28,8 @@ pub(crate) fn risk_from_str(s: &str) -> Option<RiskLevel> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
||||
pub(super) fn truncate_chars(s: &str, max: usize) -> String {
|
||||
/// 截断字符串到 max 字符(按 char_indices 边界切,避免切坏中文/emoji)
|
||||
pub(crate) fn truncate_chars(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
return s.to_string();
|
||||
}
|
||||
|
||||
@@ -189,6 +189,7 @@ mod tests {
|
||||
status: ProjectStatus::InProgress,
|
||||
description: "AI-native dev tool".into(),
|
||||
path: Some(SanitizedPath::new("E:/wk-lab/devflow")),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "头部应隔离标注, got: {}", seg);
|
||||
@@ -206,6 +207,7 @@ mod tests {
|
||||
status: TaskStatus::Todo,
|
||||
description: "do X".into(),
|
||||
project_name: Some("devflow".into()),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(seg.contains("【任务】Implement X"));
|
||||
@@ -234,6 +236,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
},
|
||||
Augmentation::Project {
|
||||
id: "p1".into(),
|
||||
@@ -241,6 +244,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
},
|
||||
];
|
||||
let seg = build_augmentation_segment(&augs, "zh");
|
||||
@@ -258,6 +262,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "en");
|
||||
assert!(seg.starts_with("--- The following is the context"), "en 头部: {}", seg);
|
||||
@@ -271,6 +276,7 @@ mod tests {
|
||||
title: "Idea1".into(),
|
||||
status: IdeaStatus::Approved,
|
||||
description: String::new(),
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "fr");
|
||||
assert!(seg.starts_with("--- 以下是用户选择的上下文参考"), "未知 lang 应按 zh 兜底");
|
||||
@@ -284,6 +290,7 @@ mod tests {
|
||||
status: ProjectStatus::Planning,
|
||||
description: String::new(),
|
||||
path: None,
|
||||
extra: vec![],
|
||||
};
|
||||
let seg = build_augmentation_segment(&[aug], "zh");
|
||||
assert!(!seg.contains("目录:"), "无 path 不应渲染目录行: {}", seg);
|
||||
|
||||
@@ -304,12 +304,15 @@ impl MentionResolver for SkillResolver {
|
||||
}
|
||||
};
|
||||
// 取剥 frontmatter 正文(注入用);None = 缓存未命中或文件读失败
|
||||
let body = read_skill_content_stripped(&name).ok_or_else(|| ResolveError::NotFound {
|
||||
kind: "skill".to_string(),
|
||||
ref_id: name.clone(),
|
||||
})?;
|
||||
let body = read_skill_content_stripped(name.clone())
|
||||
.await
|
||||
.ok_or_else(|| ResolveError::NotFound {
|
||||
kind: "skill".to_string(),
|
||||
ref_id: name.clone(),
|
||||
})?;
|
||||
// source 从缓存取 SkillInfo.source;失败(缓存不一致)默认 "skill"
|
||||
let source = crate::commands::ai::skills::skills_cached()
|
||||
.await
|
||||
.into_iter()
|
||||
.find(|s| s.name == name)
|
||||
.map(|s| s.source)
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::atomic::Ordering;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter, Manager, State};
|
||||
|
||||
use df_ai::provider::{ChatMessage, ContentPart};
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageStatus};
|
||||
use df_storage::models::AiProviderRecord;
|
||||
use df_types::augmentation::{MentionRef, MentionSpanDto};
|
||||
use df_types::types::new_id;
|
||||
@@ -24,8 +24,11 @@ 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, GOAL_MAX_CHARS, GOAL_PIN_ENABLED, MAX_GOALS};
|
||||
// 双轨收口批1:读侧入口拦截用 ConvState(can_accept_request 的 unwrap_or 兜底初值)。
|
||||
use super::super::agentic::{run_agentic_loop, try_continue_agent_loop};
|
||||
#[cfg(test)]
|
||||
use super::super::agentic::GOAL_MAX_CHARS;
|
||||
// ConvState 枚举变体引用(Idle 等),用于 B-Phase2 写侧 conv_states.transition 调用。
|
||||
// 读侧拦截(can_accept_request)走 state.conv_states 方法,不直接引用 ConvState 类型。
|
||||
use super::super::agentic::conv_state::ConvState;
|
||||
use super::super::audit::{audit_finalize, emit_data_changed};
|
||||
use super::super::augmentation::build_augmentation_segment;
|
||||
@@ -72,20 +75,8 @@ pub(crate) fn finalize_pending_placeholders(session: &mut super::super::AiSessio
|
||||
}
|
||||
}
|
||||
|
||||
/// G1 目标钉扎:从 user 消息文本提取单条目标(纯函数,无 IO / 无额外 LLM 请求)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩出局)的提取侧:每次 user 消息的文本即潜在目标,
|
||||
/// 提取后追加到 `PerConvState.pinned_goals` Vec(去重),即使原消息出局目标仍在。
|
||||
///
|
||||
/// 处理(零额外请求,对齐 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_goals(避免空目标注入)。
|
||||
/// 默认用原文非 LLM 提取(消息锚点派 keepIdeas:零额外请求)。
|
||||
/// 原为 G1 目标钉扎的提取函数(2026-07-03 已改为工具调用推理),现仅测试用例使用。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
// strip 所有 [kind:...] mention 段(对齐 intent.rs strip_mention_tags/try_match_mention 逻辑,
|
||||
// 单一真相源:KINDS 表与 intent.rs 一致)。全文扫描,非仅行首。
|
||||
@@ -114,6 +105,7 @@ pub(crate) fn extract_pinned_goal(text: &str) -> String {
|
||||
///
|
||||
/// kind 大小写不敏感(ASCII)+ 中文直比;冒号接受全角:/半角:;kind 后允许空白;
|
||||
/// 未闭合 `]` 返 None(不当 mention,保留原样 `[`)。对齐 intent.rs try_match_mention。
|
||||
#[allow(dead_code)]
|
||||
fn find_mention_close(chars: &[char], open: usize, kinds: &[&str]) -> Option<usize> {
|
||||
let after = open + 1;
|
||||
for k in kinds {
|
||||
@@ -259,15 +251,12 @@ pub async fn ai_regenerate(
|
||||
// 原子占用 generating + 弹出末尾 AI 回复(保留 user 消息)
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可重新生成);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2: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;
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 仅拦
|
||||
// Generating/Compressed(漏拦 Stopping 态),can_accept_request 更严谨(防停止中接新请求竞态)。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 批3 双轨收口:generating bool 已退役,生成态由 run_agentic_loop 入口 ConvState→Generating
|
||||
// 迁移设置(此处不再手动赋值)。stop_flag/iteration/model_override 仍是 per_conv 独立字段。
|
||||
@@ -352,18 +341,21 @@ pub async fn ai_is_generating(
|
||||
// conv_id 为空(前端未传/分离窗口无活跃)时 fallback active conv 兼容旧调用方。
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<bool, String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
let target = conversation_id.filter(|s| !s.is_empty())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
.unwrap_or_default();
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。target 解析仍需 active_conversation_id
|
||||
// (单值读),仅此处轻量 lock 取 active,ConvState 读完全脱离 session lock。
|
||||
let target = {
|
||||
let session = state.ai_session.lock().await;
|
||||
conversation_id.filter(|s| !s.is_empty())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
.unwrap_or_default()
|
||||
};
|
||||
if target.is_empty() {
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false(批4 顶层 generating 已退役)。
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑,返 false。
|
||||
return Ok(false);
|
||||
}
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active(),删除 CONV_STATE_ENABLED off 回退分支
|
||||
// (enum 单路径)。is_active() 拦 Generating+Compressed(压缩期间 loop 仍活跃),语义与
|
||||
// 原 c.generating 一致(此读点用于判断「是否在生成中」,应保留拦 Compressed)。
|
||||
Ok(session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false))
|
||||
// L2 读侧:统一读 conv_state.is_active()(Generating/Compressed)。
|
||||
// B-Phase2:切 conv_states(无锁),语义等价(同一 ConvState 值)。
|
||||
Ok(state.conv_states.is_active(&target))
|
||||
}
|
||||
|
||||
/// 发送消息并获取流式 AI 响应
|
||||
@@ -424,11 +416,10 @@ pub async fn ai_chat_send(
|
||||
session.active_conv_created_at = Some(now_millis());
|
||||
}
|
||||
}
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态,
|
||||
// 入口拦截(双轨收口批1):拦 Stopping/Generating/Compressed,放行 Idle+Error。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。原 c.generating 漏拦 Stopping 态,
|
||||
// can_accept_request 更严谨。仅查目标 conv(真并发下各 conv 独立)。
|
||||
let cs = session.conv_read(&target).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
if !state.conv_states.can_accept_request(&target) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&target);
|
||||
@@ -463,17 +454,6 @@ 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_goals(去重,支持累积多目标)。
|
||||
// GOAL_PIN_ENABLED=false → 跳过;extract_pinned_goal 返空 → 不追加。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
(target, user_msg_id)
|
||||
};
|
||||
|
||||
@@ -529,6 +509,7 @@ pub async fn ai_approve(
|
||||
tool_call_id: String,
|
||||
approved: bool,
|
||||
) -> Result<String, String> {
|
||||
let __lock_t534 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
|
||||
let approval = match session.pending_approvals.remove(&tool_call_id) {
|
||||
@@ -574,7 +555,14 @@ pub async fn ai_approve(
|
||||
// 无 conversation_id 的无主审批(R-9 异常数据)无 per_conv 可写,拒绝结果不入 messages
|
||||
// (审计仍记 rejected),避免写错 conv。
|
||||
if let Some(ref cid) = approval.conversation_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, "用户拒绝了此操作");
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
// (部分 provider 严格解析 tool role content,裸文本会触发协议错误)。
|
||||
let reject_payload = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "user_denied",
|
||||
"message": "用户拒绝了此操作"
|
||||
}).to_string();
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &reject_payload);
|
||||
}
|
||||
let conv_id = approval.conversation_id.clone();
|
||||
let ev = AiChatEvent::AiApprovalResult {
|
||||
@@ -598,11 +586,17 @@ pub async fn ai_approve(
|
||||
// 作 start_iteration,防多次审批反复跑满 max_iterations 致 token 失控。
|
||||
// F-09 B 批4:per_conv 唯一真相源(conv_id 来源 approval.conversation_id);无 conv_id 时 0。
|
||||
let start_iter = {
|
||||
let __lock_t610 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
conv_id.as_deref()
|
||||
let v = conv_id.as_deref()
|
||||
.and_then(|c| session.conv_read(c))
|
||||
.map(|c| c.iteration_used)
|
||||
.unwrap_or(0)
|
||||
.unwrap_or(0);
|
||||
let __hold610 = __lock_t610.elapsed();
|
||||
if __hold610 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:610 持锁 {:?} (含 lock 等待)", __hold610);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环
|
||||
// F-09 B 批2:传 conv_id(approval.conversation_id),try_continue 按 conv 过滤 pending/读 per_conv。
|
||||
@@ -634,6 +628,10 @@ pub async fn ai_approve(
|
||||
);
|
||||
}
|
||||
}
|
||||
let __hold534 = __lock_t534.elapsed();
|
||||
if __hold534 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:534 持锁 {:?} (含 lock 等待)", __hold534);
|
||||
}
|
||||
drop(session); // 释放锁后再执行
|
||||
|
||||
// B-260617-01 决策 a(方案 a2 后端分支):run_workflow 特殊处理。
|
||||
@@ -692,6 +690,7 @@ pub async fn ai_approve(
|
||||
// 重新获取锁,替换占位 tool_result 为真实结果(失败时为错误信息,LLM 据此决定下一步)
|
||||
// F-260616-09 B 批4:per_conv.messages 唯一真相源(conv_id 来源 approval.conversation_id)。
|
||||
// 无 conv_id 的无主审批不入 messages(审计仍记 completed/failed)。
|
||||
let __lock_t704 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&id, &result_val.to_string());
|
||||
@@ -713,6 +712,10 @@ pub async fn ai_approve(
|
||||
let _ = app.emit("ai-chat-event", ev_approval.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。
|
||||
let _ = app.state::<AppState>().ai_event_bus.publish_event(ev_approval);
|
||||
let __hold704 = __lock_t704.elapsed();
|
||||
if __hold704 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:704 持锁 {:?} (含 lock 等待)", __hold704);
|
||||
}
|
||||
drop(session);
|
||||
|
||||
// F-260619-04 P2(方案 B): create_idea source 消息级溯源补全。
|
||||
@@ -723,10 +726,16 @@ pub async fn ai_approve(
|
||||
// 仅 completed(成功)且 create_idea 才补;失败/其他工具 noop(helper 内判定,不污染本路径主流程)。
|
||||
if audit_status == "completed" {
|
||||
let message_id = {
|
||||
let __lock_t735 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
conv_id.as_deref().and_then(|cid| {
|
||||
let v = conv_id.as_deref().and_then(|cid| {
|
||||
session.conv_read(cid).and_then(|c| c.messages.last_assistant_message_id())
|
||||
})
|
||||
});
|
||||
let __hold735 = __lock_t735.elapsed();
|
||||
if __hold735 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:735 持锁 {:?} (含 lock 等待)", __hold735);
|
||||
}
|
||||
v
|
||||
};
|
||||
super::super::audit::maybe_fill_idea_source(
|
||||
&state.db,
|
||||
@@ -749,8 +758,14 @@ pub async fn ai_approve(
|
||||
// F-09 B 批4:per_conv 唯一真相源;无 conv_id 时 start_iter=0。
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t761 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold761 = __lock_t761.elapsed();
|
||||
if __hold761 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_approve:761 持锁 {:?} (含 lock 等待)", __hold761);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 所有待审批处理完毕后恢复 agentic 循环(recovered 无 live loop,try_continue 因 generating=false 自然不续)
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
@@ -852,11 +867,17 @@ pub async fn ai_authorize_dir(
|
||||
);
|
||||
// 取 pending(阶段3a 单真相源合并:从 pending_approvals remove;kind 校验为 Path)。
|
||||
let approval = {
|
||||
let __lock_t864 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
match session.pending_approvals.remove(&tool_call_id) {
|
||||
let v = match session.pending_approvals.remove(&tool_call_id) {
|
||||
Some(a) => a,
|
||||
None => return Err(format!("未找到路径授权挂起: {}", tool_call_id)),
|
||||
};
|
||||
let __hold864 = __lock_t864.elapsed();
|
||||
if __hold864 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:864 持锁 {:?} (含 lock 等待)", __hold864);
|
||||
}
|
||||
v
|
||||
};
|
||||
// 阶段3a 决策层分离:ai_authorize_dir 只消费 kind==Path(路径授权挂起,once/always/deny)。
|
||||
// 提取 PathAuthRequest(kind 下沉原 path_auth 字段),非 Path(误调普通 RiskLevel 审批)→ Err。
|
||||
@@ -866,8 +887,13 @@ pub async fn ai_authorize_dir(
|
||||
ApprovalKind::Path(req) => req,
|
||||
ApprovalKind::Risk { .. } => {
|
||||
// 误调:re-insert 回单表保持挂起,返 Err(前端据 Err 提示改调 ai_approve)。
|
||||
let __lock_t878 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.pending_approvals.insert(tool_call_id.clone(), approval);
|
||||
let __hold878 = __lock_t878.elapsed();
|
||||
if __hold878 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:878 持锁 {:?} (含 lock 等待)", __hold878);
|
||||
}
|
||||
return Err(format!(
|
||||
"tool_call_id={} 非路径授权挂起(普通审批请用 ai_approve)",
|
||||
tool_call_id
|
||||
@@ -884,12 +910,22 @@ pub async fn ai_authorize_dir(
|
||||
|
||||
// ── "deny":拒绝路径授权 → 工具返 Err,恢复 loop ──
|
||||
if decision == "deny" {
|
||||
let err_msg = "用户拒绝路径授权".to_string();
|
||||
// BUG-2026-07-07/P1-3:拒绝消息改结构化 JSON,防裸文本破坏 tool role 消息协议
|
||||
let err_msg = serde_json::json!({
|
||||
"status": "rejected",
|
||||
"reason": "path_auth_denied",
|
||||
"message": "用户拒绝路径授权"
|
||||
}).to_string();
|
||||
{
|
||||
let __lock_t903 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &err_msg);
|
||||
}
|
||||
let __hold903 = __lock_t903.elapsed();
|
||||
if __hold903 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:903 持锁 {:?} (含 lock 等待)", __hold903);
|
||||
}
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
@@ -913,8 +949,14 @@ pub async fn ai_authorize_dir(
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t930 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold930 = __lock_t930.elapsed();
|
||||
if __hold930 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:930 持锁 {:?} (含 lock 等待)", __hold930);
|
||||
}
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
return Ok("rejected".to_string());
|
||||
@@ -1001,10 +1043,15 @@ pub async fn ai_authorize_dir(
|
||||
|
||||
// 替换占位 tool_result 为真实结果
|
||||
{
|
||||
let __lock_t1018 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(ref cid) = conv_id {
|
||||
session.conv(cid).messages.replace_tool_result_content(&tool_call_id, &result_val.to_string());
|
||||
}
|
||||
let __hold1018 = __lock_t1018.elapsed();
|
||||
if __hold1018 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1018 持锁 {:?} (含 lock 等待)", __hold1018);
|
||||
}
|
||||
}
|
||||
let ev_completed = AiChatEvent::AiToolCallCompleted {
|
||||
id: tool_call_id.clone(),
|
||||
@@ -1027,8 +1074,14 @@ pub async fn ai_authorize_dir(
|
||||
}
|
||||
let cont_conv_id = conv_id.clone().unwrap_or_default();
|
||||
let start_iter = {
|
||||
let __lock_t1044 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0)
|
||||
let v = session.conv_read(&cont_conv_id).map(|c| c.iteration_used).unwrap_or(0);
|
||||
let __hold1044 = __lock_t1044.elapsed();
|
||||
if __hold1044 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_authorize_dir:1044 持锁 {:?} (含 lock 等待)", __hold1044);
|
||||
}
|
||||
v
|
||||
};
|
||||
try_continue_agent_loop(&app, &state, &cont_conv_id, start_iter).await;
|
||||
Ok(audit_status.to_string())
|
||||
@@ -1054,6 +1107,7 @@ pub async fn ai_pending_tool_calls(
|
||||
state: State<'_, AppState>,
|
||||
conv_id: String,
|
||||
) -> Result<Vec<PendingToolCallInfo>, String> {
|
||||
let __lock_t1071 = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
// 阶段3a 单真相源合并:单 pending_approvals 表按 conv_id 过滤,kind 字段透传给前端。
|
||||
// 阶段4:返 kind 字段,前端 switchConversation 恢复 pendingApprovals 时按 kind 渲染
|
||||
@@ -1072,12 +1126,17 @@ pub async fn ai_pending_tool_calls(
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
let __hold1071 = __lock_t1071.elapsed();
|
||||
if __hold1071 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_pending_tool_calls:1071 持锁 {:?} (含 lock 等待)", __hold1071);
|
||||
}
|
||||
Ok(list)
|
||||
}
|
||||
|
||||
/// 清空对话历史
|
||||
#[tauri::command]
|
||||
pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let __lock_t1095 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 取活跃对话 id 后释放锁(避免持 session 锁调 DB)
|
||||
let active_id = session.active_conversation_id.clone();
|
||||
@@ -1091,6 +1150,10 @@ pub async fn ai_chat_clear(state: State<'_, AppState>) -> Result<(), String> {
|
||||
}
|
||||
// 阶段3a 单真相源合并:单表 retain(口径不变:清本 conv 保留其他 conv,kind 不区分)。
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != active_id.as_deref());
|
||||
let __hold1095 = __lock_t1095.elapsed();
|
||||
if __hold1095 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear:1095 持锁 {:?} (含 lock 等待)", __hold1095);
|
||||
}
|
||||
drop(session);
|
||||
// 真删 DB:清空该对话 messages(JSON 备份列同步清空 + 清零 token,保留对话壳),刷新不再恢复(AR-7)
|
||||
// F-260619-03 批次 B:同时清空 ai_messages 表(全删,delete_range min_seq=0 max=None)
|
||||
@@ -1128,6 +1191,7 @@ pub async fn ai_chat_clear_context(
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可分段);messages 操作走 per_conv。
|
||||
let conv_id = {
|
||||
let __lock_t1145 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 保护区:保留最近 PROTECT_COUNT 条 active。
|
||||
const PROTECT_COUNT: usize = 6;
|
||||
@@ -1147,9 +1211,13 @@ pub async fn ai_chat_clear_context(
|
||||
// 对保护区外 active 消息标 archived_segment(messages_mut 直接改 status)。
|
||||
for t in conv.messages.messages_mut()[..protect_start].iter_mut() {
|
||||
if t.message.is_active() {
|
||||
t.message.status = Some("archived_segment".to_string());
|
||||
t.message.status = Some(MessageStatus::ArchivedSegment);
|
||||
}
|
||||
}
|
||||
let __hold1145 = __lock_t1145.elapsed();
|
||||
if __hold1145 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_clear_context:1145 持锁 {:?} (含 lock 等待)", __hold1145);
|
||||
}
|
||||
drop(session);
|
||||
conversation_id
|
||||
};
|
||||
@@ -1188,6 +1256,7 @@ pub async fn ai_chat_compress_context(
|
||||
// 取 active 克隆 + compress_end(读不改,LLM 失败则消息状态完全不变)
|
||||
// F-260616-09 B 批2:messages 操作改 per_conv(conv_id 来源:IPC 参数 conversation_id,已与 active 校验)。
|
||||
let (conv_id, active_msgs, compress_end) = {
|
||||
let __lock_t1205 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):移除 active 一致性校验(真并发下后台 conv 也应可压缩)。
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -1228,13 +1297,22 @@ pub async fn ai_chat_compress_context(
|
||||
.map(|t| t.message.clone())
|
||||
.collect();
|
||||
let cid = session.active_conversation_id.clone().unwrap_or_else(|| conversation_id.clone());
|
||||
let __hold1205 = __lock_t1205.elapsed();
|
||||
if __hold1205 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1205 持锁 {:?} (含 lock 等待)", __hold1205);
|
||||
}
|
||||
(cid, active_msgs, protect_start)
|
||||
};
|
||||
|
||||
if active_msgs.is_empty() {
|
||||
// 保护区外无 active 消息(已全 compressed/archived_segment/truncated) → noop
|
||||
let __lock_t1250 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1250 = __lock_t1250.elapsed();
|
||||
if __hold1250 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1250 持锁 {:?} (含 lock 等待)", __hold1250);
|
||||
}
|
||||
drop(session);
|
||||
// 治 Task#1:手动 IPC 降级(无 active 可压缩)用 AiManualCompressed(前端弹 toast)。
|
||||
let ev = AiChatEvent::AiManualCompressed {
|
||||
@@ -1252,8 +1330,13 @@ pub async fn ai_chat_compress_context(
|
||||
let provider = match super::super::secret::build_provider_for(&provider_config) {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let __lock_t1269 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1269 = __lock_t1269.elapsed();
|
||||
if __hold1269 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1269 持锁 {:?} (含 lock 等待)", __hold1269);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
@@ -1278,8 +1361,13 @@ pub async fn ai_chat_compress_context(
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// LLM 失败:不阻塞,set_compressing(false),消息状态不变(未调 compress_old_messages)
|
||||
let __lock_t1295 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
session.conv(&conv_id).messages.set_compressing(false);
|
||||
let __hold1295 = __lock_t1295.elapsed();
|
||||
if __hold1295 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1295 持锁 {:?} (含 lock 等待)", __hold1295);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiError {
|
||||
error: format!("压缩失败: {}", e),
|
||||
@@ -1295,11 +1383,16 @@ pub async fn ai_chat_compress_context(
|
||||
|
||||
// LLM 成功 → 标 compressed(扣 token) + 摘要插首位
|
||||
{
|
||||
let __lock_t1312 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
let conv = session.conv(&conv_id);
|
||||
let _compressed = conv.messages.compress_old_messages(compress_end);
|
||||
conv.messages.insert_at(0, ChatMessage::system(&summary));
|
||||
conv.messages.set_compressing(false);
|
||||
let __hold1312 = __lock_t1312.elapsed();
|
||||
if __hold1312 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_compress_context:1312 持锁 {:?} (含 lock 等待)", __hold1312);
|
||||
}
|
||||
}
|
||||
save_conversation(&state.ai_session, &state.db, &conv_id, None, None, false).await;
|
||||
// 治 Task#1:手动 IPC 成功主路径用 AiManualCompressed(Task#1 期望保留 toast+刷新)。
|
||||
@@ -1335,14 +1428,12 @@ pub async fn ai_chat_edit(
|
||||
// 原子占用 generating + 替换末条 user content + truncate 其后
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可编辑);per_conv 唯一真相源,删顶层双写。
|
||||
// B-Phase2:ConvState 读侧切无锁 conv_states(零锁竞争)。
|
||||
if !state.conv_states.can_accept_request(&conversation_id) {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
{
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 入口拦截(双轨收口批1):读 conv_state 经 can_accept_request() 判断能否接新请求。
|
||||
// 语义:拦 Stopping/Generating/Compressed,放行 Idle+Error。原 c.generating 漏拦 Stopping 态。
|
||||
let cs = session.conv_read(&conversation_id).map(|c| c.conv_state).unwrap_or(ConvState::Idle);
|
||||
if !cs.can_accept_request() {
|
||||
return Err("AI 正在生成中,请等待完成".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// ① 替换末条 active user 消息 content(无 active user → Err)
|
||||
if conv.messages.replace_last_active_user_content(&new_message).is_err() {
|
||||
@@ -1359,16 +1450,7 @@ pub async fn ai_chat_edit(
|
||||
conv.iteration_used = 0;
|
||||
// F-01 阶段6: 记录用户指定模型 override(主对话专用)。
|
||||
conv.model_override = model_override.clone();
|
||||
// G1 目标钉扎:替换消息后重新提取目标(用户可能通过编辑改变了意图,与 send 路径一致)
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&new_message);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
}
|
||||
|
||||
let _tool_defs = state.ai_tools.tool_definitions();
|
||||
@@ -1465,6 +1547,7 @@ pub async fn ai_chat_force_send(
|
||||
// 不再跨 conv 杀(旧实现清全局 generating + 全 clear pending_approvals,在真并发下会误杀其他
|
||||
// 后台 conv 的 loop/审批)。pending_approvals 仅清目标 conv 的(retain),保留其他 conv 的。
|
||||
let (old_conv_id, conv_id, _user_message_id, old_pinned_goals) = {
|
||||
let __lock_t1473 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active → 懒创建。
|
||||
let target = conversation_id.clone().filter(|s| !s.is_empty())
|
||||
@@ -1485,9 +1568,9 @@ pub async fn ai_chat_force_send(
|
||||
}
|
||||
}
|
||||
// ① 软停止复位目标 conv(与 ai_chat_stop 审批分支一致),emit AiCompleted 用旧 target 自身。
|
||||
// was_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。is_active() 与原 generating 语义一致。
|
||||
let was_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// was_gen 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),
|
||||
// 用于判断「原是否在生成」以决定是否 emit AiCompleted。语义等价。
|
||||
let was_gen = state.conv_states.is_active(&target);
|
||||
// SW-260618-02:强制发送丢弃目标 conv 的旧审批,先终态化占位 tool_result,防占位随 messages
|
||||
// 残留下次发送喂给 LLM。仅清目标 conv 的 pending_approvals(retain 保留其他 conv)。
|
||||
finalize_pending_placeholders(&mut *session, &target, "已取消");
|
||||
@@ -1495,15 +1578,13 @@ pub async fn ai_chat_force_send(
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
{
|
||||
let conv = session.conv(&target);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle 收敛)。
|
||||
// was_gen=false 时仍 Idle(幂等);非活跃态→Idle 非法仅 warn(不阻断强制发送语义)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
if let Err(e) = state.conv_states.transition(&target, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] 强制发送 ConvState→Idle 非法(不阻断强制发送)"
|
||||
),
|
||||
"[ai] conv_states 强制发送→Idle 非法(不阻断强制发送)"
|
||||
);
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
@@ -1534,16 +1615,10 @@ 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_goals(去重,与 send/edit 路径一致,支持累积多目标)。
|
||||
// force_send 是用户新指令,审批续跑会复用已累积目标。
|
||||
if GOAL_PIN_ENABLED {
|
||||
let goal = extract_pinned_goal(&user_content);
|
||||
if !goal.is_empty() && !conv.pinned_goals.contains(&goal) {
|
||||
conv.pinned_goals.push(goal);
|
||||
if conv.pinned_goals.len() > MAX_GOALS {
|
||||
conv.pinned_goals.remove(0);
|
||||
}
|
||||
}
|
||||
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
|
||||
let __hold1473 = __lock_t1473.elapsed();
|
||||
if __hold1473 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_force_send:1473 持锁 {:?} (含 lock 等待)", __hold1473);
|
||||
}
|
||||
(was_gen.then_some(target.clone()), target, user_msg_id, conv.pinned_goals.clone())
|
||||
};
|
||||
@@ -1613,6 +1688,7 @@ pub async fn ai_chat_stop(
|
||||
// None/空 → fallback active(旧调用方兼容)。
|
||||
conversation_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let __lock_t1611 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active。
|
||||
let target = conversation_id.filter(|s| !s.is_empty())
|
||||
@@ -1620,24 +1696,20 @@ pub async fn ai_chat_stop(
|
||||
.unwrap_or_default();
|
||||
if target.is_empty() {
|
||||
// 无目标 conv 且无 active:无任何 conv 在跑(批4 顶层 generating 已退役),直接返回。
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
return Ok(());
|
||||
}
|
||||
// 仅查目标 conv 的 generating(真并发下各 conv 独立)。
|
||||
// L2 读侧(双轨收口批1):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。用于判断停止按钮是否可点的预判断。
|
||||
let is_gen = session.conv_read(&target).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// B-Phase3:读侧切无锁 conv_states(单源收敛)。
|
||||
let is_gen = state.conv_states.is_active(&target);
|
||||
if !is_gen {
|
||||
return Ok(());
|
||||
}
|
||||
// 目标 conv 是否有待审批(仅本 conv 的)。
|
||||
// path_auth 审批链阶段1:改调 session_state(target) 收敛状态机判定,替代手写两表合并。
|
||||
// 阶段3a 单真相源合并后:session_state 单表 any 判定(原双表 OR 合一)。
|
||||
//
|
||||
// 兜底/快速回退(改一行即可):
|
||||
// let has_pending = session.pending_approvals.values()
|
||||
// .any(|a| a.conversation_id.as_deref() == Some(target.as_str()));
|
||||
let has_pending = session.session_state(&target) == SessionState::AwaitingApproval;
|
||||
let has_pending = session.session_state(&target, &state.conv_states) == SessionState::AwaitingApproval;
|
||||
if has_pending {
|
||||
// 审批等待态:loop 已退出,直接清理目标 conv 让其立即可用
|
||||
// SW-260618-02:messages 不 clear,占位 tool_result 会残留,下次发送喂给 LLM。
|
||||
@@ -1645,17 +1717,23 @@ pub async fn ai_chat_stop(
|
||||
finalize_pending_placeholders(&mut *session, &target, "会话已停止");
|
||||
session.pending_approvals.retain(|_, a| a.conversation_id.as_deref() != Some(target.as_str()));
|
||||
let conv = session.conv(&target);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 审批分支 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源(per_conv 字段已删)。
|
||||
{
|
||||
let conv_states = app.state::<AppState>().conv_states.clone();
|
||||
if let Err(e) = conv_states.transition(&target, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target,
|
||||
error = %e,
|
||||
"[ai] conv_states ai_chat_stop 审批分支→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
conv.stop_flag.store(true, Ordering::SeqCst); // 双保险:防 try_continue 误判重启
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0, prompt_tokens: 0, completion_tokens: 0, incomplete: None,
|
||||
@@ -1678,6 +1756,10 @@ pub async fn ai_chat_stop(
|
||||
// Notify 仅承载「即时唤醒」,停止真值仍由 stop_flag 决定(stream_llm 唤醒后再判 flag)。
|
||||
notify.notify_one();
|
||||
let conv_id = Some(target);
|
||||
let __hold1611 = __lock_t1611.elapsed();
|
||||
if __hold1611 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1611 持锁 {:?} (含 lock 等待)", __hold1611);
|
||||
}
|
||||
drop(session);
|
||||
|
||||
// B-260615-13 兜底任务:loop 若 panic/异常退出漏发收尾,stop_flag 无人读,
|
||||
@@ -1688,28 +1770,32 @@ pub async fn ai_chat_stop(
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
let __lock_t1686 = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
let target_owned = conv_id.clone().unwrap_or_default();
|
||||
// still_gen 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed),
|
||||
// 3秒后重检是否仍在生成。is_active() 与原 generating 语义一致。
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
let still_gen = if target_owned.is_empty() {
|
||||
false
|
||||
} else {
|
||||
session.conv_read(&target_owned).map(|c| c.conv_state.is_active()).unwrap_or(false)
|
||||
app.state::<AppState>().conv_states.is_active(&target_owned)
|
||||
};
|
||||
if still_gen {
|
||||
// 3秒超时强制收尾:generating bool 已退役,改 ConvState 迁移(Generating→Idle)收敛生成态。
|
||||
// 正常路径由 guard.drop 复位,此处仅超时兜底(loop 未及时退出场景)。
|
||||
let conv = session.conv(&target_owned);
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
conv_id = %target_owned,
|
||||
error = %e,
|
||||
"[ai] ai_chat_stop 3秒超时 ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
{
|
||||
let conv_states = app.state::<AppState>().conv_states.clone();
|
||||
if let Err(e) = conv_states.transition(&target_owned, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %target_owned,
|
||||
error = %e,
|
||||
"[ai] conv_states 3秒超时→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
}
|
||||
let pinned_goals = session.conv(&target_owned).pinned_goals.clone();
|
||||
let __hold1686 = __lock_t1686.elapsed();
|
||||
if __hold1686 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
|
||||
}
|
||||
let pinned_goals = conv.pinned_goals.clone();
|
||||
drop(session); // 释放锁后再 emit,避免持锁调 runtime emit
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
total_tokens: 0,
|
||||
@@ -1722,6 +1808,11 @@ pub async fn ai_chat_stop(
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
// L3 emit 双写:tunnel subscriber(阶段2 后续)透传 miniapp。闭包内 app_handle 仍可访问 AppState。
|
||||
let _ = app_handle.state::<AppState>().ai_event_bus.publish_event(ev);
|
||||
} else {
|
||||
let __hold1686 = __lock_t1686.elapsed();
|
||||
if __hold1686 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_chat_stop:1686 持锁 {:?} (含 lock 等待)", __hold1686);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1746,13 +1837,16 @@ pub async fn ai_continue_loop(
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
{
|
||||
let __lock_t1744 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,可对任意 conv 操作
|
||||
// (含切走后的后台 conv),移除 active 一致性校验(真并发下后台 conv 也应可续跑)。
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。达 max 时
|
||||
// generating=true(conv_state=Generating),is_active() 返回 true 符合预期。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
return Err("AI 未在暂停态,无需继续".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
@@ -1761,6 +1855,10 @@ pub async fn ai_continue_loop(
|
||||
// F-260616-11 决策 a: 达 max 续跑重计 iteration(F-260616-03 决策 a,用户点继续=授权重来)。
|
||||
// 审批续跑(ai_approve)累计不重置见另路径;本路径 start_iteration 传 0,loop 从头计数。
|
||||
conv.iteration_used = 0;
|
||||
let __hold1744 = __lock_t1744.elapsed();
|
||||
if __hold1744 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_continue_loop:1744 持锁 {:?} (含 lock 等待)", __hold1744);
|
||||
}
|
||||
}
|
||||
// 复用审批恢复续 loop 入口(不重写 loop),其内部 spawn run_agentic_loop
|
||||
// F-09 B 批2:传 conv_id(IPC 参数 conversation_id)。
|
||||
@@ -1782,30 +1880,36 @@ pub async fn ai_stop_loop(
|
||||
state: State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<String, String> {
|
||||
let pinned_goals: Vec<String>;
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry>;
|
||||
{
|
||||
let __lock_t1782 = std::time::Instant::now();
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// F-260616-09 B 批4(决策 e):conv_id 来源 IPC 参数 conversation_id,移除 active 一致性校验
|
||||
// (真并发下后台 conv 也应可停止)。
|
||||
// 读点(双轨收口批1):改读 conv_state.is_active()(Generating+Compressed)。停止按钮判断
|
||||
// 是否在生成中(与 ai_continue_loop 同场景)。is_active() 符合语义。
|
||||
let is_gen = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
if !is_gen {
|
||||
// B-Phase3:读侧切无锁 conv_states。
|
||||
if !state.conv_states.is_active(&conversation_id) {
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
return Err("AI 未在暂停态,无需停止".to_string());
|
||||
}
|
||||
let conv = session.conv(&conversation_id);
|
||||
// 置 stop_flag 双保险:防 try_continue 误判重启(与 ai_chat_stop 审批分支一致)
|
||||
conv.stop_flag.store(true, Ordering::SeqCst);
|
||||
// 批3 双轨收口:generating bool 已退役,复位改 ConvState 迁移(Generating→Idle)。
|
||||
match conv.conv_state.transition_to(ConvState::Idle) {
|
||||
Ok(ns) => conv.conv_state = ns,
|
||||
Err(e) => tracing::warn!(
|
||||
// B-Phase3:conv_state 写切 ConvStateStore 单源。
|
||||
if let Err(e) = state.conv_states.transition(&conversation_id, ConvState::Idle) {
|
||||
tracing::warn!(
|
||||
conv_id = %conversation_id,
|
||||
error = %e,
|
||||
"[ai] ai_stop_loop ConvState→Idle 非法(不阻断 emit AiCompleted)"
|
||||
),
|
||||
"[ai] conv_states ai_stop_loop→Idle 非法(不阻断 emit AiCompleted)"
|
||||
);
|
||||
}
|
||||
pinned_goals = conv.pinned_goals.clone();
|
||||
let __hold1782 = __lock_t1782.elapsed();
|
||||
if __hold1782 > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] ai_stop_loop:1782 持锁 {:?} (含 lock 等待)", __hold1782);
|
||||
}
|
||||
}
|
||||
// 暂停态进入前已 save_conversation,此处零 token 上报仅作收敛信号(与 try_continue 补发 AiCompleted 一致)
|
||||
let ev = AiChatEvent::AiCompleted {
|
||||
@@ -1879,6 +1983,10 @@ mod tests {
|
||||
assert_eq!(goal.chars().count(), GOAL_MAX_CHARS);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 低价值目标过滤
|
||||
// ============================================================
|
||||
|
||||
#[test]
|
||||
fn test_extract_mention_unclosed() {
|
||||
let goal = extract_pinned_goal("测试 [项目: DevFlow");
|
||||
|
||||
@@ -30,7 +30,7 @@ use crate::state::AppState;
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
// 命中进程内缓存(skills_cached 已 owned Vec,直接返回)
|
||||
Ok(skills_cached())
|
||||
Ok(skills_cached().await)
|
||||
}
|
||||
|
||||
/// 热重载技能列表(核心设计6:进程内重载,不重启生效)。
|
||||
@@ -40,7 +40,7 @@ pub async fn ai_list_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
#[tauri::command]
|
||||
pub async fn ai_reload_skills() -> Result<Vec<SkillInfo>, String> {
|
||||
invalidate_skills();
|
||||
Ok(skills_cached())
|
||||
Ok(skills_cached().await)
|
||||
}
|
||||
|
||||
/// 设置 LLM 调用并发上限(运行时调整,立即生效)
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::atomic::Ordering;
|
||||
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use df_ai::provider::{ChatMessage, MessageRole};
|
||||
use df_ai::provider::{ChatMessage, MessageRole, MessageStatus};
|
||||
use df_storage::models::AiMessageRecord;
|
||||
use df_types::types::new_id;
|
||||
|
||||
@@ -85,10 +85,10 @@ pub fn record_to_message(rec: &AiMessageRecord) -> ChatMessage {
|
||||
.filter(|s| !s.is_empty())
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
// status "active" → None(归一化,对齐 ChatMessage 默认语义 None=正常可见)
|
||||
let status = if rec.status == "active" {
|
||||
let status = if rec.status == "active" || rec.status.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rec.status.clone())
|
||||
MessageStatus::from_db_str(&rec.status)
|
||||
};
|
||||
ChatMessage {
|
||||
id: Some(rec.id.clone()),
|
||||
@@ -127,7 +127,10 @@ pub fn message_to_record(
|
||||
.tool_calls
|
||||
.as_ref()
|
||||
.map(|t| serde_json::to_string(t).unwrap_or_default());
|
||||
let status = msg.status.clone().unwrap_or_else(|| "active".to_string());
|
||||
let status = msg.status
|
||||
.as_ref()
|
||||
.map(|s| s.as_db_str().to_string())
|
||||
.unwrap_or_else(|| "active".to_string());
|
||||
AiMessageRecord {
|
||||
id,
|
||||
conversation_id: conversation_id.to_string(),
|
||||
@@ -223,7 +226,8 @@ pub async fn ai_conversation_list(
|
||||
.unwrap_or_default();
|
||||
let pinned_goals: Vec<String> = r.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.and_then(|s| serde_json::from_str::<Vec<crate::commands::ai::GoalEntry>>(s).ok())
|
||||
.map(|entries| entries.into_iter().map(|e| e.text).collect())
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"id": r.id,
|
||||
@@ -318,10 +322,10 @@ pub async fn ai_conversation_switch(
|
||||
drop(session);
|
||||
state.clear_session_allowed_dirs().await;
|
||||
let mut session = state.ai_session.lock().await;
|
||||
// 读点(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。is_active() 与原 generating 语义一致(压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload)。
|
||||
let already_live = session.conv_read(&conversation_id).map(|c| c.conv_state.is_active()).unwrap_or(false);
|
||||
// 读点(B-Phase2):改读无锁 conv_states.is_active()(Generating/Compressed),判断目标会话是否
|
||||
// 已在生成中(是否需要从 DB reload)。语义等价(同一 ConvState 值)——压缩期间后台 loop
|
||||
// 仍持有 per_conv.messages,reload 会用 DB 旧快照覆盖内存新消息,应判为已在生成而跳过 reload。
|
||||
let already_live = state.conv_states.is_active(&conversation_id);
|
||||
if !already_live {
|
||||
// 目标 conv 未在生成:从 DB reload messages 到其 per_conv(首次切入或上次切走后无后台 loop)。
|
||||
// 已在生成:保留其 per_conv 现状(后台 loop 持有),messages 由 loop 自行维护。
|
||||
@@ -333,10 +337,34 @@ pub async fn ai_conversation_switch(
|
||||
conv.iteration_used = 0;
|
||||
conv.stop_flag.store(false, Ordering::SeqCst);
|
||||
// 从 DB 恢复 pinned_goals 到 per_conv(G1 目标钉扎持久化)
|
||||
let pinned_goals: Vec<String> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
// 兼容旧格式:["text1","text2"] 转为 [{text, status: active}]
|
||||
let pinned_goals: Vec<crate::commands::ai::GoalEntry> = {
|
||||
let raw: Option<serde_json::Value> = record.pinned_goals
|
||||
.as_deref()
|
||||
.and_then(|s| serde_json::from_str(s).ok());
|
||||
match raw {
|
||||
Some(serde_json::Value::Array(arr)) => {
|
||||
arr.iter().filter_map(|v| {
|
||||
// 新格式: {"text":"...","status":"active"}
|
||||
if let Some(obj) = v.as_object() {
|
||||
let text = obj.get("text")?.as_str()?.to_string();
|
||||
let status = obj.get("status").and_then(|s| s.as_str())
|
||||
.map(|s| if s == "completed" {
|
||||
crate::commands::ai::GoalStatus::Completed
|
||||
} else {
|
||||
crate::commands::ai::GoalStatus::Active
|
||||
}).unwrap_or(crate::commands::ai::GoalStatus::Active);
|
||||
Some(crate::commands::ai::GoalEntry { text, status })
|
||||
} else {
|
||||
// 旧格式: "text" → 转 active
|
||||
let text = v.as_str()?.to_string();
|
||||
Some(crate::commands::ai::GoalEntry::new(text))
|
||||
}
|
||||
}).collect()
|
||||
}
|
||||
_ => vec![],
|
||||
}
|
||||
};
|
||||
conv.pinned_goals = pinned_goals;
|
||||
}
|
||||
// 仅清空目标对话自身的挂起审批,保留其他对话的(防 init 重建的内存 HashMap 被清空,
|
||||
@@ -452,6 +480,9 @@ pub async fn ai_conversation_delete(
|
||||
// F-260616-09 B 批4:删除 conv 时移除其 per_conv 条目(设计 §4.1 conv 存在性判据依赖此,
|
||||
// 旧 loop 检测 conv 不存在即退出)。per_conv 唯一真相源,删顶层 messages.clear 双写。
|
||||
session.per_conv.remove(&conversation_id);
|
||||
// B-Phase4:conv 删除同步清理 ConvStateStore 条目,防已删 conv 残留
|
||||
// Generating 态致 id 复用(同一 conv_id 重新创建)脏状态。
|
||||
state.conv_states.remove(&conversation_id);
|
||||
let was_active = session.active_conversation_id.as_deref() == Some(&conversation_id);
|
||||
if was_active {
|
||||
session.active_conversation_id = None;
|
||||
@@ -528,7 +559,11 @@ pub async fn ai_update_conversation_goals(
|
||||
conversation_id: String,
|
||||
goals: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let goals_json = serde_json::to_string(&goals).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||
// 前端传回的 string[] 转为 GoalEntry(假设全部 active)
|
||||
let goal_entries: Vec<crate::commands::ai::GoalEntry> = goals.into_iter()
|
||||
.map(|g| crate::commands::ai::GoalEntry::new(g))
|
||||
.collect();
|
||||
let goals_json = serde_json::to_string(&goal_entries).map_err(|e| format!("序列化目标列表失败: {e}"))?;
|
||||
// 写 DB
|
||||
state.ai_conversations
|
||||
.update_field(&conversation_id, "pinned_goals", &goals_json)
|
||||
@@ -537,7 +572,7 @@ pub async fn ai_update_conversation_goals(
|
||||
// 同步内存 per_conv(若已加载)
|
||||
let mut session = state.ai_session.lock().await;
|
||||
if let Some(conv) = session.per_conv.get_mut(&conversation_id) {
|
||||
conv.pinned_goals = goals;
|
||||
conv.pinned_goals = goal_entries;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -634,7 +669,7 @@ pub async fn ai_conversation_export(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageRole, ToolCall, ToolCallFunction};
|
||||
use df_ai::provider::{ChatMessage, ContentPart, MessageRole, MessageStatus, ToolCall, ToolCallFunction};
|
||||
|
||||
fn base_msg() -> ChatMessage {
|
||||
ChatMessage {
|
||||
@@ -746,20 +781,22 @@ mod tests {
|
||||
assert_eq!(back.tool_call_id.as_deref(), Some("call_abc"));
|
||||
}
|
||||
|
||||
/// status 各值("truncated"/"compressed")round-trip(非 active 原样保留)
|
||||
/// status 各值(Truncated/Compressed)round-trip
|
||||
#[test]
|
||||
fn roundtrip_status_non_active_preserved() {
|
||||
let mut msg = base_msg();
|
||||
msg.status = Some("truncated".into());
|
||||
msg.status = Some(MessageStatus::Truncated);
|
||||
let rec = message_to_record(&msg, "c", 0, "ts");
|
||||
assert_eq!(rec.status, "truncated");
|
||||
let back = record_to_message(&rec);
|
||||
assert_eq!(back.status.as_deref(), Some("truncated"));
|
||||
assert_eq!(back.status, Some(MessageStatus::Truncated));
|
||||
|
||||
// compressed
|
||||
msg.status = Some("compressed".into());
|
||||
msg.status = Some(MessageStatus::Compressed);
|
||||
let rec = message_to_record(&msg, "c", 0, "ts");
|
||||
assert_eq!(rec.status, "compressed");
|
||||
let back = record_to_message(&rec);
|
||||
assert_eq!(back.status, Some(MessageStatus::Compressed));
|
||||
}
|
||||
|
||||
/// id=None 兜底:`msg_{conv}_{seq}`
|
||||
@@ -802,7 +839,7 @@ mod tests {
|
||||
},
|
||||
}]);
|
||||
msg.model = Some("m".into());
|
||||
msg.status = Some("compressed".into());
|
||||
msg.status = Some(MessageStatus::Compressed);
|
||||
msg.reasoning_content = Some("r".into());
|
||||
msg.timestamp = Some(123);
|
||||
let rec = message_to_record(&msg, "conv_full", 7, "ts_full");
|
||||
@@ -816,7 +853,7 @@ mod tests {
|
||||
serde_json::to_string(&msg.tool_calls).unwrap()
|
||||
);
|
||||
assert_eq!(back.model.as_deref(), Some("m"));
|
||||
assert_eq!(back.status.as_deref(), Some("compressed"));
|
||||
assert_eq!(back.status.as_ref(), Some(&MessageStatus::Compressed));
|
||||
assert_eq!(back.reasoning_content.as_deref(), Some("r"));
|
||||
assert_eq!(back.timestamp, Some(123));
|
||||
}
|
||||
@@ -870,7 +907,7 @@ mod tests {
|
||||
},
|
||||
}]),
|
||||
status: if i % 6 == 0 {
|
||||
Some("compressed".into())
|
||||
Some(MessageStatus::Compressed)
|
||||
} else {
|
||||
None
|
||||
},
|
||||
@@ -975,7 +1012,7 @@ mod tests {
|
||||
id: Some("m_summary".into()),
|
||||
role: MessageRole::Assistant,
|
||||
content: "压缩摘要".into(),
|
||||
status: Some("compressed".into()),
|
||||
status: Some(MessageStatus::Compressed),
|
||||
..base_msg()
|
||||
},
|
||||
ChatMessage {
|
||||
|
||||
@@ -27,8 +27,10 @@ use crate::state::AppState;
|
||||
use crate::commands::{err_str, now_millis};
|
||||
|
||||
// provider.rs 的 super = commands,super::super = ai(与原 commands.rs 的 super=ai 等价)。
|
||||
// 使用 async 版本(`*_async`):IPC 在 async 上下文,同步 keyring 调用会卡 tokio runtime
|
||||
// (Tauri 单线程 runtime,Windows COM / Linux D-Bus 慢场景累加尾延迟)。
|
||||
use super::super::secret::{
|
||||
get_provider_secret, set_provider_secret, delete_provider_secret,
|
||||
get_provider_secret_async, set_provider_secret_async, delete_provider_secret_async,
|
||||
};
|
||||
|
||||
// ============================================================
|
||||
@@ -50,13 +52,41 @@ fn mask_api_key(key: &str) -> String {
|
||||
#[tauri::command]
|
||||
pub async fn ai_list_providers(state: State<'_, AppState>) -> Result<Vec<AiProviderRecord>, String> {
|
||||
let mut providers = state.ai_providers.list_all().await.map_err(err_str)?;
|
||||
// 收敛 is_default 唯一性自愈:历史脏数据(早期 ai_set_provider 未互斥/F-04 前
|
||||
// ai_save_provider 曾直接置默认)可能让多条 provider 同为 is_default=true,
|
||||
// UI 会显示多个"默认"徽章且 get_active_provider 取首条不可预期。此处检测到
|
||||
// 多于 1 条时,保留 created_at 最新的一条(与 ai_set_provider 语义一致:后设优先),
|
||||
// 其余清零并落库。Settings 每次打开必经此 IPC,作为单点收敛入口。
|
||||
let defaults: Vec<&AiProviderRecord> = providers.iter().filter(|p| p.is_default).collect();
|
||||
if defaults.len() > 1 {
|
||||
// created_at 最大者保留为默认(list_all 已按 created_at DESC 排序,首条即最新)
|
||||
let keep_id = defaults[0].id.clone();
|
||||
tracing::warn!(
|
||||
"[is_default] 检测到 {} 条 provider 同为默认,收敛保留 {} 清零其余",
|
||||
defaults.len(), keep_id
|
||||
);
|
||||
for p in &providers {
|
||||
if p.is_default && p.id != keep_id {
|
||||
let mut updated = p.clone();
|
||||
updated.is_default = false;
|
||||
updated.updated_at = now_millis();
|
||||
state.ai_providers.update_full(&updated).await.map_err(err_str)?;
|
||||
}
|
||||
}
|
||||
// 同步内存副本,供下方 mask 循环使用
|
||||
for p in &mut providers {
|
||||
if p.id != keep_id {
|
||||
p.is_default = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
// IPC 不传明文 api_key(FR-S1):前端编辑用空 apiKey 表示不改,mask 后前端 realm 不持有明文。
|
||||
// 迁移后 DB api_key 空 → 从 keyring 取真实密钥再 mask(前端看到 mask 但不持有明文)
|
||||
for p in &mut providers {
|
||||
let real = if !p.api_key.is_empty() {
|
||||
p.api_key.clone() // 未迁移(老明文)
|
||||
} else {
|
||||
get_provider_secret(&p.id).unwrap_or_default() // 迁移后从 keyring
|
||||
get_provider_secret_async(p.id.clone()).await.unwrap_or_default() // 迁移后从 keyring
|
||||
};
|
||||
p.api_key = if real.is_empty() { String::new() } else { mask_api_key(&real) };
|
||||
}
|
||||
@@ -103,7 +133,7 @@ pub async fn ai_save_provider(
|
||||
let provider_id = id.clone().unwrap_or_else(new_id);
|
||||
if !api_key.is_empty() {
|
||||
// 显式改/填密钥 → 写 keyring(现状不变)
|
||||
if let Err(e) = set_provider_secret(&provider_id, &api_key) {
|
||||
if let Err(e) = set_provider_secret_async(provider_id.clone(), api_key.clone()).await {
|
||||
return Err(format!("密钥保存到系统钥匙串失败: {}", e));
|
||||
}
|
||||
} else if let Some(pid) = &id {
|
||||
@@ -116,10 +146,10 @@ pub async fn ai_save_provider(
|
||||
.map_err(err_str)?;
|
||||
if let Some(old) = old {
|
||||
if !old.api_key.is_empty()
|
||||
&& get_provider_secret(pid).is_none()
|
||||
&& get_provider_secret_async(pid.clone()).await.is_none()
|
||||
{
|
||||
// DB 有明文 且 keyring 无 → 即时迁移补密钥
|
||||
if let Err(e) = set_provider_secret(pid, &old.api_key) {
|
||||
if let Err(e) = set_provider_secret_async(pid.clone(), old.api_key.clone()).await {
|
||||
return Err(format!(
|
||||
"检测到该提供商密钥尚未迁移至系统钥匙串,本次保存尝试即时迁移失败({})。\
|
||||
已保留原密钥未改动——请检查系统钥匙串权限后再次保存。",
|
||||
@@ -246,7 +276,7 @@ pub async fn ai_delete_provider(
|
||||
state.ai_providers.delete(&provider_id).await.map_err(err_str)?;
|
||||
// CR-260615-01:DB 已删则清 keyring 残留密钥(失败仅 warn 不阻断——无 DB 消费方,
|
||||
// 残留 keyring 不可复活;同 id 复用也不会读到旧密钥,因 set 覆盖写)
|
||||
if let Err(e) = delete_provider_secret(&provider_id) {
|
||||
if let Err(e) = delete_provider_secret_async(provider_id.clone()).await {
|
||||
tracing::warn!("[FR-S1] keyring 清理失败 {} (残留但无消费方,不阻断删除): {}", provider_id, e);
|
||||
}
|
||||
// 删除的若是当前默认,清空 active 指向,避免悬空
|
||||
|
||||
@@ -153,6 +153,13 @@ fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
|
||||
|
||||
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
|
||||
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
|
||||
///
|
||||
/// **退化检测(BUG-2026-07-07/P0-1)**:实测 9357c27c 会话 seq0 system 消息内容为
|
||||
/// 同一短语重复 41 次的 1879 字符废文本(LLM 退化输出 / repetition degeneration)。
|
||||
/// 原因:某些情况下 LLM(compress 用的 Standard 智力模型)会陷入重复循环,产出形如
|
||||
/// "现在更新项目信息:...让我更新项目描述和技术栈。" × N 的退化输出,被当作合法摘要
|
||||
/// 塞进 active system 消息,污染每一轮 prompt。此处检测后返空,调用方据空值报错降级
|
||||
/// 走关键词摘要兜底(extract_keyword_summary),不让退化输出污染上下文。
|
||||
fn clean_summary(raw: &str) -> String {
|
||||
let t = raw.trim();
|
||||
// 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
|
||||
@@ -162,9 +169,66 @@ fn clean_summary(raw: &str) -> String {
|
||||
.unwrap_or(t)
|
||||
.trim_start_matches('\n');
|
||||
let t = t.strip_suffix("```").unwrap_or(t).trim();
|
||||
// 退化检测:若文本含同一片段重复 ≥5 次,判定为 LLM 退化输出,返空让调用方降级。
|
||||
// 阈值 5 容忍正常排比(如列表项),只拦死循环式拼接(实测 41 次远超阈值)。
|
||||
if is_degenerated_repetition(t) {
|
||||
tracing::warn!(
|
||||
len = t.len(),
|
||||
"[ai] 压缩摘要疑似 LLM 退化重复输出,丢弃降级走关键词兜底"
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
t.to_string()
|
||||
}
|
||||
|
||||
/// 检测 LLM 退化重复输出:文本中是否存在某一长片段(≥8 字符)重复 ≥5 次。
|
||||
///
|
||||
/// 实测样本:`"我已经了解了项目结构。" + "现在更新项目信息:...让我更新项目描述和技术栈。" × 41`
|
||||
/// 即「开头有引导句 + 后接同一片段重复 N 次」形态。
|
||||
///
|
||||
/// 检测策略:退化输出的重复片段在文本中点附近必然是周期性的,故取**文本中部**一段作候选,
|
||||
/// 检查它在全文出现次数。取中点而非前缀,是因为退化输出常带不同前缀引导句
|
||||
/// ("我已经了解了项目结构。"),前缀取样会漏检。
|
||||
///
|
||||
/// **UTF-8 安全**:中文字符占 3 字节,按字节切片会切断字符致 panic。改按 `char_indices`
|
||||
/// 取字符边界,确保片段始终在完整字符边界上。
|
||||
fn is_degenerated_repetition(text: &str) -> bool {
|
||||
if text.len() < 40 {
|
||||
return false; // 太短不可能退化
|
||||
}
|
||||
const MIN_FRAGMENT_CHARS: usize = 8; // 最少 8 个字符(非字节)
|
||||
const REPEAT_THRESHOLD: usize = 5;
|
||||
// 收集字符边界位置(每个字符起始字节偏移),用于安全切片
|
||||
let char_boundaries: Vec<usize> = std::iter::once(0)
|
||||
.chain(text.char_indices().map(|(i, _)| i))
|
||||
.collect();
|
||||
let char_count = char_boundaries.len() - 1;
|
||||
if char_count < MIN_FRAGMENT_CHARS * REPEAT_THRESHOLD {
|
||||
return false; // 字符太少不可能重复 5 次
|
||||
}
|
||||
// 从文本中点取样候选片段(退化输出中段必然是周期性重复)
|
||||
let mid_char = char_count / 2;
|
||||
// 候选片段长度(字符数):从 MIN_FRAGMENT_CHARS 到 40 逐步试
|
||||
let max_frag_chars = 40.min(char_count / REPEAT_THRESHOLD);
|
||||
for frag_chars in (MIN_FRAGMENT_CHARS..=max_frag_chars).rev() {
|
||||
// 取中点附近的 frag_chars 个字符作候选(按字符边界切片)
|
||||
let start_char = mid_char.saturating_sub(frag_chars / 2);
|
||||
let end_char = (start_char + frag_chars).min(char_count);
|
||||
let start_byte = char_boundaries[start_char];
|
||||
let end_byte = char_boundaries[end_char];
|
||||
let fragment = &text[start_byte..end_byte];
|
||||
if fragment.chars().count() < MIN_FRAGMENT_CHARS {
|
||||
continue;
|
||||
}
|
||||
// 用子串计数(不依赖正则,避免引入依赖)
|
||||
let count = text.matches(fragment).count();
|
||||
if count >= REPEAT_THRESHOLD {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -184,6 +248,38 @@ mod tests {
|
||||
assert_eq!(clean_summary(""), "");
|
||||
}
|
||||
|
||||
// ── BUG-2026-07-07/P0-1: LLM 退化重复输出检测 ──
|
||||
#[test]
|
||||
fn clean_summary_rejects_degenerated_repetition() {
|
||||
// 实测样本(9357c27c seq0):同一短语重复 41 次
|
||||
let fragment = "现在更新项目信息:基于代码分析,这是一个大型综合游戏平台后端。让我更新项目描述和技术栈。";
|
||||
let degenerated = format!("我已经了解了项目结构。{}", fragment.repeat(41));
|
||||
// 退化输出应返空(调用方据空值报错降级走关键词兜底)
|
||||
assert_eq!(clean_summary(°enerated), "", "重复拼接的退化输出应被丢弃");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clean_summary_keeps_normal_summary_with_lists() {
|
||||
// 正常摘要(含列表项重复词)不应被误判为退化
|
||||
let normal = "## 意图\n- 整理文档\n## 决策\n- 使用方案A\n- 使用方案A\n- 使用方案A\n## 约束\n不超 100 字";
|
||||
assert_eq!(clean_summary(normal), normal, "正常摘要不应被误判");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_detects_5x_repeat() {
|
||||
// 5 次重复阈值边界测试
|
||||
let fragment = "这是一个测试片段abcdef";
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(5)));
|
||||
assert!(is_degenerated_repetition(&fragment.repeat(41)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_degenerated_repetition_ignores_short_text() {
|
||||
// 短文本不触发(即使重复)
|
||||
assert!(!is_degenerated_repetition("短文本短文本短文本短文本"));
|
||||
assert!(!is_degenerated_repetition("ab"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compress_prompt_returns_template_per_lang() {
|
||||
// 中文模板(默认)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! 对话持久化 + Token 累加器
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
@@ -12,6 +13,14 @@ use crate::commands::now_millis;
|
||||
use super::commands::message_to_record;
|
||||
use super::AiSession;
|
||||
|
||||
/// save_conversation 的总超时上限(秒)。
|
||||
///
|
||||
/// 内部 DB IO 经 Repo 方法已各自 spawn_blocking(不会阻塞 tokio runtime),
|
||||
/// 但仍可能因 SQLite Mutex 长等待 / fs 卡 / 大体量 records 序列化等卡住整个调用,
|
||||
/// 致 agentic loop 或 IPC 永久挂起。外层 timeout 防御:超时只 warn 不阻断
|
||||
/// (save 本就是非关键路径,失败已 warn + 用户可见结果仍在内存真相源)。
|
||||
const SAVE_CONVERSATION_TIMEOUT_SECS: u64 = 5;
|
||||
|
||||
/// Token 用量累加器(agent loop 生命周期内各轮叠加)
|
||||
///
|
||||
/// 纯结构 + 方法:抽自 run_agentic_loop 的 `total_prompt`/`total_completion` 双计数器,
|
||||
@@ -153,6 +162,33 @@ pub(crate) async fn save_conversation(
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 外层 timeout(SAVE_CONVERSATION_TIMEOUT_SECS):超时只 warn 不阻断。
|
||||
// save 是非关键路径(失败已 warn,内存真相源不变),防 DB/fs 卡死拖垮 agentic loop / IPC。
|
||||
// timeout 仅作用于 DB 写入段(此处包住整个 inner,因 session 锁/序列化也属 save 周期,
|
||||
// 超时即放弃本次 save,下次 loop 轮次会重新落库,幂等覆盖无副作用)。
|
||||
if let Err(_elapsed) = tokio::time::timeout(
|
||||
Duration::from_secs(SAVE_CONVERSATION_TIMEOUT_SECS),
|
||||
save_conversation_inner(session_arc, db, conv_id, usage, model, touch_updated_at),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
"save_conversation 超时 ({}s) 放弃 — conv_id={} (DB/fs 可能卡住,内存真相源不变,下次 loop 重落库)",
|
||||
SAVE_CONVERSATION_TIMEOUT_SECS,
|
||||
conv_id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// save_conversation 内部实现(无超时,由外层 save_conversation 包 timeout 调用)。
|
||||
async fn save_conversation_inner(
|
||||
session_arc: &Arc<Mutex<AiSession>>,
|
||||
db: &Arc<Database>,
|
||||
conv_id: &str,
|
||||
usage: Option<&df_ai::provider::TokenUsage>,
|
||||
model: Option<&str>,
|
||||
touch_updated_at: bool,
|
||||
) {
|
||||
// 取 messages + 懒创建首次落库所需的 provider_id/created_at
|
||||
// 工具结果(content)超 8KB(B-260619-02:50KB→8KB,治 GLM 1214)时截断头尾各 ~3KB + 中段
|
||||
@@ -164,30 +200,42 @@ pub(crate) async fn save_conversation(
|
||||
// loop 内 save 由 run_agentic_loop 入参 conv_id 透传;IPC 路径(commands.rs)save 也传 conv_id。
|
||||
// conv() 惰性建:save 路径 conv 必然已建(send/regenerate/edit/switch 均先 conv());若极端
|
||||
// 未建(如启动恢复无 live conv),conv() 建空 PerConvState,save 空 messages(幂等不污染)。
|
||||
let (persist_msgs, provider_id, created_at, pinned_goals) = {
|
||||
// BUG-2026-07-19: lock 段只 clone 必要读(messages + 元数据),truncate 移出 lock。
|
||||
// 原 clone+truncate 全在 lock 内,对话历史长(多轮+大工具结果)时 clone+truncate 持锁可达秒级,
|
||||
// 致 guard.reset/process_tool_calls 等 session lock 竞争超时(aichat 卡死连环:工具卡片不呈现+
|
||||
// 回答完卡住+下条进队列)。truncate 在 clone 副本上操作,锁外执行不污染 session 真相源,行为等价。
|
||||
let __lock_start = std::time::Instant::now();
|
||||
let (mut msgs, provider_id, created_at, pinned_goals) = {
|
||||
let mut session = session_arc.lock().await;
|
||||
let mut msgs = session.conv(conv_id).messages.all_messages_clone();
|
||||
for m in &mut msgs {
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL
|
||||
// (实测 tool result read 大文件后落库 Invalid control character @pos3072)。
|
||||
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
m.parts = truncate_parts_for_persist(parts);
|
||||
}
|
||||
}
|
||||
(
|
||||
msgs,
|
||||
let __wait = __lock_start.elapsed();
|
||||
let cloned = (
|
||||
session.conv(conv_id).messages.all_messages_clone(),
|
||||
session.active_provider_id.clone(),
|
||||
session.active_conv_created_at.clone(),
|
||||
session.conv(conv_id).pinned_goals.clone(),
|
||||
)
|
||||
);
|
||||
let _ = __wait; // 诊断:lock 等待时长(下行 eprintln 输出 lock 段总时长)
|
||||
cloned
|
||||
};
|
||||
let __lock_total = __lock_start.elapsed();
|
||||
if __lock_total > std::time::Duration::from_millis(50) {
|
||||
eprintln!("[LOCK-DIAG] save clone 段持锁 {:?} (含等待,>50ms 报告,定位 session lock 长持有者)", __lock_total);
|
||||
}
|
||||
// truncate 在锁外(clone 副本上操作,不影响 session 真相源)
|
||||
for m in &mut msgs {
|
||||
// P0-2:tool result 是结构化 JSON(provider 读工具返回原样),
|
||||
// 中段截断会插裸换行+中文省略标记破坏 JSON 结构致 reload/重发 parse FAIL。
|
||||
// tool result 体量已由 read_file limit 控源,此处跳过 content 截断,仅截 assistant/user 文本。
|
||||
if !matches!(m.role, df_ai::provider::MessageRole::Tool) {
|
||||
m.content = truncate_for_persist(&m.content);
|
||||
}
|
||||
// F-260614-05 Phase 2a: parts(Image base64) 同样截断(替换占位 Text 片),
|
||||
// 防大体量图把对话 JSON 撑爆。仅作用于持久化副本,不污染内存真相源。
|
||||
if let Some(parts) = m.parts.as_ref() {
|
||||
m.parts = truncate_parts_for_persist(parts);
|
||||
}
|
||||
}
|
||||
let persist_msgs = msgs;
|
||||
|
||||
|
||||
// 序列化当前 conv 的挂起审批快照:从 session.pending_approvals 筛选本 conv 条目,
|
||||
|
||||
@@ -380,6 +380,7 @@ pub(crate) async fn inject_knowledge_into_prompt(
|
||||
// 同一条消息取 text + id(②口径修复):单次反向扫描,避免 text 过滤 is_active 而 id 不过滤
|
||||
// 导致两值取自不同消息。
|
||||
let (last_user_text, user_message_id) = {
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let session = state.ai_session.lock().await;
|
||||
let msgs = session
|
||||
.conv_read(conv_id)
|
||||
@@ -389,6 +390,10 @@ pub(crate) async fn inject_knowledge_into_prompt(
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && m.is_active());
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] build_system_prompt_with_knowledge:383 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
match found {
|
||||
Some(m) => (m.content.clone(), m.id.clone()),
|
||||
None => (String::new(), None),
|
||||
@@ -434,6 +439,7 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
// true 即跳过(等价「提炼中」哨兵);spawn 后按 inserted 结果修正(见下)。
|
||||
// 清位:trigger_extraction_now(手动按钮)强制清位,允许用户手动重提炼。
|
||||
{
|
||||
let __lock_t = std::time::Instant::now();
|
||||
let mut session = session_arc.lock().await;
|
||||
// 一次 conv_read 读两个字段(knowledge_extracted + messages.len()),map 后借用即结束,
|
||||
// 后续 session.conv()(&mut self)不再冲突。
|
||||
@@ -443,6 +449,10 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
.unwrap_or((false, 0));
|
||||
// 守卫 1:已提炼过(或提炼中) → 跳过 + warn。
|
||||
if already {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
tracing::warn!(
|
||||
conv_id = %conv_id,
|
||||
"[knowledge] 跳过自动提炼:本会话已提炼过/提炼中(去重标志置位,防重复刷 candidate);如需重提炼用手动按钮"
|
||||
@@ -451,10 +461,18 @@ pub(crate) async fn maybe_spawn_extraction(
|
||||
}
|
||||
// 守卫 2:消息数。conv_read 未建返 0(< min_messages 自然跳过,语义=空对话不注入知识)。
|
||||
if (count as u32) < config.min_messages {
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
// 原子预置位(TOCTOU 核心):释放锁前先占提炼槽位,杜绝并发窗口内重复 spawn。
|
||||
session.conv(conv_id).knowledge_extracted = true;
|
||||
let __hold = __lock_t.elapsed();
|
||||
if __hold > std::time::Duration::from_millis(30) {
|
||||
eprintln!("[LOCK-SLOW] maybe_spawn_extraction:437 持锁 {:?} (含 lock 等待)", __hold);
|
||||
}
|
||||
}
|
||||
|
||||
let session_arc = session_arc.clone();
|
||||
|
||||
@@ -45,7 +45,7 @@ pub mod stream_recv;
|
||||
pub mod title;
|
||||
pub mod tool_registry;
|
||||
|
||||
use agentic::conv_state::ConvState;
|
||||
use agentic::conv_state::{ConvState, ConvStateStore};
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::sync::Arc;
|
||||
@@ -105,9 +105,10 @@ pub enum ErrorType {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
/// AI 聊天事件(推送到前端)
|
||||
/// AI 聊天事件(推送到前端)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[allow(clippy::large_enum_variant, dead_code)]
|
||||
pub enum AiChatEvent {
|
||||
/// 流式文本片段
|
||||
AiTextDelta { delta: String, conversation_id: Option<String> },
|
||||
@@ -139,7 +140,7 @@ pub enum AiChatEvent {
|
||||
incomplete: Option<bool>,
|
||||
conversation_id: Option<String>,
|
||||
/// 当前会话 pinned_goals(G1 目标钉扎持久化,前端直接读取刷新)
|
||||
pinned_goals: Vec<String>,
|
||||
pinned_goals: Vec<GoalEntry>,
|
||||
},
|
||||
/// 错误
|
||||
///
|
||||
@@ -306,6 +307,65 @@ pub enum AiChatEvent {
|
||||
/// 灵感全量列表(list_by_query 空 query,等价 list_all)
|
||||
ideas: Vec<df_storage::models::IdeaRecord>,
|
||||
},
|
||||
/// 多 Agent 并行执行:Plan 创建(Coordinator.decompose 完成后 emit)。
|
||||
/// 前端 PlanProgress 据此展示 DAG 层结构。
|
||||
AiPlanCreated {
|
||||
plan_id: String,
|
||||
/// 按 layer 分组的子任务列表(layer 0 在前)
|
||||
layers: Vec<PlanLayerInfo>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 多 Agent 并行执行:SubTask 状态变更。
|
||||
AiSubTaskStatusChanged {
|
||||
subtask_id: String,
|
||||
plan_id: String,
|
||||
status: String,
|
||||
persona_id: Option<String>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 多 Agent 并行执行:Plan 合并完成。
|
||||
AiMergeCompleted {
|
||||
plan_id: String,
|
||||
/// 合并后的最终产出(前端展示为单条可展开消息)
|
||||
merged_output: String,
|
||||
/// 检测到的冲突列表(空=无冲突)
|
||||
conflicts: Vec<ConflictInfo>,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
/// 多 Agent 并行执行:冲突已解决。
|
||||
AiConflictResolved {
|
||||
conflict_id: String,
|
||||
plan_id: String,
|
||||
resolution: String,
|
||||
resolved_by: String,
|
||||
conversation_id: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Plan 层信息(AiPlanCreated 事件的载荷)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct PlanLayerInfo {
|
||||
/// 本层的子任务列表
|
||||
pub items: Vec<SubTaskInfo>,
|
||||
}
|
||||
|
||||
/// SubTask 信息(事件载荷)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct SubTaskInfo {
|
||||
pub id: String,
|
||||
pub persona_id: Option<String>,
|
||||
pub intent: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
/// 冲突信息(AiMergeCompleted 事件的载荷)
|
||||
#[derive(Debug, Clone, serde::Serialize)]
|
||||
pub struct ConflictInfo {
|
||||
pub id: String,
|
||||
pub file_path: String,
|
||||
pub conflict_type: String,
|
||||
pub subtask_a: Option<String>,
|
||||
pub subtask_b: Option<String>,
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
@@ -498,18 +558,13 @@ impl AiSession {
|
||||
/// `ai_chat_stop`(commands/chat.rs) 改调本方法替代各自手写的 has_pending,
|
||||
/// 收敛两处状态机判定到单一入口。
|
||||
///
|
||||
/// 兜底/回退:若本方法判定异常需快速回退手写 has_pending,调用方注释已保留原
|
||||
/// 三路组合(generating + path_auth + risk)字面量,改一行即可切回。
|
||||
pub fn session_state(&self, conv_id: &str) -> SessionState {
|
||||
// 阶段3a 单真相源合并后:两类挂起(path_auth + risk)合一进 pending_approvals,
|
||||
// kind 字段区分语义但状态机只看「有无挂起」,单表 any 一次即可(消除原双表 OR 合并判断)。
|
||||
/// B-Phase3:conv_state 读侧切 ConvStateStore(单源收敛)。
|
||||
pub fn session_state(&self, conv_id: &str, conv_states: &ConvStateStore) -> SessionState {
|
||||
let has_pending = self.pending_approvals.values()
|
||||
.any(|a| a.conversation_id.as_deref() == Some(conv_id));
|
||||
if has_pending {
|
||||
SessionState::AwaitingApproval
|
||||
} else if self.conv_read(conv_id).map(|c| c.conv_state.is_active()).unwrap_or(false) {
|
||||
// 读侧(双轨收口批2):改读 conv_state.is_active()(Generating+Compressed),判断是否在
|
||||
// 活跃生成。is_active() 与原 generating 语义一致(压缩期间 loop 仍活跃,应判为 Streaming)。
|
||||
} else if conv_states.is_active(conv_id) {
|
||||
SessionState::Streaming
|
||||
} else {
|
||||
SessionState::Idle
|
||||
@@ -553,7 +608,8 @@ mod tests_f09_per_conv {
|
||||
fn test_per_conv_state_new() {
|
||||
let s = PerConvState::new();
|
||||
// 批3 收口:generating bool 已退役,生成态初值校验改读 conv_state(应为 Idle)。
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "conv_state 初值应为 Idle");
|
||||
// B-Phase3:conv_state 已迁 ConvStateStore,PerConvState 不再持有。
|
||||
// conv_state 由 guard.new(Idle→Generating)经 conv_states.transition 迁移。
|
||||
assert_eq!(s.iteration_used, 0, "iteration_used 初值应为 0");
|
||||
assert!(s.agent_language.is_none(), "agent_language 初值应为 None");
|
||||
assert!(s.model_override.is_none(), "model_override 初值应为 None");
|
||||
@@ -584,7 +640,8 @@ mod tests_f09_per_conv {
|
||||
let ptr_first = {
|
||||
let s = session.conv("conv-a");
|
||||
// 批3 收口:generating bool 已退役,首建会话生成态应为 Idle。
|
||||
assert_eq!(s.conv_state, ConvState::Idle);
|
||||
// B-Phase3:conv_state 已迁 ConvStateStore。
|
||||
// conv_state 初值由 guard/入口保证,PerConvState 不再持有。
|
||||
assert_eq!(s.iteration_used, 0);
|
||||
s as *const PerConvState
|
||||
};
|
||||
@@ -756,21 +813,16 @@ mod tests_f09_per_conv {
|
||||
pub struct PerConvState {
|
||||
/// 对话历史(ContextManager:会话级消息真相源,裁剪仅影响发送视图)
|
||||
pub messages: ContextManager,
|
||||
/// L2 统一状态机:对话生命周期状态(单一真相源,批2 持久化供读侧/前端消费)。
|
||||
/// 写收敛:经 GeneratingGuard/入口 transition_to 守卫迁移,非直接赋值。
|
||||
/// 批3 双轨收口:generating bool 已退役,此 enum 成为生成态唯一真相源。
|
||||
pub conv_state: ConvState,
|
||||
/// G1 目标钉扎真相源:用户消息中提取的目标列表(内容态字段,与生命周期态正交)。
|
||||
// B-Phase3:conv_state 字段已迁到 ConvStateStore(无锁真相源),此字段已删。
|
||||
/// G1 目标钉扎真相源:从 LLM 工具调用推理的目标列表(内容态字段,与生命周期态正交)。
|
||||
///
|
||||
/// 治 R1(目标消息被压缩/compressed 物理出局,sanitize step0 过滤 is_active 致目标丢失):
|
||||
/// 目标存本字段绕过消息 active 状态过滤,即使原始 user 消息出局,goal 仍在。
|
||||
/// run_agentic_loop 入口拼进 system_prompt 尾部(system_prompt 是 loop 不变量,天然
|
||||
/// 免疫压缩/裁剪/sanitize,见 agentic/mod.rs:683)。
|
||||
/// run_agentic_loop 入口拼 active 目标进 system_prompt 尾部(system_prompt 是 loop 不变量)。
|
||||
///
|
||||
/// 写:chat.rs send/force_send/edit push 后提取目标追加到列表(去重,支持累积多目标);
|
||||
/// 读:loop 入口拼接全部目标。GOAL_PIN_ENABLED=false 时不提取不注入,本字段永远空 Vec。
|
||||
/// 随会话销毁不落库(对齐 knowledge_extracted 语义,PerConvState 无 serde derive)。
|
||||
pub pinned_goals: Vec<String>,
|
||||
/// 2026-07-03 改:提取从「用户消息规则」→「工具调用推理」(infer_goal_from_tool_calls),
|
||||
/// 每轮 LLM 回复后从工具名+路径推理目标追加(去重),支持多条/轮 + active→completed 状态流转。
|
||||
pub pinned_goals: Vec<GoalEntry>,
|
||||
/// 停止信号(会话级):ai_chat_stop 置位,agentic loop / stream_llm 检测后尽快退出
|
||||
pub stop_flag: Arc<AtomicBool>,
|
||||
/// 即时停止唤醒(会话级):阻塞在 stream.next() 时 notify_one() 立即唤醒跳出 select!
|
||||
@@ -803,6 +855,34 @@ pub struct PerConvState {
|
||||
pub knowledge_extracted: bool,
|
||||
}
|
||||
|
||||
/// 单个目标记录(含状态追踪)。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GoalEntry {
|
||||
pub text: String,
|
||||
#[serde(default = "default_goal_status")]
|
||||
pub status: GoalStatus,
|
||||
}
|
||||
|
||||
/// 目标状态。
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum GoalStatus {
|
||||
/// 正在进行中(当前 LLM 正在处理)
|
||||
Active,
|
||||
/// 已完成(LLM 已进入下一任务)
|
||||
Completed,
|
||||
}
|
||||
|
||||
fn default_goal_status() -> GoalStatus {
|
||||
GoalStatus::Active
|
||||
}
|
||||
|
||||
impl GoalEntry {
|
||||
pub fn new(text: String) -> Self {
|
||||
GoalEntry { text, status: GoalStatus::Active }
|
||||
}
|
||||
}
|
||||
|
||||
impl PerConvState {
|
||||
/// 新建默认会话级状态。
|
||||
///
|
||||
@@ -821,7 +901,7 @@ impl PerConvState {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
messages: ContextManager::new(ContextConfig::default()),
|
||||
conv_state: ConvState::Idle,
|
||||
|
||||
pinned_goals: Vec::new(),
|
||||
stop_flag: Arc::new(AtomicBool::new(false)),
|
||||
notify: Arc::new(tokio::sync::Notify::new()),
|
||||
|
||||
@@ -77,6 +77,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- Ask for clarification if the user's intent is unclear\n\
|
||||
- Prefer using tools to complete actions rather than just describing steps\n\
|
||||
- When a tool call fails, clearly tell the user it failed and why. Never disguise a fallback action as the original intent's success (e.g. don't write to description to fake a directory binding), and never falsely report success\n\
|
||||
- **Self-check before claiming completion**: Before announcing a task done, verify the result matches the original intent (grep key references / read back field values / cross-check). Fix discrepancies before declaring success; never claim all done without verification (repeatedly observed: missed reference renames, fields silently overwritten by later ops, forcing the user to manually verify)\n\
|
||||
- **Duplicate call detection**: If a tool (e.g. read_file/search_files) has already been called with identical arguments and returned successfully, do not call it again — the result is already in the conversation history; review the context instead of re-executing\n\
|
||||
## Focus\n\
|
||||
- Always center your response on the core goal of the user's current request; the previous round's topic is only background, not the current task.\n\
|
||||
- When the user switches topics (a clear new intent), follow the latest request; do not drag the old topic into the new answer.\n\
|
||||
@@ -101,6 +103,8 @@ fn system_prompt_parts(lang: &str) -> (&'static str, &'static str, &'static str)
|
||||
- 如果不确定用户意图,先提问\n\
|
||||
- 优先使用工具完成操作,而不是只描述步骤\n\
|
||||
- 工具调用失败时必须明确告知用户失败原因,严禁用替代操作冒充原意图成功(如绑定目录失败不得改写描述冒充已绑定),也绝不谎报成功\n\
|
||||
- **宣称任务完成前必须自检**:用 grep/读取关键引用/核对字段值等方式验证结果与原意图一致,发现偏差先修正再宣布完成;严禁不验证就宣称「全部完成」(实测反复出现:漏改引用名、字段被后续操作覆盖未发现,被迫用户人工核对)\n\
|
||||
- **重复调用检测**:如果某个工具(如 read_file/search_files)已用相同参数调用过且返回成功,不要重复调用——会话历史里已有结果,回顾上下文而非重复执行\n\
|
||||
## 聚焦准则\n\
|
||||
- 始终围绕用户当前请求的核心目标回答;上一轮的主题只是背景,不是当前任务。\n\
|
||||
- 用户切换话题(明显的新意图)时,以最新请求为准,不要把旧话题带进新回答。\n\
|
||||
|
||||
@@ -738,33 +738,30 @@ async fn route_send_message(app: &AppHandle, state: &State<'_, AppState>, args:
|
||||
/// - 读 `conv_state.is_active()`(Generating / Compressed 派生态均视为活跃,压缩期间 loop 仍活跃;
|
||||
/// 批3 收口后单一 enum 路径,开关 + generating bool 已退役)
|
||||
///
|
||||
/// 目标 conv 解析(对齐 ai_is_generating:278-284):
|
||||
/// 目标 conv 解析(对齐 ai_is_generating):
|
||||
/// - 入参 conversation_id 非空优先
|
||||
/// - 否则 fallback `active_conversation_id`
|
||||
/// - 都无 → 返 None(放行,无 conv 在跑)
|
||||
///
|
||||
/// 读 conv 状态用 `conv_read`(只读不创建),对齐 ai_is_generating 实现。
|
||||
/// 读 ConvState 用无锁 `conv_states`(B-Phase2,零锁竞争)。
|
||||
async fn check_generating_reject(
|
||||
state: &State<'_, AppState>,
|
||||
conversation_id: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let session = state.ai_session.lock().await;
|
||||
// 目标 conv:入参优先 → active 兜底。
|
||||
let target = conversation_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| session.active_conversation_id.clone());
|
||||
// 目标 conv:入参优先 → active 兜底(轻量 lock 取 active 单值)。
|
||||
let target = {
|
||||
let session = state.ai_session.lock().await;
|
||||
conversation_id
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| session.active_conversation_id.clone())
|
||||
};
|
||||
let target = match target {
|
||||
Some(id) => id,
|
||||
None => return None, // 无目标 conv 且无 active:无任何 conv 在跑,放行
|
||||
};
|
||||
// 读侧(双轨收口批2):统一读 conv_state.is_active()(Generating+Compressed),删除
|
||||
// CONV_STATE_ENABLED off 回退分支(enum 单路径)。对齐 ai_is_generating,无 conv_read 视为 false 放行。
|
||||
let is_gen = session
|
||||
.conv_read(&target)
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
if is_gen {
|
||||
// B-Phase2:读侧切无锁 conv_states.is_active()(Generating/Compressed),对齐 ai_is_generating。
|
||||
if state.conv_states.is_active(&target) {
|
||||
Some(target)
|
||||
} else {
|
||||
None
|
||||
@@ -833,7 +830,7 @@ fn args_get_mention_spans(args: &Value, key: &str) -> Option<Vec<MentionSpanDto>
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::commands::ai::{AiSession, PerConvState};
|
||||
use crate::commands::ai::PerConvState;
|
||||
use df_ai::context::ContextConfig;
|
||||
|
||||
// ── MiniCommand 反序列化测试 ──
|
||||
@@ -920,97 +917,69 @@ mod tests {
|
||||
assert_eq!(args_get_bool(&args, "missing"), None);
|
||||
}
|
||||
|
||||
// ── R1 check_generating_reject 路径测试(隔离 AiSession,绕开 Tauri State 依赖) ──
|
||||
// ── R1 check_generating_reject 路径测试 ──
|
||||
//
|
||||
// check_generating_reject 需 `State<AppState>`,单元测试难构造。
|
||||
// 改测 R1 核心判定逻辑:用 AiSession + PerConvState 模拟生成态,
|
||||
// 复核 conv_read + conv_state.is_active() 口径对齐 ai_is_generating。
|
||||
// (批3 收口后单一 enum 路径,开关 + generating bool 已退役。)
|
||||
// check_generating_reject 生产实现经 State<AppState> 读 conv_states(B-Phase2),
|
||||
// 单元测试难构造 Tauri State。改测 R1 核心判定口径:用 ConvStateStore(无锁,独立可构造)
|
||||
// 模拟生成态,复核 is_active() 拒绝/放行判定对齐 check_generating_reject / ai_is_generating。
|
||||
|
||||
/// R1 判定:无目标 conv 且无 active → 放行(返 None)。
|
||||
#[test]
|
||||
fn test_r1_no_target_pass() {
|
||||
let session = AiSession::new();
|
||||
// 无 active,无入参 conv_id → 无任何 conv 在跑
|
||||
assert!(
|
||||
session.active_conversation_id.is_none(),
|
||||
"新 session 无 active conv"
|
||||
);
|
||||
assert!(
|
||||
session.per_conv.is_empty(),
|
||||
"新 session per_conv 为空"
|
||||
);
|
||||
// 无入参 conv_id 且 active_conversation_id=None 在生产侧由 check_generating_reject 早 return。
|
||||
// 此处只验空 store 的默认语义:任何 conv_id 读返 Idle 不活跃。
|
||||
let store = crate::commands::ai::agentic::conv_state::ConvStateStore::new();
|
||||
assert!(!store.is_active("any"), "空 store 读任意 conv_id 不活跃");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(conv_state=Idle,is_active()=false)。
|
||||
/// R1 判定:目标 conv 未在跑 → 放行(Idle,is_active()=false)。
|
||||
#[test]
|
||||
fn test_r1_target_idle_pass() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-idle");
|
||||
conv.conv_state = ConvState::Idle;
|
||||
// 批3 收口:读 conv_state.is_active() 口径(对齐 check_generating_reject / ai_is_generating)。
|
||||
// 开关已退役,直接读 enum 单一路径。
|
||||
let is_gen = session
|
||||
.conv_read("conv-idle")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
store.transition("conv-idle", ConvState::Idle).unwrap();
|
||||
let is_gen = store.is_active("conv-idle");
|
||||
assert!(!is_gen, "conv-idle Idle 未在跑,is_gen 应为 false(放行)");
|
||||
}
|
||||
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(conv_state=Generating,is_active()=true)。
|
||||
/// R1 判定:目标 conv 在跑 → 拒绝(Generating,is_active()=true)。
|
||||
#[test]
|
||||
fn test_r1_target_generating_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-busy");
|
||||
// 批3 收口:generating bool 已退役,生成态经 conv_state=Generating 表达(单一真相源)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
let is_gen = session
|
||||
.conv_read("conv-busy")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// 生成态经 transition(Generating) 写入(单一真相源,无 generating bool 双轨)。
|
||||
store.transition("conv-busy", ConvState::Generating).unwrap();
|
||||
let is_gen = store.is_active("conv-busy");
|
||||
assert!(is_gen, "conv-busy Generating 应拒绝");
|
||||
}
|
||||
|
||||
/// R1 判定:conv_state 派生态(Generating)is_active()=true(单一 enum 路径)。
|
||||
/// R1 判定:conv_state 派生态(Compressed)is_active()=true(单一 enum 路径)。
|
||||
///
|
||||
/// 验证批3 收口后读 conv_state.is_active() 判定活跃拒绝(enum 唯一真相源)。
|
||||
/// 验证读 is_active() 判定活跃拒绝(enum 唯一真相源),含 Compressed 派生。
|
||||
#[test]
|
||||
fn test_r1_conv_state_active_reject() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
// 批3 收口:开关已退役,enum 单一真相源,无条件 on 路径。
|
||||
let mut session = AiSession::new();
|
||||
let conv = session.conv("conv-state-busy");
|
||||
// 模拟状态机写收敛路径:conv_state=Generating(单一真相源,无 generating bool 双轨)。
|
||||
conv.conv_state = ConvState::Generating;
|
||||
let is_gen = session
|
||||
.conv_read("conv-state-busy")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// Compressed 派生态:loop 仍活跃,is_active()=true。
|
||||
store.transition("conv-state-busy", ConvState::Generating).unwrap();
|
||||
store.transition("conv-state-busy", ConvState::Compressed).unwrap();
|
||||
let is_gen = store.is_active("conv-state-busy");
|
||||
assert!(
|
||||
is_gen,
|
||||
"conv_state=Generating is_active() 应为 true(单一 enum 路径拒绝)"
|
||||
"conv_state=Compressed is_active() 应为 true(单一 enum 路径拒绝)"
|
||||
);
|
||||
}
|
||||
|
||||
/// R1 目标 conv 解析:入参 conversation_id 优先于 active_conversation_id。
|
||||
#[test]
|
||||
fn test_r1_target_input_priority() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let mut session = AiSession::new();
|
||||
session.active_conversation_id = Some("conv-active".to_string());
|
||||
use crate::commands::ai::agentic::conv_state::{ConvState, ConvStateStore};
|
||||
let store = ConvStateStore::new();
|
||||
// active 在跑(Generating),但入参 conv-other 未跑(Idle) → 入参优先,应放行
|
||||
session.conv("conv-active").conv_state = ConvState::Generating;
|
||||
let _ = session.conv("conv-other"); // 创建但 Idle(默认)
|
||||
let active_gen = session
|
||||
.conv_read("conv-active")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
let other_gen = session
|
||||
.conv_read("conv-other")
|
||||
.map(|c| c.conv_state.is_active())
|
||||
.unwrap_or(false);
|
||||
store.transition("conv-active", ConvState::Generating).unwrap();
|
||||
// conv-other 不写入(默认 Idle)
|
||||
let active_gen = store.is_active("conv-active");
|
||||
let other_gen = store.is_active("conv-other");
|
||||
assert!(active_gen, "conv-active 在跑");
|
||||
assert!(!other_gen, "conv-other 未跑");
|
||||
// 入参 conv-other 优先 → 判定 conv-other Idle → 放行
|
||||
@@ -1019,12 +988,12 @@ mod tests {
|
||||
|
||||
// ── PerConvState 初值对齐(确保 R1 测试基线 conv_state=Idle) ──
|
||||
|
||||
/// PerConvState::new() conv_state 初值 Idle(R1 放行基线)。
|
||||
/// B-Phase3:PerConvState.conv_state 字段已迁 ConvStateStore。
|
||||
#[test]
|
||||
fn test_per_conv_state_conv_state_default_idle() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvState;
|
||||
let _ = ContextConfig::default(); // 触发 ContextConfig default 可用(避免 unused)
|
||||
let s = PerConvState::new();
|
||||
assert_eq!(s.conv_state, ConvState::Idle, "PerConvState::new conv_state 初值应为 Idle");
|
||||
fn test_conv_state_store_default_idle() {
|
||||
use crate::commands::ai::agentic::conv_state::ConvStateStore;
|
||||
let store = ConvStateStore::new();
|
||||
assert_eq!(store.get("nonexistent"), crate::commands::ai::agentic::conv_state::ConvState::Idle,
|
||||
"ConvStateStore 不存在的 conv_id 返 Idle");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,16 +287,23 @@ type SkillsGuard = RwLockReadGuard<'static, Option<Vec<SkillInfo>>>;
|
||||
///
|
||||
/// 返 `RwLockReadGuard<Option<Vec<SkillInfo>>>`,调用方解 `*guard` 得 `&Vec<SkillInfo>`。
|
||||
/// 懒初始化走双检锁:先读锁查 Some(快),None 时释放 → 扫盘 → 写锁填回 → 读锁重取。
|
||||
fn skills_lock() -> SkillsGuard {
|
||||
// 快路径:读锁命中
|
||||
///
|
||||
/// P1-260617-3:`scan_skills` 同步递归 `fs::read_dir` + `read_to_string`(plugins/marketplaces
|
||||
/// 多层嵌套,Windows 文件多时同步阻塞 tokio runtime)。本函数改 async,慢路径扫盘包
|
||||
/// `spawn_blocking` 隔离(对齐 commands/project.rs detect_stack 模式)。快路径(读锁命中)仍同步无 fs。
|
||||
async fn skills_lock_async() -> SkillsGuard {
|
||||
// 快路径:读锁命中(无 fs,纯内存)
|
||||
{
|
||||
let g = RwLock::read(&SKILLS).expect("SKILLS poisoned");
|
||||
if g.is_some() {
|
||||
return g;
|
||||
}
|
||||
}
|
||||
// 慢路径:扫盘 + 写锁填回
|
||||
let scanned = scan_skills().skills;
|
||||
// 慢路径:扫盘(spawn_blocking 隔离同步 fs 递归,防阻塞 tokio runtime)
|
||||
let scanned = tokio::task::spawn_blocking(scan_skills)
|
||||
.await
|
||||
.map(|res| res.skills)
|
||||
.unwrap_or_default();
|
||||
{
|
||||
let mut g = RwLock::write(&SKILLS).expect("SKILLS poisoned");
|
||||
// 另一线程可能已填,二次检查(双检锁)
|
||||
@@ -314,8 +321,11 @@ fn skills_lock() -> SkillsGuard {
|
||||
///
|
||||
/// 替代原 `OnceLock::get_or_init` 路径:返 owned `Vec<SkillInfo>`(clone),
|
||||
/// 因 RwLock 不能返 `&'static`。调用方(config.rs:30 / read_skill_content_stripped)已同步适配。
|
||||
pub(crate) fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock();
|
||||
///
|
||||
/// P1-260617-3:改 async,慢路径(首次/重扫)走 `skills_lock_async` → spawn_blocking
|
||||
/// 隔离同步 fs 防阻塞 tokio runtime(Tauri 单线程 runtime)。快路径(读锁命中)无 fs。
|
||||
pub(crate) async fn skills_cached() -> Vec<SkillInfo> {
|
||||
let g = skills_lock_async().await;
|
||||
g.clone().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -332,11 +342,25 @@ pub(crate) fn invalidate_skills() {
|
||||
///
|
||||
/// 核心设计6:注入用正文,避免 YAML 头噪声污染 system prompt。
|
||||
/// 缓存未命中返 None;文件读失败返 None。
|
||||
pub(crate) fn read_skill_content_stripped(name: &str) -> Option<String> {
|
||||
let g = skills_lock();
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
let md = fs::read_to_string(&info.path).ok()?;
|
||||
///
|
||||
/// P1-260617-3:改 async,缓存懒初始化(可能触发扫盘)走 spawn_blocking 防阻塞 runtime。
|
||||
///
|
||||
/// 注:guard(std::sync::RwLockReadGuard 非 Send)必须在 spawn_blocking 的 .await 前 drop,
|
||||
/// 否则非 Send 跨 await 点致 future 不 Send(MentionResolver 要求 Send)。guard 用 { } 限作用域。
|
||||
pub(crate) async fn read_skill_content_stripped(name: String) -> Option<String> {
|
||||
// 在作用域内取 path 后立即 drop guard,避免非 Send guard 跨 spawn_blocking await 点
|
||||
let path: String = {
|
||||
let g = skills_lock_async().await;
|
||||
let skills = g.as_ref()?;
|
||||
let info = skills.iter().find(|s| s.name == name)?;
|
||||
info.path.clone()
|
||||
};
|
||||
// 单文件读取仍是同步 fs,但只一次小读;持续阻塞风险远低于扫盘递归。
|
||||
// 仍包 spawn_blocking 与慢路径一致(对齐 detect_stack:防 Windows fs 调度慢)。
|
||||
let md = tokio::task::spawn_blocking(move || fs::read_to_string(&path))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()?;
|
||||
Some(strip_frontmatter(&md))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
//! 流式接收 LLM 响应
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use futures::StreamExt;
|
||||
use tracing::warn;
|
||||
|
||||
use df_ai::provider::{CompletionRequest, LlmProvider};
|
||||
@@ -21,39 +21,30 @@ use super::{AiChatEvent, ToolCallDraft};
|
||||
/// timeout/connect 关键词分类。
|
||||
///
|
||||
/// 返回 `(status_or_class, raw)`:status 取文本中首个三位数;否则按关键词给 timeout/connect/unknown。
|
||||
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
let raw = e.to_string();
|
||||
|
||||
// 1) 抠 HTTP 状态码:匹配 provider bail 串里的 "错误 4xx/5xx" 或 reqwest 的 "HTTP status"。
|
||||
// R-P2-6:原实现按字节窗口 `&bytes[i..i+3]` 切片——若窗口恰好切在多字节 UTF-8 字符中间会 panic
|
||||
// (依赖中文恰好 3 字节、状态码恰为 ascii 的巧合)。改为按 char 迭代 + 前后非数字边界判断,
|
||||
// 既 UTF-8 安全又顺便修复"长数字串(如端口号 14012)内嵌 401 误命中"的潜在问题。
|
||||
// 白名单码(首位 4/5,故只查 4xx/5xx 区段,减少无效匹配):
|
||||
/// 从字符串(非 anyhow)提取诊断信息,用于 mpsc channel 中来自专用线程的错误字符串。
|
||||
fn extract_error_diag_from_str(raw: &str) -> (String, String) {
|
||||
// 1) 抠 HTTP 状态码
|
||||
const HTTP_CODES: &[&str] = &[
|
||||
"400", "401", "403", "404", "408", "409", "413",
|
||||
"422", "429", "500", "502", "503", "504",
|
||||
];
|
||||
// 把 raw 按 char 收集,索引即 char 下标(非字节),边界判断用 char 安全
|
||||
let chars: Vec<char> = raw.chars().collect();
|
||||
let n = chars.len();
|
||||
let mut i = 0;
|
||||
while i + 3 <= n {
|
||||
// 窗口必须是三个 ascii 数字
|
||||
if chars[i].is_ascii_digit() && chars[i + 1].is_ascii_digit() && chars[i + 2].is_ascii_digit() {
|
||||
let code: String = chars[i..i + 3].iter().collect();
|
||||
// 前后边界必须非数字(否则会从端口号 14012 里抠出 401)
|
||||
let prev_ok = i == 0 || !chars[i - 1].is_ascii_digit();
|
||||
let next_ok = i + 3 == n || !chars[i + 3].is_ascii_digit();
|
||||
if prev_ok && next_ok && HTTP_CODES.contains(&code.as_str()) {
|
||||
return (format!("HTTP {}", code), raw);
|
||||
return (format!("HTTP {}", code), raw.to_string());
|
||||
}
|
||||
i += 3; // 已确认是三连数字,跳过避免窗口重叠重复扫
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
// 2) 传输层分类(reqwest Display 文本特征),不命名 reqwest 类型
|
||||
let lower = raw.to_lowercase();
|
||||
let class = if lower.contains("timeout") || lower.contains("超时") {
|
||||
"timeout"
|
||||
@@ -64,13 +55,20 @@ fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
(class.to_string(), raw)
|
||||
(class.to_string(), raw.to_string())
|
||||
}
|
||||
|
||||
/// 从 anyhow::Error 提取诊断信息
|
||||
#[allow(dead_code)]
|
||||
fn extract_error_diag(e: &anyhow::Error) -> (String, String) {
|
||||
extract_error_diag_from_str(&e.to_string())
|
||||
}
|
||||
|
||||
/// AiError 诊断消息的上下文,区分「建连/首字节失败」与「流中途断」两类。
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub(crate) enum DiagKind {
|
||||
/// `provider.stream()` 直接返回 Err:连接/鉴权/HTTP non-2xx 等
|
||||
#[allow(dead_code)]
|
||||
Init,
|
||||
/// 流已建立,next() 返回 Err:SSE 传输断/解析错等
|
||||
MidStream,
|
||||
@@ -144,282 +142,335 @@ pub(crate) enum StreamResult {
|
||||
/// 前端 watchdog 据此 reset,区分「LLM 在跑」与「真断」(避免空气泡误报中断)
|
||||
/// - `stop_notify.notified()`:用户点停止即时打断,不再等 chunk 到或 120s idle timeout
|
||||
pub(crate) async fn stream_llm(
|
||||
provider: &dyn LlmProvider,
|
||||
provider: Arc<dyn LlmProvider>,
|
||||
request: CompletionRequest,
|
||||
app_handle: &AppHandle,
|
||||
stop_flag: &AtomicBool,
|
||||
notify: &tokio::sync::Notify,
|
||||
conv_id: &str,
|
||||
) -> StreamResult {
|
||||
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(120);
|
||||
/// 流式读取空闲超时:超过此时长无任何 chunk 即判定连接已断
|
||||
/// BUG-2026-07-17:原 120s 太长,后端挂死时用户等太久。改为 45s,正常流式每 chunk 间隔
|
||||
/// 远不到 45s(即使模型思考间隙),超时即判为连接异常走保文/报错路径
|
||||
const STREAM_IDLE_TIMEOUT: Duration = Duration::from_secs(45);
|
||||
/// BUG-2026-07-08: 首 chunk 超时(等首字节)。
|
||||
/// 正常 provider 首 token <3s(即使 48 工具定义 + 长上下文),10s 足够宽容;
|
||||
/// 已有数据后中途静默仍用 STREAM_IDLE_TIMEOUT(120s,允许模型思考间隙)。
|
||||
const FIRST_CHUNK_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// 心跳间隔:静默期向前端报「LLM 仍在跑」,reset watchdog
|
||||
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
match provider.stream(request).await {
|
||||
Ok(mut stream) => {
|
||||
let mut full_text = String::new();
|
||||
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
|
||||
let mut finished_received = false;
|
||||
let mut stopped = false;
|
||||
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = None;
|
||||
// ================================================================
|
||||
// BUG-2026-07-17 根治: 整个 LLM 流式请求运行在独立 OS 线程的
|
||||
// 专用 tokio runtime 上,通过 mpsc channel 将 chunk 流式送回主 runtime。
|
||||
// ================================================================
|
||||
//
|
||||
// 根因: Tauri 在 Windows 上使用单线程 tokio runtime。provider.stream()
|
||||
// 内部(eventsource-stream/reqwest TLS/网络建连)存在同步阻塞操作,
|
||||
// 阻塞唯一工作线程导致 tokio 计时器系统无法推进,timeout 不触发。
|
||||
//
|
||||
// 方案:
|
||||
// 1. 独立 OS 线程 + 多线程 tokio runtime 执行完整 LLM 请求
|
||||
// (provider.stream + stream.next 循环),隔离同步阻塞。
|
||||
// 2. 通过 mpsc channel 将 StreamChunk 逐块传回主 runtime。
|
||||
// 3. reqwest Response/Stream 的生命周期不跨 runtime,避免
|
||||
// "error decoding response body"(runtime 状态不兼容)。
|
||||
//
|
||||
// 资源: 专用线程运行至 LLM 流结束或 mpsc receiver 被 drop(超时/停止)。
|
||||
let (chunk_tx, mut chunk_rx) = tokio::sync::mpsc::channel::<
|
||||
std::result::Result<df_ai::provider::StreamChunk, String>
|
||||
>(64);
|
||||
let provider_for_thread = provider.clone();
|
||||
let conv_id_owned = conv_id.to_string();
|
||||
|
||||
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
|
||||
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
|
||||
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
|
||||
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 stream.next() 抢分支错过首 chunk)。
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
heartbeat.tick().await;
|
||||
|
||||
loop {
|
||||
// B-260615-04:stop 即时打断。stream.next() 阻塞等 chunk 时,
|
||||
// 用户点停止需等 chunk 到或 120s idle timeout 才轮到此处检查——
|
||||
// 合并到下方 select! 的 stop_notify 分支后此处为快路径(非阻塞首检)。
|
||||
// stop_flag 可能被 stopChat() 在 select! 阻塞期间置位,
|
||||
// select! 的 stop_notify 分支会唤醒;此处保留作冗余快检(非阻塞)。
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// 三分支 select!(B-260615-02 + B-260615-04 合并):
|
||||
// 1) stream.next():正常 chunk(idle timeout 120s 包裹,真断返回 InitFailed 交调用方处理)
|
||||
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
|
||||
// 3) stop_notify.notified():用户停止即时打断
|
||||
//
|
||||
// 注意:stop_flag 是 AtomicBool 无 async 通知能力——
|
||||
// 此处用「timeout 包 stream.next() + 进入循环前/后查 stop_flag + 循环内 30s 心跳 tick」
|
||||
// 间接实现「≤30s 感知 stop」(每轮 select! 至多 120s,但心跳 tick 30s 一次会
|
||||
// 触发 select! 返回 → 循环回顶部 stop_flag 快检)。无 Notify 依赖,改动最小。
|
||||
tokio::select! {
|
||||
// 1) 正常 chunk(idle timeout 包裹)
|
||||
chunk_result = tokio::time::timeout(STREAM_IDLE_TIMEOUT, stream.next()) => {
|
||||
match chunk_result {
|
||||
Err(_elapsed) => {
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1:
|
||||
// idle timeout 属 MidStream 类失败——已有文本则保文(Partial),不重试。
|
||||
// 空文本无文可保,UX-260618-15: 不再 emit AiError(retryable=true 重试路径,
|
||||
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应超时(120 秒无数据,连接可能已断开)".to_string(),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
Ok(None) => break, // 流正常结束
|
||||
Ok(Some(chunk_result)) => match chunk_result {
|
||||
Ok(chunk) => {
|
||||
if !chunk.delta.is_empty() {
|
||||
full_text.push_str(&chunk.delta);
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: chunk.delta,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积
|
||||
if let Some(ref rc) = chunk.reasoning_content {
|
||||
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc);
|
||||
}
|
||||
// provider 流式错误事件(Anthropic SSE `type=="error"` 等):
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
|
||||
// 不重试。空文本无文可保,返回 InitFailed(保守 retryable=true,
|
||||
// err_msg 不可靠解析状态码,默认按可重试交调用方决定)。
|
||||
if let Some(err_msg) = &chunk.error {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
error = %err_msg,
|
||||
text_len = full_text.len(),
|
||||
"[ai] provider 流式错误事件",
|
||||
);
|
||||
// UX-2025-04 / CR-30-2 / 决策 a1: MidStream 类失败——已有文本则保文(Partial),
|
||||
// 不重试。空文本无文可保,UX-260618-15: 不再 emit AiError(保守 retryable=true,
|
||||
// emit 权交 agentic 重试耗尽时统一发,避免 N 次重试 N+1 气泡)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
if chunk.finished {
|
||||
finished_received = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 诊断:流中途错误(多为 SSE 传输断),补 provider 标识 + HTTP 状态/分类 + 原始文本
|
||||
let (status_or_class, raw) = extract_error_diag(&e);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流式接收中途错误",
|
||||
);
|
||||
// UX-2025-04 / CR-30-2 / 决策 F-260616-07 a1: MidStream chunk Err——
|
||||
// 已有文本则保文(Partial),不重试。空文本无文可保,返回
|
||||
// InitFailed{retryable=classify_status_or_class(status_or_class)}
|
||||
// (4xx Fatal 立即放弃,5xx/429/timeout/connect 可重试)。
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
// UX-260618-15: 不再 emit AiError。retryable=true 走重试路径,
|
||||
// retryable=false(4xx Fatal)也交 agentic Fatal 分支统一 emit
|
||||
// (InitFailed 携带 retryable + error,agentic 据此决定重试或 Fatal emit)。
|
||||
return StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
|
||||
_ = heartbeat.tick() => {
|
||||
// 心跳只在「仍在等下一 chunk」时有意义——若 stop_flag 已置,顶部快检会 break,无需发心跳
|
||||
// L3 emit 双写:提变量避免构造两次,publish 到事件总线(EVENT_BUS_ENABLED 门控在 publish 内)。
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
std::thread::spawn(move || {
|
||||
let rt = match tokio::runtime::Runtime::new() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id_owned,
|
||||
"[ai] 创建专用 tokio runtime 失败: {}",
|
||||
e,
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// BUG-2026-07-17 双重保险:在专用 runtime 内部也加 120s 超时。
|
||||
// 即使 provider.stream() 在专用线程上意外阻塞(理论不应发生,因专用 runtime
|
||||
// 多线程可推进独立 timer wheel),此 timeout 保证线程最终退出不永久挂起。
|
||||
rt.block_on(async move {
|
||||
use futures::StreamExt;
|
||||
let stream_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(120),
|
||||
provider_for_thread.stream(request),
|
||||
).await;
|
||||
match stream_result {
|
||||
Ok(Ok(mut stream)) => {
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let sent = match chunk_result {
|
||||
Ok(chunk) => chunk_tx.send(Ok(chunk)).await,
|
||||
Err(e) => chunk_tx.send(Err(e.to_string())).await,
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// 3) 即时停止唤醒(B-260615-14):用户点 stop → ai_chat_stop 置 stop_flag 后 notify_one()。
|
||||
// stream.next() 正阻塞等 chunk 时立即被唤醒,不再等 30s 心跳 tick 或 120s idle timeout。
|
||||
// 唤醒后判 stop_flag:真值仍由 AtomicBool 决定,Notify 仅承载「即时唤醒」职责
|
||||
// (防误唤醒继续跑——若非 stop 触发的 notify,flag 仍 false 则照常进下一轮循环)。
|
||||
_ = notify.notified() => {
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
if sent.is_err() {
|
||||
// receiver dropped (timeout/stop/error) — 停止发送
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 用户停止:已生成文本(可能残缺)交调用方入库展示。Complete(incomplete=false)——
|
||||
// 用户主动停止语义非异常中断,正常入库 + AiCompleted(决策 a1: 不重试路径)。
|
||||
if stopped {
|
||||
return StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 断连检测:流尽但从未收到 finished 信号 = 异常中断。
|
||||
// B-260615-05:空内容无 finished emit AiError + InitFailed{retryable=true}(无文可保,交重试)。
|
||||
// UX-2025-04:有 partial_text 则保文(Partial),不 emit AiError,不重试。
|
||||
if !finished_received {
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
// B-260615-05:空内容无 finished。UX-260618-15: 不再 emit AiError(retryable=true
|
||||
// 重试路径,emit 权交 agentic 重试耗尽时统一发)。
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
};
|
||||
Ok(Err(e)) => {
|
||||
let _ = chunk_tx.send(Err(e.to_string())).await;
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
Err(_elapsed) => {
|
||||
tracing::error!(
|
||||
conv_id = %conv_id_owned,
|
||||
"[ai] 专用 runtime 内部超时(120s),provider.stream 未返回"
|
||||
);
|
||||
let _ = chunk_tx.send(Err("LLM 调用超时(120s)".to_string())).await;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 在主 runtime 上通过 mpsc 接收 chunk,应用 timeout/heartbeat/stop 逻辑
|
||||
let mut full_text = String::new();
|
||||
let mut tool_calls_acc: HashMap<u32, ToolCallDraft> = HashMap::new();
|
||||
let mut finished_received = false;
|
||||
let mut stopped = false;
|
||||
let mut final_usage: Option<df_ai::provider::TokenUsage> = None;
|
||||
// BUG-260617-12: DeepSeek thinking 模式推理内容累积(多轮需回传)
|
||||
let mut reasoning_content_acc: Option<String> = None;
|
||||
|
||||
// B-260615-15:heartbeat interval 提至 loop 外复用,避免每轮重建计时器
|
||||
// (每轮重建会丢已积累的节拍,且 interval 首次 tick 立即返回的特性会被误用)。
|
||||
// tokio interval 首 tick 立即返回——此处先丢弃首 tick,让心跳等满首个 30s 静默期才发
|
||||
// (心跳语义是"静默期仍在跑",循环入口立即报无意义且会与 rx.recv() 抢分支错过首 chunk)。
|
||||
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
|
||||
heartbeat.tick().await; // 丢弃首 tick
|
||||
|
||||
// BUG-2026-07-14 根治: wall-clock deadline 替代 select! 内 timeout 包裹。
|
||||
// idle_deadline 是绝对时刻:"下一次 chunk 必须在此前到达,否则判定超时"。
|
||||
// - 首 chunk 阶段: idle_deadline = now + 10s (FIRST_CHUNK_TIMEOUT)
|
||||
// - 收到首 chunk 后: idle_deadline = now + 120s (STREAM_IDLE_TIMEOUT),每次收到 chunk 更新
|
||||
// 心跳打断 select! 后回到循环顶部,deadline 检查在 select! 之前(不受竞争影响)。
|
||||
let mut idle_deadline = tokio::time::Instant::now() + FIRST_CHUNK_TIMEOUT;
|
||||
let mut first_chunk_done = false;
|
||||
|
||||
loop {
|
||||
// B-260615-04:stop 即时打断。
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// BUG-2026-07-14 根治: wall-clock deadline 检查(不依赖 select! timeout 语义)。
|
||||
if tokio::time::Instant::now() >= idle_deadline {
|
||||
if !first_chunk_done {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: format!(
|
||||
"流式响应超时({}秒未收到首字节,服务无响应或请求体过大)",
|
||||
FIRST_CHUNK_TIMEOUT.as_secs()
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// 正常完成:流尽 + finished 信号到位,Complete(incomplete=false)
|
||||
StreamResult::Complete {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout(deadline),保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 三分支 select!:
|
||||
// 1) chunk_rx.recv():来自专用线程的 LLM chunk (15s 保底 timeout,防 select! 死等)
|
||||
// 2) heartbeat.tick():静默期发 AiHeartbeat reset 前端 watchdog
|
||||
// 3) notify.notified():用户停止即时打断
|
||||
tokio::select! {
|
||||
chunk_result = tokio::time::timeout(
|
||||
Duration::from_secs(15),
|
||||
chunk_rx.recv()
|
||||
) => {
|
||||
match chunk_result {
|
||||
Err(_elapsed) => {
|
||||
// 15s 保底 timeout 触发。正常路径不会到这里:心跳 30s 会先触发 select! 返回。
|
||||
if !first_chunk_done {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: format!(
|
||||
"流式响应超时({}秒未收到首字节,服务无响应或请求体过大)",
|
||||
FIRST_CHUNK_TIMEOUT.as_secs()
|
||||
),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流中途 idle timeout,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
Ok(None) => break, // mpsc channel 关闭 = 专用线程 stream 结束
|
||||
Ok(Some(Ok(chunk))) => {
|
||||
// BUG-2026-07-14 根治: 收到 chunk 更新 idle_deadline。
|
||||
if !first_chunk_done {
|
||||
first_chunk_done = true;
|
||||
}
|
||||
idle_deadline = tokio::time::Instant::now() + STREAM_IDLE_TIMEOUT;
|
||||
if !chunk.delta.is_empty() {
|
||||
full_text.push_str(&chunk.delta);
|
||||
// L3 emit 双写
|
||||
let ev = AiChatEvent::AiTextDelta {
|
||||
delta: chunk.delta,
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
if let Some(tc_deltas) = &chunk.tool_calls {
|
||||
for tc_delta in tc_deltas {
|
||||
let draft = tool_calls_acc.entry(tc_delta.index).or_default();
|
||||
if let Some(id) = &tc_delta.id { draft.id = id.clone(); }
|
||||
if let Some(name) = &tc_delta.function_name { draft.name.push_str(name); }
|
||||
if let Some(args) = &tc_delta.function_arguments { draft.args.push_str(args); }
|
||||
}
|
||||
}
|
||||
if let Some(u) = &chunk.usage {
|
||||
final_usage = Some(u.clone());
|
||||
}
|
||||
if let Some(ref rc) = chunk.reasoning_content {
|
||||
reasoning_content_acc.get_or_insert_with(String::new).push_str(rc);
|
||||
}
|
||||
// provider 流式错误事件
|
||||
if let Some(err_msg) = &chunk.error {
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
error = %err_msg,
|
||||
text_len = full_text.len(),
|
||||
"[ai] provider 流式错误事件",
|
||||
);
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
"stream-error",
|
||||
err_msg,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
if chunk.finished {
|
||||
finished_received = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(Some(Err(err_str))) => {
|
||||
// 专用线程上报的流错误(SSE 解析失败/HTTP 错误等)
|
||||
let (status_or_class, raw) = extract_error_diag_from_str(&err_str);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流式接收中途错误(from 专用线程)",
|
||||
);
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::MidStream,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
};
|
||||
}
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// 2) 心跳:静默期 30s 发 AiHeartbeat,前端 watchdog reset(B-260615-02)
|
||||
_ = heartbeat.tick() => {
|
||||
let ev = AiChatEvent::AiHeartbeat {
|
||||
conversation_id: Some(conv_id.to_string()),
|
||||
};
|
||||
let _ = app_handle.emit("ai-chat-event", ev.clone());
|
||||
let _ = app_handle.state::<crate::state::AppState>().ai_event_bus.publish_event(ev);
|
||||
}
|
||||
// 3) 即时停止唤醒(B-260615-14)
|
||||
_ = notify.notified() => {
|
||||
if stop_flag.load(std::sync::atomic::Ordering::SeqCst) {
|
||||
stopped = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
// 诊断:连接/鉴权/HTTP 错误,补 provider 标识 + HTTP 状态码/分类 + 原始文本,
|
||||
// 便于区分 401(key)/404(url)/429(限流)/timeout/连接失败(provider_type 或 base_url 不对)。
|
||||
let (status_or_class, raw) = extract_error_diag(&e);
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
status = %status_or_class,
|
||||
conv_id = %conv_id,
|
||||
error = %raw,
|
||||
"[ai] LLM 流式调用失败",
|
||||
);
|
||||
// UX-260618-15: 不再 emit AiError(retryable 走重试路径,Fatal 立即放弃,均交 agentic 统一 emit
|
||||
// 最终错误气泡,避免重试过程 N 次失败 push N+1 气泡)。诊断文本经 fmt_diag 拼好携带出,
|
||||
// agentic 重试耗尽/Fatal 时 emit AiError 用此文本,诊断信息不丢失。
|
||||
// CR-30-1: Init 失败分类——retryable 据 status_or_class 镜像 retry::is_status_retryable:
|
||||
// 5xx/429/timeout/connect=true 可重试;4xx(非429)/鉴权/参数错=false Fatal 立即放弃。
|
||||
StreamResult::InitFailed {
|
||||
retryable: classify_status_or_class(&status_or_class),
|
||||
error: fmt_diag(
|
||||
provider.name(),
|
||||
DiagKind::Init,
|
||||
&status_or_class,
|
||||
&raw,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// 用户停止
|
||||
if stopped {
|
||||
return StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 断连检测
|
||||
if !finished_received {
|
||||
if full_text.is_empty() && tool_calls_acc.is_empty() {
|
||||
return StreamResult::InitFailed {
|
||||
retryable: true,
|
||||
error: "流式响应意外中断(未收到完成信号,已丢弃残缺响应)".to_string(),
|
||||
};
|
||||
}
|
||||
warn!(
|
||||
provider = %provider.name(),
|
||||
conv_id = %conv_id,
|
||||
text_len = full_text.len(),
|
||||
"[ai] 流尽未收到 finished 但有 partial_text,保文不重试(incomplete)",
|
||||
);
|
||||
return StreamResult::Partial {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
};
|
||||
}
|
||||
|
||||
// 正常完成
|
||||
StreamResult::Complete {
|
||||
text: full_text,
|
||||
tool_calls: tool_calls_acc,
|
||||
usage: final_usage.unwrap_or_default(),
|
||||
reasoning_content: reasoning_content_acc,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -453,12 +504,24 @@ fn classify_status_or_class(status_or_class: &str) -> bool {
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// 无三位数字码:按文本分类(timeout/connect 可重试,unknown/其他保守不重试)
|
||||
// 无三位数字码:按文本分类(timeout/connect/transport/decode 可重试,其余 unknown 保守不重试)
|
||||
// BUG-260617-01:Anthropic 中文参数错误(如 `[1214][messages 参数非法]`)无 HTTP 数字前缀,
|
||||
// 走此文本分支,原 || true 致 retryable=true 空耗 4 次重试浪费 token。
|
||||
// 删 || true 对齐 retry::is_status_retryable 未知码不可重试语义;timeout/connect 瞬态仍可重试。
|
||||
//
|
||||
// BUG-2026-07-17 根治:SSE 解码/传输错误(error decoding response body / Transport error)
|
||||
// 是传输层错误,不是 Fatal。原实现未识别这些关键词,返 unknown→Fatal,致连续重试 4 次均
|
||||
// 立即放弃。现补全传输层关键词,让这类错误走可重试路径。
|
||||
let lower = status_or_class.to_lowercase();
|
||||
lower.contains("timeout") || lower.contains("connect") // unknown 保守不重试(BUG-260617-01)
|
||||
lower.contains("timeout")
|
||||
|| lower.contains("connect")
|
||||
|| lower.contains("transport")
|
||||
|| lower.contains("decoding")
|
||||
|| lower.contains("decode")
|
||||
|| lower.contains("body")
|
||||
|| lower.contains("eof")
|
||||
|| lower.contains("reset")
|
||||
|| lower.contains("broken pipe")
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
@@ -82,15 +82,10 @@ pub(crate) async fn ensure_conversation_title(
|
||||
// 在传给 LLM 前合并连续同 role(拼接 content),不影响其他逻辑(本函数 summary 派生数据)。
|
||||
let summary_msgs = merge_consecutive_roles(summary_msgs);
|
||||
|
||||
// B-260617-17 修复:进入即 extract_title 兜底落库 + emit,保证侧栏即时有非"新对话"标题。
|
||||
// 原实现仅在 LLM 返回 None(失败)或 provider 构建失败时才落 extract——但 LLM 卡住
|
||||
// (generate_title_via_llm 的 llm_concurrency 信号量 acquire 阻塞 / 网络挂起 / 后台
|
||||
// spawn 未跑完即刷新)时,既不返回 None 也不落库 → 标题长期停留"新对话"。现先落 extract
|
||||
// 兜底,下方 LLM 生成成功后覆盖;LLM 卡住/失败/超时则保留兜底,无论如何不再"新对话"。
|
||||
let fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
||||
if let Err(e) = conv_repo.set_title(conv_id, &fallback_title).await {
|
||||
tracing::error!("对话标题兜底落库失败(conv_id={}, title={}): {}", conv_id, fallback_title, e);
|
||||
}
|
||||
// B-260617-17 修复:先用 extract 兜底 emit 让侧栏即时更新(不落库),
|
||||
// 下方 LLM 生成成功后覆盖。这样 LLM 失败时有重试机会,
|
||||
// 不会永久锁定一个机械截取的差标题。
|
||||
let _fallback_title = extract_title(&all_msgs).unwrap_or_else(|| "新对话".to_string());
|
||||
let _ = app_handle.emit("ai-conversation-changed", ());
|
||||
|
||||
// 标题生成是独立一次 LLM 调用,自建 provider(便于后台 spawn,不借主 loop 的 &dyn LlmProvider)
|
||||
@@ -204,15 +199,43 @@ fn clean_title(raw: &str) -> String {
|
||||
if cleaned.is_empty() { "新对话".to_string() } else { cleaned }
|
||||
}
|
||||
|
||||
/// 从消息历史中提取对话标题(取第一条用户消息前 30 字)
|
||||
/// 从消息历史中提取对话标题。策略:取前 5 条 user 消息中字符数最多的那条。
|
||||
///
|
||||
/// 开头常出现测试/问候("1"、"你好"等),取最长消息自然跳过这些噪音。
|
||||
/// 极端情况全部过短时(仅测试消息),用第一条非空 user 兜底。
|
||||
pub(crate) fn extract_title(messages: &[ChatMessage]) -> Option<String> {
|
||||
messages.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::User))
|
||||
.map(|m| {
|
||||
let mut chars = m.content.chars();
|
||||
let t: String = chars.by_ref().take(30).collect();
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
const MAX_TITLE_CHARS: usize = 60;
|
||||
const SCAN_LIMIT: usize = 5;
|
||||
|
||||
let user_msgs: Vec<&str> = messages.iter()
|
||||
.filter(|m| matches!(m.role, MessageRole::User))
|
||||
.take(SCAN_LIMIT)
|
||||
.map(|m| m.content.trim())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
|
||||
// 取字符数最多的
|
||||
let best = user_msgs.iter()
|
||||
.max_by_key(|s| s.chars().count())
|
||||
.copied();
|
||||
|
||||
match best {
|
||||
Some(text) => {
|
||||
let mut chars = text.chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { Some(format!("{}...", t)) } else { Some(t) }
|
||||
}
|
||||
None => {
|
||||
// 极端情况全空 → 第一条非空 user(兜底)
|
||||
messages.iter()
|
||||
.find(|m| matches!(m.role, MessageRole::User) && !m.content.trim().is_empty())
|
||||
.map(|m| {
|
||||
let mut chars = m.content.trim().chars();
|
||||
let t: String = chars.by_ref().take(MAX_TITLE_CHARS).collect();
|
||||
if chars.next().is_some() { format!("{}...", t) } else { t }
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 合并相邻同 role 消息,拼接 content(防 Anthropic 1214 连续同 role 错误)。
|
||||
|
||||
@@ -593,7 +593,7 @@ fn register_project_tools(registry: &mut AiToolRegistry, db: &Arc<Database>) {
|
||||
})},
|
||||
);
|
||||
registry.register(
|
||||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)",
|
||||
"bind_directory", "为项目绑定代码目录(自动探测技术栈,防重复绑定)。path 应为**项目根路径**(单仓库=仓库根;多工程 monorepo=工作区根,非单个子工程路径;子工程信息走 list_project_modules/update_project_module 维护)。多次调用会覆盖 path 字段,monorepo 场景勿为每个子工程重复绑定。可选 stack 手动指定技术栈(如 'Go / Gin / Vue3'),不传则自动探测。",
|
||||
df_ai::ai_tools::object_schema(vec![("id", "string", true), ("path", "string", true), ("stack", "string", false)]),
|
||||
RiskLevel::Medium,
|
||||
{ let db = db.clone(); Box::new(move |args: serde_json::Value| {
|
||||
@@ -1308,22 +1308,27 @@ fn register_task_graph_tools(registry: &mut AiToolRegistry, db: &Arc<Database>)
|
||||
// Git 只读 AI 工具(工程系统,2026-06-29)
|
||||
// ============================================================
|
||||
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败返回空字符串(非崩溃)。
|
||||
/// 在指定目录执行 git 命令(10s 超时,返回 stdout)。失败/超时返回空字符串(非崩溃)。
|
||||
///
|
||||
/// BUG-2026-07-18: 原实现 spawn_blocking 内裸 std::process::Command::output() 无 timeout
|
||||
/// (注释谎称"10s 超时")。git 在 OneDrive/网盘/挂载盘/lfs/大仓库场景会卡数十秒到无限,
|
||||
/// spawn_blocking 线程永不返回 → 累计耗尽 tokio blocking 池 → 间接卡死单线程 runtime
|
||||
/// (与 env_snapshot::probe_version 同型病根)。AI 的 git_status/log/diff 工具在会话内高频触发。
|
||||
///
|
||||
/// 改用 tokio::process + tokio::time::timeout(10s) + kill_on_drop:超时 drop 时 child 进程
|
||||
/// 被 kill,不泄漏线程/进程,对齐 shell.rs execute 同源封装。
|
||||
async fn exec_git(working_dir: &str, args: &[&str]) -> String {
|
||||
let dir = working_dir.to_string();
|
||||
let args_vec: Vec<String> = args.iter().map(|s| s.to_string()).collect();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.args(&args_vec).current_dir(&dir);
|
||||
cmd.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null());
|
||||
match cmd.output() {
|
||||
Ok(out) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Err(_) => String::new(),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
result.unwrap_or_default()
|
||||
let mut cmd = tokio::process::Command::new("git");
|
||||
cmd.args(args)
|
||||
.current_dir(working_dir)
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.kill_on_drop(true);
|
||||
match tokio::time::timeout(std::time::Duration::from_secs(10), cmd.output()).await {
|
||||
Ok(Ok(out)) => String::from_utf8_lossy(&out.stdout).to_string(),
|
||||
Ok(Err(_)) => String::new(),
|
||||
Err(_elapsed) => String::new(), // 超时:child 被 kill_on_drop 终止
|
||||
}
|
||||
}
|
||||
|
||||
/// git status --porcelain 解析为结构化文件列表。
|
||||
@@ -1767,7 +1772,16 @@ fn register_file_tools(
|
||||
use tokio::fs::File;
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut file = File::open(path).await
|
||||
.map_err(|e| anyhow::anyhow!("无法访问文件 {}: {}", path, e))?;
|
||||
.map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
anyhow::anyhow!(
|
||||
"无法访问文件 {}: 路径不存在。建议用 list_directory 先查看目录下的实际文件列表",
|
||||
path
|
||||
)
|
||||
} else {
|
||||
anyhow::anyhow!("无法访问文件 {}: {}", path, e)
|
||||
}
|
||||
})?;
|
||||
let metadata = file.metadata().await
|
||||
.map_err(|e| anyhow::anyhow!("读取元数据失败 {}: {}", path, e))?;
|
||||
if metadata.len() > 1_048_576 {
|
||||
@@ -2036,7 +2050,7 @@ fn register_file_tools(
|
||||
})
|
||||
};
|
||||
registry.register(
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。小改动(≤5 行)自动放行不阻塞,大改动需人工审批。注意:若文件已被外部修改,请先重新 read_file 获取最新内容",
|
||||
"patch_file", "局部更新文件内容(三模式互斥)。模式1 old_text:精确匹配原文替换(含空格/缩进,CAS 语义)。模式2 replace_lines:按行号区间 {start,end}(1-based 含首尾)替换,不需原文,配 expected_hash 防行号漂移。模式3 anchor:按首尾子串锚点 {start,end}(大小写敏感,子串匹配)定位行号区间替换,不需完整原文。三模式均需 path+new_text,expected_hash 可选通用。注意:若文件已被外部修改,请先重新 read_file 获取最新内容。所有 patch_file 操作自动放行不阻塞 AI 工作流,写入会落 audit 表可追溯。",
|
||||
patch_file_schema,
|
||||
RiskLevel::Medium,
|
||||
{ let allowed_dirs = allowed_dirs.clone(); Box::new(move |args: serde_json::Value| {
|
||||
@@ -2510,11 +2524,25 @@ fn register_file_tools(
|
||||
let output_mode = args.get("output_mode").and_then(|v| v.as_str()).unwrap_or("content");
|
||||
let max_results = args.get("max_results").and_then(|v| v.as_u64()).unwrap_or(50).clamp(1, 200) as usize;
|
||||
|
||||
// 编译正则:case_insensitive 开 i flag;失败上抛明确错误(非法正则不是业务错,LLM 据此修参)
|
||||
// 编译正则:case_insensitive 开 i flag;失败尝试自动修复常见问题(未分组 | 用 (?:...) 包裹)
|
||||
let mut re_builder = regex::RegexBuilder::new(pattern);
|
||||
re_builder.case_insensitive(case_insensitive);
|
||||
let re = re_builder.build()
|
||||
.map_err(|e| anyhow::anyhow!("正则编译失败「{}」: {}", pattern, e))?;
|
||||
let (re, _auto_fixed) = match re_builder.build() {
|
||||
Ok(r) => (r, false),
|
||||
Err(e) => {
|
||||
// LLM 常传 `a|b|c` 未分组模式(如 `<template|<div`),尝试 (?:...) 包裹后重试
|
||||
let wrapped = format!("(?:{})", pattern);
|
||||
let mut fix_builder = regex::RegexBuilder::new(&wrapped);
|
||||
fix_builder.case_insensitive(case_insensitive);
|
||||
match fix_builder.build() {
|
||||
Ok(r) => (r, true),
|
||||
Err(_) => anyhow::bail!(
|
||||
"正则编译失败「{}」: {}。提示:多选模式请用 (?:a|b|c) 分组",
|
||||
pattern, e
|
||||
),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// glob 过滤器:编译为 regex 单段匹配(* → [^/]*, ? → [^/], 字面其他字符 escape)。
|
||||
// 仅匹配文件名单段(不含 /),对齐 Claude Code grep glob 语义。
|
||||
@@ -2680,7 +2708,8 @@ fn register_file_tools(
|
||||
|
||||
let request = ShellRequest {
|
||||
command: command.clone(),
|
||||
working_dir: Some(working_dir.clone()),
|
||||
// 空字符串 → None(空路径是非法 current_dir,Windows 报 os error 123)
|
||||
working_dir: if working_dir.is_empty() { None } else { Some(working_dir.clone()) },
|
||||
env: HashMap::new(),
|
||||
timeout_secs: Some(timeout_secs),
|
||||
shell_type: Default::default(),
|
||||
@@ -3340,18 +3369,20 @@ mod tests {
|
||||
// register_git_tools 3 个)
|
||||
// Git 写工具(2026-06-29): data 层 31→34(新增 git_commit/git_branch/git_merge,
|
||||
// register_git_tools 3→6)
|
||||
// BUG-2026-07-07: 修正 baseline 计数(原写死 41 与实际 48 不符,HEAD 上即失败)。
|
||||
// 48 = 34 data + 13 file + 1 http。data 34 含 6 知识图谱 + 2 基础设施 + 1 module + 6 git。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
41,
|
||||
"工具总数应为 41(27 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
48,
|
||||
"工具总数应为 48(34 data + 13 file + 1 http),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
// data 层 27 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置
|
||||
// data 层 34 个(持 db):CRUD/状态机/工作流/知识图谱任务关联 + 项目事件流 + 基础设施配置 + git 工具
|
||||
// file 层 13 个(不持 db):命令/读/列/写/改/元/追加/删/移/搜/grep/环境探测/符号解析
|
||||
// http 层 1 个(不持 db):http_request
|
||||
let mut expected: Vec<&str> = vec![
|
||||
// ── data 层 (27) ──
|
||||
// ── data 层 (34) ──
|
||||
"list_projects", "list_tasks", "list_ideas",
|
||||
"update_project", "create_project", "bind_directory",
|
||||
"create_task", "update_task", "advance_task",
|
||||
@@ -4080,7 +4111,7 @@ mod tests {
|
||||
id: "proj-svc-1".to_string(),
|
||||
name: "proj-svc-1".to_string(),
|
||||
description: String::new(),
|
||||
status: "planning".to_string(),
|
||||
status: ProjectStatus::Planning,
|
||||
idea_id: None,
|
||||
path: None,
|
||||
stack: None,
|
||||
|
||||
223
src-tauri/src/commands/ci_status.rs
Normal file
223
src-tauri/src/commands/ci_status.rs
Normal file
@@ -0,0 +1,223 @@
|
||||
//! CI 检查状态查询命令 — 读取 Gitea commit 检查状态(对标 GitHub commit status API)
|
||||
//!
|
||||
//! 调用 Gitea `{api_base}/api/v1/repos/{owner}/{repo}/commits/{sha}/statuses` 拉取该
|
||||
//! commit 的 CI 检查项列表,转成 CheckStatus 返回前端(供 commit 详情面板展示 ✓/✗/⏳)。
|
||||
//!
|
||||
//! 设计取舍:
|
||||
//! - **容错优先**:网络错误 / URL 解析失败 / Gitea 异常 → 返回空 vec,**不阻断工作流**
|
||||
//! (CI 状态属于辅助信息,失败不应阻塞 Git 浏览等主功能)。
|
||||
//! - URL 解析:从 repo_url(如 `https://gitea.1216.top/lxy/DevFlow`)拆出
|
||||
//! `{scheme}://{host}` 作 api_base,路径段尾两段作 owner/repo。
|
||||
//! - 字段映射:Gitea status.state 取值 `pending/success/failure/error/...`,
|
||||
//! error/warning 归一为 failure 展示;`context` 作检查名,`target_url` 作详情链接。
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
|
||||
|
||||
/// 前端展示用的 CI 检查状态(归一化后)
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct CheckStatus {
|
||||
/// 检查名(如 "ci/cargo-test"、"continuous-integration/drone/push")
|
||||
pub name: String,
|
||||
/// 归一状态:"pending" | "success" | "failure"
|
||||
pub status: String,
|
||||
/// 详情链接(Gitea target_url,可能为空)
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
/// Gitea API 原始返回项(仅取关心的字段,其余忽略)
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct GiteaStatus {
|
||||
/// 检查上下文名(Gitea 字段名 context)
|
||||
context: Option<String>,
|
||||
/// 原始状态:pending / success / failure / error / warning / ...
|
||||
state: Option<String>,
|
||||
/// 详情链接
|
||||
target_url: Option<String>,
|
||||
}
|
||||
|
||||
/// 从 repo_url 解析 (api_base, owner, repo)。
|
||||
///
|
||||
/// 例:`https://gitea.1216.top/lxy/DevFlow` →
|
||||
/// (`https://gitea.1216.top`, `lxy`, `DevFlow`)
|
||||
///
|
||||
/// `.git` 后缀容忍;路径段不足两段返回 None。
|
||||
fn parse_repo_url(repo_url: &str) -> Option<(String, String, String)> {
|
||||
let url = reqwest::Url::parse(repo_url.trim()).ok()?;
|
||||
if !matches!(url.scheme(), "http" | "https") {
|
||||
return None;
|
||||
}
|
||||
let host = url.host_str()?;
|
||||
// BUG-2026-07-07: port_or_known_default() 对 https 返 Some(443)、http 返 Some(80),
|
||||
// 致 authority 总是带默认端口(:443/:80),base URL 形如 "https://host:443" 而非规范
|
||||
// "https://host"。下游 Gitea API 调用虽大多容忍,但测试断言与可读性受影响。
|
||||
// 修复:仅当端口非 scheme 默认端口时才拼入 authority(显式非默认端口才保留)。
|
||||
let port = url.port(); // 仅取显式端口(未在 URL 写出的不返)
|
||||
let authority = match port {
|
||||
Some(p) => format!("{}:{}", host, p),
|
||||
None => host.to_string(),
|
||||
};
|
||||
let api_base = format!("{}://{}", url.scheme(), authority);
|
||||
|
||||
// 路径段:去空 / 去 .git 后缀 → 取末两段 owner / repo
|
||||
// (url::Url::path_segments() 返 Option<Split>,Split 不实现 Default,需手动兜底)
|
||||
let path_iter = url.path_segments().unwrap_or("".split('/'));
|
||||
let mut segments: Vec<String> = path_iter
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.trim_end_matches(".git").to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
if segments.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let repo = segments.pop()?;
|
||||
let owner = segments.pop()?;
|
||||
Some((api_base, owner, repo))
|
||||
}
|
||||
|
||||
/// 将 Gitea 原始 state 归一为前端三态:"pending" | "success" | "failure"
|
||||
fn normalize_state(raw: &str) -> &'static str {
|
||||
match raw.to_lowercase().as_str() {
|
||||
"success" => "success",
|
||||
"pending" | "" => "pending",
|
||||
// failure / error / warning / blocked / 其他均视为失败展示
|
||||
_ => "failure",
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询指定 commit 的 CI 检查状态列表。
|
||||
///
|
||||
/// 任一环节失败(网络/解析/Gitea 异常)均返回空 vec,日志告警但不返回错误,
|
||||
/// 避免阻断前端 Git 浏览等主流程。
|
||||
#[tauri::command]
|
||||
pub async fn get_commit_status(
|
||||
_state: State<'_, AppState>,
|
||||
repo_url: String,
|
||||
commit_sha: String,
|
||||
) -> Result<Vec<CheckStatus>, String> {
|
||||
let (api_base, owner, repo) = match parse_repo_url(&repo_url) {
|
||||
Some(parsed) => parsed,
|
||||
None => {
|
||||
tracing::warn!(
|
||||
repo_url = %repo_url,
|
||||
"[ci_status] repo_url 解析失败,返回空列表"
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let url = format!(
|
||||
"{}/api/v1/repos/{}/{}/commits/{}/statuses",
|
||||
api_base, owner, repo, commit_sha
|
||||
);
|
||||
|
||||
tracing::info!(url = %url, "[ci_status] 查询 Gitea commit statuses");
|
||||
|
||||
let client = match reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 构建 reqwest client 失败");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match client.get(&url).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 请求 Gitea 失败(网络不可达或未鉴权)");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
if !resp.status().is_success() {
|
||||
tracing::warn!(
|
||||
status = %resp.status(),
|
||||
"[ci_status] Gitea 响应非 2xx,返回空列表"
|
||||
);
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let raw: Vec<GiteaStatus> = match resp.json().await {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "[ci_status] 解析 Gitea 响应 JSON 失败");
|
||||
return Ok(vec![]);
|
||||
}
|
||||
};
|
||||
|
||||
let checks = raw
|
||||
.into_iter()
|
||||
.map(|s| CheckStatus {
|
||||
name: s.context.unwrap_or_else(|| "unknown".to_string()),
|
||||
status: normalize_state(s.state.as_deref().unwrap_or("")).to_string(),
|
||||
url: s.target_url.filter(|u| !u.is_empty()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(checks)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// ── parse_repo_url ──
|
||||
|
||||
#[test]
|
||||
fn parse_standard_gitea_url() {
|
||||
let (base, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow").unwrap();
|
||||
assert_eq!(base, "https://gitea.1216.top");
|
||||
assert_eq!(owner, "lxy");
|
||||
assert_eq!(repo, "DevFlow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_strips_git_suffix() {
|
||||
let (_, owner, repo) = parse_repo_url("https://gitea.1216.top/lxy/DevFlow.git").unwrap();
|
||||
assert_eq!(owner, "lxy");
|
||||
assert_eq!(repo, "DevFlow");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_port() {
|
||||
let (base, _, _) = parse_repo_url("http://gitea.local:3000/team/proj").unwrap();
|
||||
assert_eq!(base, "http://gitea.local:3000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_non_http() {
|
||||
assert!(parse_repo_url("ftp://gitea/x/y").is_none());
|
||||
assert!(parse_repo_url("file:///x/y").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_rejects_short_path() {
|
||||
assert!(parse_repo_url("https://gitea.1216.top/").is_none());
|
||||
assert!(parse_repo_url("https://gitea.1216.top/onlyone").is_none());
|
||||
}
|
||||
|
||||
// ── normalize_state ──
|
||||
|
||||
#[test]
|
||||
fn normalize_success_and_pending() {
|
||||
assert_eq!(normalize_state("success"), "success");
|
||||
assert_eq!(normalize_state("pending"), "pending");
|
||||
assert_eq!(normalize_state(""), "pending");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_failure_variants() {
|
||||
assert_eq!(normalize_state("failure"), "failure");
|
||||
assert_eq!(normalize_state("error"), "failure");
|
||||
assert_eq!(normalize_state("warning"), "failure");
|
||||
assert_eq!(normalize_state("blocked"), "failure");
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! 所有 command 统一返回 `Result<T, String>`,错误经 `to_string()` 传给前端。
|
||||
|
||||
pub mod ai;
|
||||
pub mod ci_status;
|
||||
pub mod events;
|
||||
pub mod idea;
|
||||
pub mod knowledge;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user