修复: 上下文记忆与工作流注入生产侧空壳接通

WorkingContext 写入链落地(用户轮目标钉扎+压缩后 JSON 卡片回填重锚定,含 6 单测);
工作流 DAG 摘要注入生产侧接通(AI 路径启动写入+终态回写,审批调用点透传会话)。
附知识库与记忆走查诊断(「笨/丢历史」根因:消息压缩视图丢失+知识检索四重收口),
todo 登记修复分档(含随批补登 09-28 交互走查索引条目)。
This commit is contained in:
lxy
2026-09-29 13:28:56 +08:00
parent f57e25a277
commit dfa38f6259
9 changed files with 333 additions and 27 deletions
+133 -2
View File
@@ -724,11 +724,14 @@ impl WorkingContextVersion {
} }
} }
/// 压缩后重置:将所有字段标记为本轮变更 /// 压缩后重锚定:将所有脏标记置为 current_turn,未来 recent_turns 轮内各段必注入一次。
pub fn reset_all(&mut self, _summary: &str, current_turn: u32) { /// 不解析 summary 内容,仅为签名兼容保留参数。
pub fn reset_all(&mut self, summary: &str, current_turn: u32) {
let _ = summary;
self.goals_changed_at = current_turn; self.goals_changed_at = current_turn;
self.decisions_changed_at = current_turn; self.decisions_changed_at = current_turn;
self.unresolved_changed_at = current_turn; self.unresolved_changed_at = current_turn;
self.step_changed_at = current_turn;
self.current_turn = current_turn; self.current_turn = current_turn;
} }
} }
@@ -783,6 +786,64 @@ impl WorkingContext {
parts.join("\n") parts.join("\n")
} }
/// 新用户轮:上一批 Active 目标落 Completed,本条用户请求截断后记为唯一 Active 目标;
/// current_turn 自增。自动目标与 G1 pinned_goals(用户手动钉,另链路)互不覆盖。
/// 已 Completed 的自动目标无读取方,直接丢弃(只保留 Active),防长会话无限膨胀。
pub fn begin_turn(&mut self, user_request: &str, max_chars: usize) {
self.version.current_turn += 1;
// 自动目标单活跃语义:新请求即替换,旧目标不保留(Cancelled 同理无读取方,一并丢弃)
self.goals.clear();
let trimmed = user_request.trim();
if !trimmed.is_empty() {
self.goals.push(GoalItem {
id: format!("t{}", self.version.current_turn),
text: truncate_chars(trimmed, max_chars),
status: GoalStatus::Active,
});
}
self.version.goals_changed_at = self.version.current_turn;
}
/// 压缩后重锚定:历史消息已被摘要替换,从 T3 JSON 卡片(字段 topics/decisions/key_files,
/// decisions 元素形如 {"summary": "..."})恢复决策至上限 5 条(每条 decision 文本截 120 字符,
/// question/reason 留空);随后调 version.reset_all(json_card, 自增后的 current_turn)。
/// Active 目标保留不丢(压缩不应丢目标)。current_turn 自增 1。
pub fn sync_after_compress(&mut self, json_card: &str) {
self.version.current_turn += 1;
if let Ok(card) = serde_json::from_str::<serde_json::Value>(json_card) {
let items: Vec<DecisionItem> = card
.get("decisions")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|d| d.get("summary").and_then(|s| s.as_str()))
.filter(|s| !s.trim().is_empty())
.take(5)
.map(|s| DecisionItem {
question: String::new(),
decision: truncate_chars(s, 120),
reason: String::new(),
})
.collect()
})
.unwrap_or_default();
// 仅当解析出非空决策才覆盖;脏标记由 reset_all 统一置位
if !items.is_empty() {
self.decisions = items;
}
}
self.version.reset_all(json_card, self.version.current_turn);
}
}
/// 按 chars 计数截断文本,越界补省略号(begin_turn / sync_after_compress 共用)
fn truncate_chars(s: &str, max: usize) -> String {
if s.chars().count() <= max {
return s.to_string();
}
let head: String = s.chars().take(max).collect();
format!("{}…", head)
} }
@@ -1416,4 +1477,74 @@ mod tests {
assert_eq!(result, content, "read_file 纯文本应豁免压缩"); assert_eq!(result, content, "read_file 纯文本应豁免压缩");
assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩"); assert!(!result.contains("输出已压缩"), "read_file 纯文本不应压缩");
} }
// ── WorkingContext 写入侧(begin_turn / sync_after_compress)──
#[test]
fn working_context_begin_turn_replaces_previous_goal() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("第一个请求", 100);
ctx.begin_turn("第二个请求", 100);
assert_eq!(ctx.version.current_turn, 2, "current_turn 应自增两次");
assert_eq!(ctx.goals.len(), 1, "替换语义:仅剩本条 Active 目标");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
assert_eq!(ctx.goals[0].text, "第二个请求");
assert_eq!(ctx.goals[0].id, "t2");
}
#[test]
fn working_context_begin_turn_empty_request_only_bumps_turn() {
let mut ctx = WorkingContext::new();
ctx.begin_turn(" ", 100);
assert_eq!(ctx.version.current_turn, 1, "空文本仍应 bump turn");
assert!(ctx.goals.is_empty(), "空文本不 push 目标");
assert_eq!(ctx.version.goals_changed_at, 1);
}
#[test]
fn working_context_begin_turn_truncates_long_request() {
let mut ctx = WorkingContext::new();
let long = "长".repeat(50);
ctx.begin_turn(&long, 10);
assert_eq!(ctx.goals[0].text.chars().count(), 11, "10 字符 + 省略号");
assert!(ctx.goals[0].text.ends_with('…'));
}
#[test]
fn working_context_sync_after_compress_restores_decisions() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
let card = r#"{"topics":["a"],"decisions":[{"summary":"采用块级 memo 渲染"},{"summary":"降级兜底"}],"key_files":[]}"#;
ctx.sync_after_compress(card);
assert_eq!(ctx.decisions.len(), 2);
assert_eq!(ctx.decisions[0].decision, "采用块级 memo 渲染");
assert!(ctx.decisions[0].question.is_empty(), "question 留空");
assert!(ctx.decisions[0].reason.is_empty(), "reason 留空");
assert_eq!(ctx.goals.len(), 1, "Active 目标保留不丢");
assert_eq!(ctx.goals[0].status, GoalStatus::Active);
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "应含目标段: {}", out);
assert!(out.contains("近期决策"), "应含近期决策段: {}", out);
}
#[test]
fn working_context_sync_after_compress_invalid_json_still_reanchors() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("压缩前目标", 100);
ctx.sync_after_compress("不是 JSON");
assert!(ctx.decisions.is_empty(), "非法 JSON 不恢复决策且不 panic");
let out = ctx.build_injection(3);
assert!(out.contains("目标"), "重锚定后仍应注入目标段: {}", out);
}
#[test]
fn working_context_injection_decays_after_window() {
let mut ctx = WorkingContext::new();
ctx.begin_turn("会过期的目标", 100);
assert!(ctx.build_injection(3).contains("会过期的目标"), "窗口内应注入");
// 模拟多轮未再变更:直接推进 current_turn,脏窗口(3)过期后不再注入
ctx.version.current_turn = 10;
let out = ctx.build_injection(3);
assert!(out.is_empty(), "过期后所有段均不注入, 实际: {}", out);
}
} }
@@ -1,6 +1,6 @@
# 上下文管理演进 — 执行计划 # 上下文管理演进 — 执行计划
> 关联: `上下文管理演进-任务技术设计-2026-07-20.md` | 状态: 待执行 > 关联: `上下文管理演进-任务技术设计-2026-07-20.md` | 状态: T1-T3 已落地;T4/T5 生产侧 2026-09-29 补齐落地(消费侧此前已接,本次补 WorkingContext 写入链+工作流摘要产出,见 `05-代码审查/aichat知识库与记忆走查-2026-09-29.md` 第四章)
--- ---
@@ -0,0 +1,80 @@
# aichat 知识库与记忆能力走查 — 用户反馈「笨/丢历史会话」根因诊断
> 日期: 2026-09-29 | 触发: 用户反馈「明显感觉丢历史会话特别严重」「感觉很笨」
> 方法: 双代理只读走查(知识库链路/记忆四层) + 运行时 DB 实证(devflow.db 副本只读查询)
> 结论处置: 会话内上下文生产侧已修复(同日实施,见文末);跨会话召回/知识检索收口登记待办
---
## 一、DB 实证(核心证据)
数据源: `%APPDATA%/top.1216.devflow/devflow.db` 副本(104MB,82 会话/10647 消息)。
| 指标 | 数值 | 含义 |
|------|------|------|
| compressed 消息占比 | 4758/10647 (44.7%) | 压缩是常态而非例外 |
| user 消息被压缩 | 363/892 (41%) | 四成用户请求模型看不到 |
| 最大会话 5fea6024 | 828 条消息,模型可见仅 44 条 (5%) | 26 条用户消息 **0 条可见** |
| user 可见率最差大会话 Top4 | 0b0a46e5: 0/6 · 5c3bc8a0: 0/8 · 5fea6024: 0/26 · f64dee94: 0/39 | 「我说过的话他全忘了」属实 |
| compressed 原文保留 | 363 条全部非空 | **存储没丢,丢的是发送视图** |
| 四段式压缩摘要落库 | 仅 1 条 | 大量压缩走关键词兜底/旧格式,摘要质量无保障 |
**定性**: 丢历史会话 = 机制性视图丢失(DB 单向 `compressed` 标记永不回 LLM 上下文),不是存储损坏。压缩触发阈值 0.6×可用预算,长会话大半历史必然出局;摘要 ≤1024 token 替代全部被压缩内容,且 user 原话不保证进摘要。
## 二、知识库链路: 四重收口(管道铺好,水龙头拧死)
代码事实(走查证据,均为当前 HEAD 实核):
1. **整句 LIKE 检索(最致命)**: 检索 query=末条用户消息**全文**,`idea_repo.rs:640-661` 用 `%整句%` 做 LIKE——知识标题是短语,整句子串匹配命中率≈0。自动注入即使有已发布知识也几乎空转。
2. **candidate↔published 断链**: 自动提炼(`knowledge_inject.rs:441+`)与 MCP `insert_knowledge` 产物全是 `status='candidate'`;检索只认 `'published'`(`idea_repo.rs:651`)。不手动去 /knowledge 页发布,沉淀的知识(现库 962 条!)永不进对话。`reuse_count` 排序形同虚设。
3. **语义检索默认关**: `vector_enabled=false`(`knowledge_config.rs:58`),需手配 embedding provider;默认纯 LIKE,中文同义表达必漏。
4. **LLM 零知识工具**: `tool_registry.rs` 73 个工具无任何 knowledge 注册;9eb2995 的 search/insert_knowledge 挂在 df-mcp(127.0.0.1:18765)只服务**外部** MCP 客户端,ai-chat 自己的 agentic loop 不接。「记住这个」只会落进对话文本,会话结束即散。
次要: 注入剂量 top-3×500 字符;`source_project` 存而不用于检索过滤;提炼只看最后 6 条消息且每会话仅 1 次。
**知识维度总评**: 管道全部 wired(auto_inject 默认开),但四重收口使普通对话实际获得知识的概率趋近于零。
## 三、记忆能力分层盘点
| 层 | 现状 | 判定 |
|----|------|------|
| L1 轮内 | 全量历史进请求,预算≈102k token,namespace 大结果引用化 | ✅ wired |
| L2 跨轮 | 两道丢失闸门: ①单向压缩(保护最近 6 条,其余换 ≤1024 token 摘要,不可逆);②超预算视图裁剪**无摘要补偿**(只保最近 6 条) | ⚠️ 半残 |
| L3 跨会话 | **零召回**: 新会话全空,无全局摘要/项目记忆/跨会话检索,完全隔离 | ❌ 空壳 |
| L4 跨重启 | 恢复消息(compressed 仍是压缩态)+pinned_goals;**不恢复**: working_context/namespace_store(重启清空)/agent_language/session_trust | ⚠️ 半残 |
空壳机制(有代码无调用方/断链):
- **MemoryAdapter**(`df-ai/src/memory_adapter.rs`): 语义记忆 trait,全仓零调用方,注释中的 VectorMemoryAdapter 不存在。
- **Persona 人设**(5 内置人设+prompt 模板): 唯一实例化点在两个默认关闭的实验门内,不进正常对话链路,无持久化。
- **跨会话记忆**: 无任何机制——这是「丢历史会话」主观感受的最大来源(与 DB 实证互为表里: 同会话压缩出局 + 跨会话零召回 + 重启再丢一层)。
- **DB 落库截断**: >8192 字符的 user/assistant 文本落库截为 前3072+后3072+省略标记(`conversation.rs:101-119`)——重启后连原文都只剩骨架。
## 四、本次已修(同日实施批,已过编译门禁)
「上下文管理演进」07-20 设计中 T4/T5 的**生产侧空壳**补齐(消费侧早已落地但无数据源):
- **T5 WorkingContext 写方法**(df-ai/context_helpers.rs): `begin_turn`(新用户轮目标钉扎)+`sync_after_compress`(压缩后从 T3 JSON 卡片回填决策+重锚定)+`reset_all` 实现,6 个新单测全绿。
- **T5 接线**: `chat.rs:651/1978` 用户消息入库点 begin_turn;`context_lifecycle.rs:278` 压缩成功分支 sync_after_compress(TODO 清零)。效果: 压缩不再裸丢目标/决策,下轮 system prompt 重锚定。
- **T4 生产侧**: `workflow.rs::run_workflow_inner` 增加 conv_id 参数,AI 路径启动写 `workflow_id`+`workflow_dag_summary`,终态回写完成/失败摘要;`chat.rs:896/1278` 审批调用点透传。休眠链路闭环。
门禁: `cargo check --workspace` 0 错误;`cargo test -p df-ai` 494 过/4 挂(4 挂均在 anthropic_compat/model_fetch_helpers,**本批零触碰的存量失败**,见待办)。
**边界说明**: 本批只解「会话内压缩后目标/决策丢失」一块。跨会话零召回、知识四重收口、重启失忆均未动,登记待办。
## 五、待办分档(修复方向,未实施)
**P0(直接治「笨」)**:
1. 知识检索去整句 LIKE: 分词/抽关键词后多条件 OR,或默认开语义检索(补 embedding 配置引导)。改动点 `idea_repo.rs:640-661` + `knowledge_inject.rs:364-384`。
2. candidate 断链打通: 自动提炼产物到阈值 confidence/reuse 自动晋升 published(或检索纳入高置信 candidate),人工审核保留为可选。
3. 视图裁剪补摘要: 超预算淘汰单元被丢弃时生成滚动补偿摘要(现在无声丢失)。
**P1**:
4. LLM 知识工具: tool_registry 注册 search_knowledge/insert_knowledge(复用 df-mcp 实现),让 agent 能按需查/存。
5. 跨会话召回最小闭环: 项目级记忆条目(知识表已有 source_project 字段)+会话结束时提炼进知识库(现有 auto_extract 扩为跨会话通道)。
6. 存量测试修复: `anthropic_compat::anthropic_full_stream_accumulates_usage`(assert usage.is_none() 失败)+`model_fetch_helpers::non_chat_sensenova_u1_image_filtered` 等 4 个,疑似 6bafdcd 流式 usage 语义变更后未同步测试。
**P2**:
7. DB 截断放宽/取消(user 原话平均 41 字,3072 截断对 user 无必要,主要风险在工具结果;可分 role 定阈值)。
8. namespace 持久化或重建(DB 原文在,重启可懒重建,免「已超出内存上限」占位)。
9. 兜底压缩路径接 sync_after_compress("")(当前取舍: 不接,防丢决策态;待版本计数漂移观察后复议)。
+44
View File
@@ -10,6 +10,50 @@
> **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。 > **2026-08-05 编号碰撞登记**: `F-260619-03` 被两个特性复用(消息拆分存储=文档本体 + AI工具路径授权=复用)。子代理审查确认:改 18 处编号无实际价值有风险,**暂不改代码**,后续新增注释编号统一走日期+序号体系、避免复用已有编号。路径授权若后续单独立项再独立编号。
> **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。 > **2026-08-15 编号碰撞登记**: `REFACTOR-260619-04` 被复用(父③③.2 审批状态机拆分=待办 vs ToolCard 拆分=已完成)。后续引用审批状态机拆分时用「审批状态机拆分」描述,不再沿用该编号。
### 💡 2026-09-29 aichat 可用性与交互成本研究(三动线走查+异常循环截图实证,39 项新发现)
> 背景:用户反馈交互刷屏已修(工具卡默认折叠)后,按「使用体验/使用成本」深入走查三动线;截图实证「写完就删」循环跑到轮次耗尽——G2 熔断签名机制对写删交替结构性漏网(唯一率 0.83≫0.4,单签名最高 2 次<3,全程连警示都不触发);与 9-28 走查 17 项合并排序。
> 详情:[05-代码审查/aichat可用性与交互成本研究-2026-09-29.md](./05-代码审查/aichat可用性与交互成本研究-2026-09-29.md)
- [ ] 第一批 P1:I1 停止能力窄窗(工具执行期按钮消失,失控不可干预)+I2 编辑入口死链(=9-28#1)+G2 破坏性回环熔断(写后 N 步内删同路径计数,误杀率极低)+V2 审批黑洞(非活跃会话审批事件 isCurrent 守卫整体丢弃)+V1/V3 后台信号缺失(等审批伪装 generating+完成零通知)
- [ ] 第二批 P2:V8 草稿 per-conv 防串台/R3 长回复带量提示+R4 代码块复制/I5 错误分类兜底(429 占 55%)/V4 浮窗高风险二次确认/V7 排队时钟冻结/R5+V9 消息内容检索 等 16 项,排序详报告第五章
- [ ] 第三批 P3 攒批:I8-I13+R8-R13+V10-V12+9-28 #9-#17;其中 9-28#17 图片默认上传外部域名涉开源边界,单独决策
- [x] ✅ 同日已修:工具卡默认折叠刷屏+read_file/list_directory 单行摘要补齐+分组折叠幂等补齐(工作区待提交)
### 💡 2026-09-29 aichat 知识库与记忆修复(「笨/丢历史」根因诊断,同日已修会话内侧)
> 背景:用户反馈「明显感觉丢历史会话特别严重」「感觉很笨」。DB 实证:44.7% 消息 compressed,最大会话 828 条仅 5% 可见(26 条用户消息 0 条可见),存储原文都在、丢的是发送视图;知识库四重收口(整句 LIKE 命中≈0/candidate 不参与检索/向量默认关/LLM 零知识工具,962 条知识存量近乎零生效);跨会话零召回。
> 详情:[05-代码审查/aichat知识库与记忆走查-2026-09-29.md](./05-代码审查/aichat知识库与记忆走查-2026-09-29.md)
> ✅ 同日已修:上下文管理演进 T4/T5 生产侧空壳补齐(WorkingContext begin_turn/sync_after_compress 写入链 + 工作流 DAG 摘要注入链),编译门禁过,详见诊断文档第四章。
- [ ] P0 知识检索去整句 LIKE(分词/关键词多条件 OR 或默认开语义检索,idea_repo.rs:640-661 + knowledge_inject.rs:364-384)
- [ ] P0 candidate→published 断链打通(自动提炼产物按置信/复用自动晋升,人工审核改可选)
- [ ] P0 视图裁剪补滚动摘要(超预算淘汰单元无声丢弃,无补偿,context/mod.rs:206-253)
- [ ] P1 LLM 知识工具注册(tool_registry 复用 df-mcp search/insert_knowledge,agent 可按需查/存)
- [ ] P1 跨会话召回最小闭环(项目级记忆+会话结束提炼进知识库,source_project 已有字段未用)
- [ ] P1 存量测试修复 4 个(anthropic_compat usage 断言/model_fetch_helpers 过滤,疑似 6bafdcd 后未同步)
- [ ] P2 DB 落库截断分 role 定阈值(user 原话均 41 字被 3072 截断无必要)/namespace 重启懒重建/兜底压缩路径 sync_after_compress 复议
### 💡 2026-09-28 devflow 集成 u-claw 能力(可行性已定,待实施)
> 链路:devflow → u-relay → u-claw。通路已生产验证(u-ppt 试点 2026-08-29),u-relay 零改动,唯一工作面 devflow 侧接入。
> 详情:[02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md](./02-架构设计/专项设计/devflow集成u-claw-可行性研究-2026-09-28.md)
- [ ] devflow/u-claw 两侧细勘察补全(接入点 trait/WS 渠道能力边界/多轮会话锚定)
- [ ] 接入实施:WS 客户端(移植 u-ppt relay.ts 或并入 df-tunnel)+ aichat u-claw 渠道 + 配置页(默认关,对齐 cfg.WS==nil 模式)
### 💡 2026-09-28 aichat 交互走查清尾(启动「日常主入口」标准,1P1+7P2+9P3)
> ⚠️ 2026-09-29:17 项经核验全部未修,已并入《aichat 可用性与交互成本研究-2026-09-29》合并排序,修复排序以新报告第五章为准(本条目保留作原始详单指针)。
> 背景:devflow 启动定位=日常 AI 交互调度中枢,按日常主入口标准走查。新发现集中在 God 组件拆分丢功能的链路断点+多会话并发收口残留缝隙。
> 详情:[05-代码审查/aichat交互走查-2026-09-28.md](./05-代码审查/aichat交互走查-2026-09-28.md)
- [ ] 第一批(日常直接受伤):#1 编辑末条消息入口丢失回归(2c2b2d7 提取 MessageItem 时按钮未迁移)+#4/#5 排队消息被静默清(AiError/审批失败/看门狗超时三路径,含两处全局清队列残留)+#6 后台会话错误气泡推错视图+#7 429 无分类且重试照显
- [ ] 第二批(多会话体验):#8 审批徽标跨会话误导+点击 no-op+#3 en 重试气泡残留+#2 rejectedHint i18n key 多前缀
- [ ] 第三批随批清理:P3 九项(#9-#17,详见报告)+B-260619-04③销账(实际已落地)
- [x] 第四批(用户实测反馈 2026-09-29):批量读取文件时工具卡逐卡渲染刷屏 ✅已修——分组默认收起机制 6 月已有但为一次性初始化,流式场景首卡到达即触发完毕,组从 1 卡长到 N 卡不再折叠致 N 张单行卡全展开;改响应式补齐(组达 2 卡阈值即收起)+用户手动操作/审批展开记 touched 不被自动收回
### 💡 2026-08-04 父子任务支持(✅ 已完成) ### 💡 2026-08-04 父子任务支持(✅ 已完成)
> 目标:完成父任务/子任务的完整支持(数据→后端→前端),UI/UX 重点设计。 > 目标:完成父任务/子任务的完整支持(数据→后端→前端),UI/UX 重点设计。
@@ -272,10 +272,10 @@ pub(super) async fn maybe_auto_compress(
} }
let conv = session.conv(&conv_id); let conv = session.conv(&conv_id);
let _compressed = conv.messages.compress_old_messages(protect_start); let _compressed = conv.messages.compress_old_messages(protect_start);
// T3: 插入 NL 摘要(向前兼容),JSON 卡片可供后续 WorkingContext(T5)使用 // NL 摘要插首位 + JSON 卡片回填 WorkingContext,决策随压缩重锚定
conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary)); conv.messages.insert_at(0, ChatMessage::system(&summary.nl_summary));
conv.messages.set_compressing(false); conv.messages.set_compressing(false);
// TODO(T5): 如有 WorkingContext,调用 version.reset_all(summary.json_card, turn) conv.working_context.sync_after_compress(&summary.json_card);
} }
tracing::info!( tracing::info!(
conv_id = %conv_id, conv_id = %conv_id,
@@ -1,19 +1,14 @@
//! 工作流 DAG 上下文注入 — 当会话关联工作流时,将活跃路径注入 system prompt //! 工作流 DAG 上下文注入 — 当会话关联工作流时,将活跃路径注入 system prompt
//! //!
//! T4 实现。提供两个函数: //! T4 实现,完整链路(生产侧已接通):
//! - `build_dag_summary`: 从 DagDef 构建可读的 DAG 摘要文本 //! - 生产:`run_workflow_inner`(workflow.rs)AI 路径(conv_id Some)启动时调
//! - `extract_active_path`: 从完整 DAG 中提取当前活跃路径(简化) //! `build_dag_summary` 产出摘要写入 `PerConvState.workflow_dag_summary`
//! (`workflow_id` 存本次执行 ID,非模板 ID);执行闭包终态(completed/failed)
//! 回写摘要并追加「状态: 已完成/已失败」行。
//! - 消费:`agentic/mod.rs` 的 `build_runtime_prompt` 每轮读该字段注入 system prompt。
//! //!
//! 调用方(前端/workflow 引擎)在启动工作流时将摘要存入 //! `build_dag_summary` 已有生产调用方;`extract_active_path` 仍无生产调用方
//! `PerConvState.workflow_dag_summary`,agentic loop 自动注入 system prompt。 //! (仅单测引用),模块级 allow 为其保留——节点级中间进度刷新属后续扩展。
//!
//! 预留说明(dead_code):本模块的 3 个函数当前无生产调用方——
//! T4 消费侧已落地(`agentic/mod.rs` 在构建 system prompt 时读
//! `PerConvState.workflow_dag_summary` 并注入),但生产侧尚无人写入该字段
//! (`PerConvState::new` 初始化为 `None`,无代码调用 `build_dag_summary`)。
//! 即完整链路为「工作流启动器 → 调本模块产出摘要 → 写 workflow_dag_summary
//! → agentic loop 注入 system prompt」,中间「生产者」环节待接通。
//! 属为后续工作流上下文注入准备的预留,非真死代码,故 allow 抑制告警。
#![allow(dead_code)] #![allow(dead_code)]
+6 -2
View File
@@ -647,6 +647,8 @@ pub async fn ai_chat_send(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); conv.messages.push(ChatMessage::user(&user_content));
} }
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)
conv.working_context.begin_turn(&user_content, 200);
// F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后取末条 user 消息 id(本轮 user,原供知识注入 referenced 溯源)。
// DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id), // DRY(B):知识注入已收敛至 inject_knowledge_into_prompt(helper 内部同消息取 text+id),
// 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。 // 此处 user_msg_id 不再透传到注入逻辑,保留下划线占用(锁内 push 已发生,语义不变)。
@@ -891,7 +893,7 @@ pub async fn ai_approve(
// 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部 // 回填 / emit_data_changed / save / try_continue)。workflow 联动任务推进由 run_workflow 内部
// 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。 // 按 task_id+target_status 自动处理(workflow.rs:270-308),此处不重复推进。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
// F-260620 对齐 ai_authorize_dir(:658):ai_approve 执行同样包 60s 超时。 // F-260620 对齐 ai_authorize_dir(:658):ai_approve 执行同样包 60s 超时。
// 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue // 防 list_directory 大目录/run_command 慢命令卡死 → 到不了 audit_finalize/emit/try_continue
@@ -1273,7 +1275,7 @@ pub async fn ai_authorize_dir(
// → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → generating 永真。包 60s 超时(对齐 // → 到不了 audit_finalize/emit/try_continue → IPC 永挂 → generating 永真。包 60s 超时(对齐
// run_command),超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。 // run_command),超时返 Err → 走 failed 分支 finalize+emit+try_continue,IPC 必返回不卡。
let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" { let exec_result: anyhow::Result<serde_json::Value> = if approval.tool_name == "run_workflow" {
super::super::execute_run_workflow_for_tool(&app, &state, &args).await super::super::execute_run_workflow_for_tool(&app, &state, &args, conv_id.as_deref()).await
} else { } else {
match tokio::time::timeout( match tokio::time::timeout(
std::time::Duration::from_secs(60), std::time::Duration::from_secs(60),
@@ -1972,6 +1974,8 @@ pub async fn ai_chat_force_send(
} else { } else {
conv.messages.push(ChatMessage::user(&user_content)); conv.messages.push(ChatMessage::user(&user_content));
} }
// 新用户轮把本请求钉扎为 WorkingContext 常驻目标(截断 200 字符,与 agentic G1 目标钉扎 GOAL_MAX_CHARS 同量级)
conv.working_context.begin_turn(&user_content, 200);
// F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。 // F-260619-04 P1:push 后立即取末条 user 消息 id(供知识注入 referenced 溯源)。
let user_msg_id = conv.messages.last_user_message_id(); let user_msg_id = conv.messages.last_user_message_id();
// G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send) // G1 目标钉扎已迁移至 run_agentic_loop 工具调用推理(提取方式同 ai_chat_send)
+5
View File
@@ -1142,10 +1142,14 @@ pub(crate) struct ToolCallDraft {
/// ///
/// 不在此推进任务状态 —— workflow.rs::run_workflow 内部已按 task_id+target_status 自动联动 /// 不在此推进任务状态 —— workflow.rs::run_workflow 内部已按 task_id+target_status 自动联动
/// (成功推进到 target / 失败按 regression_target 退回),此处不重复。 /// (成功推进到 target / 失败按 regression_target 退回),此处不重复。
///
/// `conv_id`(T4 会话关联)末参透传 run_workflow_inner:调用方带发起会话 ID 时,
/// 启动写 workflow_id/workflow_dag_summary、终态回写摘要,供 agentic loop 注入 system prompt。
pub(crate) async fn execute_run_workflow_for_tool( pub(crate) async fn execute_run_workflow_for_tool(
app: &tauri::AppHandle, app: &tauri::AppHandle,
state: &crate::state::AppState, state: &crate::state::AppState,
args: &serde_json::Value, args: &serde_json::Value,
conv_id: Option<&str>,
) -> anyhow::Result<serde_json::Value> { ) -> anyhow::Result<serde_json::Value> {
let task_id = args let task_id = args
.get("task_id") .get("task_id")
@@ -1178,6 +1182,7 @@ pub(crate) async fn execute_run_workflow_for_tool(
Some(task_id.clone()), Some(task_id.clone()),
Some(target_status.clone()), Some(target_status.clone()),
"ai", "ai",
conv_id,
) )
.await .await
.map_err(|e| anyhow::anyhow!("工作流执行失败: {}", e))?; .map_err(|e| anyhow::anyhow!("工作流执行失败: {}", e))?;
+54 -7
View File
@@ -21,6 +21,7 @@ use df_storage::models::WorkflowRecord;
use df_workflow::dag_def::DagDef; use df_workflow::dag_def::DagDef;
use df_workflow::executor::DagExecutor; use df_workflow::executor::DagExecutor;
use crate::commands::ai::agentic::workflow_context::build_dag_summary;
use crate::state::AppState; use crate::state::AppState;
use super::{err_str, now_millis}; use super::{err_str, now_millis};
@@ -105,7 +106,7 @@ pub async fn run_workflow(
// 瘦转发到 run_workflow_inner抽取共用核心供 ai_approve 经 // 瘦转发到 run_workflow_inner抽取共用核心供 ai_approve 经
// execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。 // execute_run_workflow_for_tool 直接调,无需构造 tauri::State)。
// 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。 // 命令层(前端 invoke run_workflow)是人工手动触发 → triggered_by="manual"。
run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual").await run_workflow_inner(&app, &state, name, dag, config, task_id, target_status, "manual", None).await
} }
/// 工作流执行核心逻辑(B-260617-01 抽取,命令层瘦转发)。 /// 工作流执行核心逻辑(B-260617-01 抽取,命令层瘦转发)。
@@ -118,6 +119,11 @@ pub async fn run_workflow(
/// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual" /// - 命令层 run_workflow(前端 invoke,人工手动)传 "manual"
/// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai" /// - execute_run_workflow_for_tool(AI 工具经 ai_approve 审批后执行)传 "ai"
/// 原硬编码 "manual" 致 AI 路径误标,改由调用方透传。 /// 原硬编码 "manual" 致 AI 路径误标,改由调用方透传。
///
/// `conv_id` T4 会话关联:Some(AI 路径)时启动写入 workflow_id(存本次执行 ID,非模板 ID)与
/// workflow_dag_summary 进会话状态,供 agentic loop 每轮 build_runtime_prompt 注入;
/// 执行闭包终态(completed/failed)回写终态摘要。会话不存在则静默跳过。
/// 命令层手动触发无会话上下文,传 None 零关联。
pub async fn run_workflow_inner( pub async fn run_workflow_inner(
app: &AppHandle, app: &AppHandle,
state: &AppState, state: &AppState,
@@ -127,6 +133,7 @@ pub async fn run_workflow_inner(
task_id: Option<String>, task_id: Option<String>,
target_status: Option<String>, target_status: Option<String>,
triggered_by: &str, triggered_by: &str,
conv_id: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
// 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。 // 0. F-260616-06 ①-1: DagDef 来源选模板(方案A·最小)。
// 规则: // 规则:
@@ -162,6 +169,10 @@ pub async fn run_workflow_inner(
// 3. 写入执行记录(status=running) // 3. 写入执行记录(status=running)
let execution_id: ExecutionId = new_id().into(); let execution_id: ExecutionId = new_id().into();
let dag_json = serde_json::to_string(&dag).map_err(err_str)?; let dag_json = serde_json::to_string(&dag).map_err(err_str)?;
// T4: DAG 摘要启动态(无当前节点),启动写入与终态刷新共用同一基础文本,
// 闭包外算好 move 进闭包,闭包内只拼状态行不重算。
let dag_summary = build_dag_summary(&dag, None);
let conv_id = conv_id.map(|s| s.to_string());
let record = WorkflowRecord { let record = WorkflowRecord {
id: execution_id.to_string(), id: execution_id.to_string(),
name, name,
@@ -181,6 +192,18 @@ pub async fn run_workflow_inner(
.await .await
.map_err(err_str)?; .map_err(err_str)?;
// T4 生产侧:AI 路径启动时把执行 ID 与 DAG 摘要写入会话状态,接通
// 「生产者 → workflow_dag_summary → build_runtime_prompt 注入」链路。
// 存的是本次执行 ID(非模板 ID);会话不存在(已删除/无效 conv_id)静默跳过,
// 与消费侧 conv_read 短路语义一致(不惰性创建空会话)。
if let Some(cid) = conv_id.as_deref() {
let mut session = state.ai_session.lock().await;
if let Some(conv) = session.per_conv.get_mut(cid) {
conv.workflow_id = Some(execution_id.to_string());
conv.workflow_dag_summary = Some(dag_summary.clone());
}
}
// 3. 订阅事件总线,把事件转发给前端(收到完成/失败事件后退出) // 3. 订阅事件总线,把事件转发给前端(收到完成/失败事件后退出)
let mut rx = state.event_bus.subscribe(); let mut rx = state.event_bus.subscribe();
let forward_app = app.clone(); let forward_app = app.clone();
@@ -281,6 +304,10 @@ pub async fn run_workflow_inner(
// F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用 // F-260616-06 ②-2: move task_id / target_status 进闭包供完成/失败回调使用
let cb_task_id = task_id.clone(); let cb_task_id = task_id.clone();
let cb_target_status = target_status.clone(); let cb_target_status = target_status.clone();
// T4 终态刷新物料:conv_id + 基础摘要 + 会话锁句柄 move 进执行闭包
let cb_conv_id = conv_id.clone();
let cb_dag_summary = dag_summary.clone();
let ai_session = state.ai_session.clone();
// panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。 // panic-guard:执行 spawn 闭包最外层包 catch_unwind(AssertUnwindSafe(..))。
// panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove), // panic 时走已存在 failed 分支(update status=failed + emit WorkflowFailed + state_registry.remove),
// 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。 // 防 panic 后终态不发/registry 不清永久卡。主逻辑/调度零改动,仅在 panic 时补发终态。
@@ -381,24 +408,44 @@ pub async fn run_workflow_inner(
// 执行结束(成功/失败)清理状态注册表,防内存泄漏 // 执行结束(成功/失败)清理状态注册表,防内存泄漏
state_registry.lock().await.remove(&exec_id); state_registry.lock().await.remove(&exec_id);
// 执行失败时补发 WorkflowFailed(执行器内部只发 NodeFailed) // failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值),
// failed_node 从状态机取首个失败/取消节点(R9⑪: 原 String::new() 空值) // 失败补发事件与 T4 会话终态摘要共用,提到分支外计算。
if let Some(error) = error { let failed_node = if error.is_some() {
let failed_node = executor executor
.state_machine() .state_machine()
.snapshot() .snapshot()
.into_iter() .into_iter()
.find(|(_, s)| matches!(s, NodeStatus::Failed | NodeStatus::Cancelled)) .find(|(_, s)| matches!(s, NodeStatus::Failed | NodeStatus::Cancelled))
.map(|(id, _)| id) .map(|(id, _)| id)
.unwrap_or_default(); .unwrap_or_default()
} else {
String::new()
};
// 执行失败时补发 WorkflowFailed(执行器内部只发 NodeFailed)
if let Some(error) = error {
event_bus event_bus
.send(WorkflowEvent::WorkflowFailed { .send(WorkflowEvent::WorkflowFailed {
execution_id: exec_id.clone(), execution_id: exec_id.clone(),
error, error,
failed_node, failed_node: failed_node.clone(),
}) })
.await; .await;
} }
// T4 终态刷新:工作流终态后把完成/失败结论写回会话摘要,agentic loop
// 下一轮注入终态而非启动时的「完成: 待执行器上报」。
// 会话不存在(已删除)静默跳过;手动路径 conv_id None 无刷新。
if let Some(cid) = cb_conv_id {
let summary = if status == "completed" {
format!("{}\n状态: 已完成", cb_dag_summary)
} else {
format!("{}\n状态: 已失败(节点: {})", cb_dag_summary, failed_node)
};
let mut session = ai_session.lock().await;
if let Some(conv) = session.per_conv.get_mut(&cid) {
conv.workflow_dag_summary = Some(summary);
}
}
}) // 闭 catch_unwind 内 async move }) // 闭 catch_unwind 内 async move
.catch_unwind() .catch_unwind()
.await; .await;