新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理

后端:
- 工作流推进链(D-03):advance_task/状态机/闸门走 df-nodes Node trait,conditions 条件引擎扩展
- 想法评估闭环:启发式评分+对抗评估,df-ideas/scoring + df-storage/idea_eval_repo + idea 前端打通
- 全局事件数据总线:df-ai/context+context_helpers+augmentation 跨模块解耦
- AI planner/plan_hint/intent:aichat B 路线并行多轮基础
- patch_file 加固(TD-03/04):读改写整体锁防 lost update,expected_hash 合约闭环
- 压缩超时兜底(F-15 卡死根治)
- F-09 多会话并发:LlmConcurrency per-conv + streamingGuard 前端守护 + verify 脚本
- 知识注入 DRY/skills/audit 扩展

清理:
- aichat 技术债(误报 allow/死导入/过时注释 30 项)
- URGENT.md 删除(11 项加急全解决/迁 todo)
- 文档整理(todo/待决策/待审查/ARCHITECTURE/INDEX + 总线/技术债审查新文档)
This commit is contained in:
lxy
2026-06-21 20:51:26 +08:00
parent 330bb7f505
commit bd6a41fe6e
111 changed files with 11931 additions and 1033 deletions
+130 -8
View File
@@ -40,9 +40,8 @@ use super::prompt::compress_prompt;
/// 注意:本函数只负责"喂 LLM 出摘要文本",不改 ContextManager 状态(标 compressed +
/// 插摘要 system 由调用方做,职责分离,便于单元测试与多调用点复用)。
//
// dead_code:阶段1 基础函数,阶段2 IPC(ai_chat_compress_context)/ 阶段3 agentic
// loop 自动压缩会调用。本阶段只暴露 + 单测,不接调用点(零行为变化原则)。
#[allow(dead_code)]
// 调用方:agentic/mod.rs:807(agentic loop 自动压缩)+ commands/chat.rs:1102
// (ai_chat_compress_context IPC)。
pub(crate) async fn compress_via_llm(
provider: &dyn LlmProvider,
provider_config: &AiProviderRecord,
@@ -54,6 +53,22 @@ pub(crate) async fn compress_via_llm(
if active_msgs.is_empty() {
return Err("无可压缩消息(active 消息为空)".to_string());
}
// 阶段2(占位配对完整性):压缩段含未闭合占位(占位 tool_result 无对应 tool_call 头)→
// 跳过该三元组不压缩,返 Err 让调用方降级(保留原状)。理由:压缩 LLM 摘要可能破坏占位
// content/__PENDING__ 标记或拆散占位 result 与(已丢的)头的关系,使下游 sanitize 无法识别
// 占位 → orphan 触发 400。保守跳过:含未闭合占位的段不压缩(降级走 build_for_request 裁剪
// 或关键词兜底),占位配对完整性由 sanitize step3.5 + 出口断言兜住。
//
// 检测:占位 tool_result(content 带 __PENDING__ 标记)的 tool_call_id 不在任何 assistant
// 头 tool_calls 内 → 未闭合占位 → 返 Err 跳过压缩。
if compress_segment_has_unclosed_placeholder(&active_msgs) {
return Err("压缩段含未闭合占位 tool_result,跳过压缩保留原状(防破坏占位配对致 400 orphan)".to_string());
}
// MED-1214(CR): 治毒——active_msgs 过 sanitize_messages(畸形 tool 配对自愈),
// 防 compress 带毒(assistant tool_calls 缺 result / 孤儿 tool_result 无 tool_calls)触发 provider 400。
// 同 build_for_request 语义:仅发送视图剔毒,不改 ContextManager 持久化。
let active_count = active_msgs.len();
let active_msgs = df_ai::context::ContextManager::sanitize_messages(active_msgs);
// 路由选模型:无 tool_use(纯文本摘要)。
// None(池空/无匹配)→ 兜底 default_model(行为不变)。
@@ -84,10 +99,18 @@ pub(crate) async fn compress_via_llm(
let _global_permit = llm_concurrency.acquire_global().await;
let _per_conv_permit = llm_concurrency.acquire_per_conv(conv_id).await;
let resp = provider
.complete(request)
.await
.map_err(|e| format!("LLM 压缩调用失败: {}", e))?;
// F-15 卡死根治(2026-06-21 诊断):provider.complete 原无超时,LLM hang → agentic loop
// (agentic/mod.rs:780)await 永不返 → run_agentic_loop 永挂 → generating 卡死
// (set_compressing(true) 已置 agentic/mod.rs:753,false 永不到达)。
// 加 60s timeout 兜底(对齐 ai_approve 超时):超时返 Err → 调用方 Err 分支
// (agentic/mod.rs:818-830)set_compressing(false) 复位 + 关键词摘要降级,loop 正常继续不卡死。
let resp = tokio::time::timeout(
std::time::Duration::from_secs(60),
provider.complete(request),
)
.await
.map_err(|_| format!("LLM 压缩调用超时(60s,active {} 条,可能 provider hang;降级关键词裁剪)", active_count))?
.map_err(|e| format!("LLM 压缩调用失败: {} (active {} 条,已 sanitize 治毒;若仍失败查 provider 原文 tool 配对)", e, active_count))?;
let summary = clean_summary(&resp.text);
if summary.is_empty() {
return Err("LLM 压缩返回空摘要".to_string());
@@ -95,9 +118,41 @@ pub(crate) async fn compress_via_llm(
Ok(summary)
}
/// 阶段2(占位配对完整性):检测压缩段是否含未闭合占位 tool_result。
///
/// 占位 tool_result(content 带 __PENDING__ 标记,审批挂起)若无对应 tool_call 头(头被裁/丢)
/// → 未闭合占位。压缩此类段会破坏占位配对(摘要改写 content / 拆散关系),使下游 sanitize
/// 无法识别占位 → orphan 触发 400。故调用方检测到未闭合占位即跳过压缩(返 Err 降级)。
///
/// 检测逻辑:收集段内所有 assistant 头的 tool_call.id;占位 tool_result 的 id 不在内 → 未闭合。
/// 已闭合占位(头在)可正常压缩(配对完整,摘要不影响头关系)。
fn compress_segment_has_unclosed_placeholder(msgs: &[ChatMessage]) -> bool {
use std::collections::HashSet;
let head_ids: HashSet<&str> = msgs
.iter()
.filter(|m| matches!(m.role, df_ai::provider::MessageRole::Assistant))
.filter_map(|m| m.tool_calls.as_ref())
.flatten()
.filter_map(|c| {
// 排除 TOOL_MISSING_PREFIX 占位头(非真实头,由出口断言自愈补的临时闭合头)
if c.id.starts_with(df_ai::context_helpers::TOOL_MISSING_PREFIX) {
None
} else {
Some(c.id.as_str())
}
})
.collect();
msgs.iter().any(|m| {
matches!(m.role, df_ai::provider::MessageRole::Tool)
&& df_ai::context_helpers::is_pending_placeholder(&m.content)
&& m.tool_call_id
.as_deref()
.is_some_and(|id| !head_ids.contains(id))
})
}
/// 清理 LLM 返回的摘要:去首尾空白 / 包裹性 markdown 代码围栏,空则原样返回
/// (调用方据空值报错)。保留内部 markdown 结构(## 标题 / 列表),摘要本身就是结构化文本。
#[allow(dead_code)]
fn clean_summary(raw: &str) -> String {
let t = raw.trim();
// 去掉可能的整体代码围栏(LLM 偶尔把整段包成 ```markdown ... ```)
@@ -146,4 +201,71 @@ mod tests {
// 未知语言降级中文
assert_eq!(compress_prompt("fr"), compress_prompt("zh"));
}
// ── 阶段2 占位配对完整性:compress_segment_has_unclosed_placeholder ──
#[test]
fn unclosed_placeholder_detected_when_no_head() {
// 占位 tool_result(带标记)无对应 tool_call 头 → 未闭合 → 应跳过压缩
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::tool_result(
"call_pending",
"需要用户审批,等待确认__PENDING__:call_pending",
),
];
assert!(
compress_segment_has_unclosed_placeholder(&msgs),
"无头的占位 tool_result 应判为未闭合占位"
);
}
#[test]
fn closed_placeholder_not_flagged() {
// 占位 tool_result 有对应 tool_call 头 → 已闭合 → 不跳过(可正常压缩)
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::assistant_with_tools(
"调",
vec![df_ai::provider::ToolCall::new(
"call_pending",
"write_file",
"{}",
)],
),
df_ai::provider::ChatMessage::tool_result(
"call_pending",
"需要用户审批,等待确认__PENDING__:call_pending",
),
];
assert!(
!compress_segment_has_unclosed_placeholder(&msgs),
"有头的占位 tool_result 是已闭合,不应跳过压缩"
);
}
#[test]
fn non_placeholder_orphan_not_flagged() {
// 普通 tool_result(非占位)无头 → 不算"未闭合占位"(普通 orphan 由 sanitize 处理,
// 不触发压缩跳过;占位保护语义专属于审批挂起占位)
let msgs = vec![
df_ai::provider::ChatMessage::user("问题"),
df_ai::provider::ChatMessage::tool_result("orphan_id", "普通结果"),
];
assert!(
!compress_segment_has_unclosed_placeholder(&msgs),
"非占位 orphan 不应触发压缩跳过(仅占位专享保护)"
);
}
#[test]
fn no_tool_messages_not_flagged() {
// 纯文本段(无 tool_result)→ 永不触发跳过
let msgs = vec![
df_ai::provider::ChatMessage::user("问题1"),
df_ai::provider::ChatMessage::assistant("回答1"),
df_ai::provider::ChatMessage::user("问题2"),
];
assert!(!compress_segment_has_unclosed_placeholder(&msgs));
}
}