新增: 批次工作落地(推进链/评估闭环/事件总线/并发/加固) + 技术债清理 + 文档整理
后端: - 工作流推进链(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:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user