From 31510d3555cdee650bc058413f2898543bad65b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Sun, 11 Oct 2026 07:33:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E:=20=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E8=90=BD=E7=9B=98=E7=98=A6=E8=BA=AB=E4=B8=8E?= =?UTF-8?q?=E7=A9=BA=E8=BD=AE=E8=87=AA=E6=84=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔) - read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回 - 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退) - 零 token 收敛信号透传(非截断语义分离) - 上下文清单描述按字符截断, 防清单体量无限膨胀 --- crates/df-ai/src/context_helpers.rs | 39 +++- crates/df-ai/src/tool_offload.rs | 117 ++++++++++++ crates/df-ai/tests/context_offload.rs | 83 +++++++++ src-tauri/src/commands/ai/agentic/helpers.rs | 4 + src-tauri/src/commands/ai/agentic/mod.rs | 166 +++++++++++++++++- .../src/commands/ai/agentic/stall_detector.rs | 11 +- .../src/commands/ai/agentic/stream_relay.rs | 2 + src-tauri/src/commands/ai/prompt.rs | 89 +++++++++- 8 files changed, 494 insertions(+), 17 deletions(-) create mode 100644 crates/df-ai/src/tool_offload.rs create mode 100644 crates/df-ai/tests/context_offload.rs diff --git a/crates/df-ai/src/context_helpers.rs b/crates/df-ai/src/context_helpers.rs index 973e24e..177b611 100644 --- a/crates/df-ai/src/context_helpers.rs +++ b/crates/df-ai/src/context_helpers.rs @@ -423,6 +423,17 @@ pub const TOOL_RESULT_CHAR_LIMIT: usize = 1_024; /// JSON 对象中字符串字段值的最大字符数(超过则截断)。独立于行数截断, /// 解决 `{"content":"大段文字(无换行)"}` 类 JSON 逃逸行级截断的问题。 pub const TOOL_RESULT_JSON_STR_FIELD_MAX: usize = 512; +/// read_file 豁免上限(P1 工具结果瘦身 2026-10-10):≤此值原样返回,超过则走压缩+落盘。 +/// +/// 依据实测:全库工具结果体量前二为 patch_file 34.6% / read_file 29.6%(单条 max 150KB), +/// 原「read_file 一律豁免」让 29.6% 体量完全裸奔(65K 均值 prompt 压不下来的直接原因之一)。 +/// 保留小文件豁免(AI 定向读代码需完整语义——历史有「限读 28KB 被折叠致能力受损」实证), +/// 阈值取 8KB 与 D2a 审计侧同构;超过则摘要+落盘句柄(可无损读回)。 +pub const READ_FILE_OFFLOAD_BYTES: usize = 8 * 1024; +/// 追加落盘句柄的最小原文字节数(与 D2a 审计侧 8KB 阈值对齐)。 +/// 小于此值的压缩结果**不加句柄**:有损摘要的丢失量小,而句柄行(约 150 字符)会净增 token; +/// ≥此值才值得(丢失量大、句柄占比可忽略),兼顾「无损可读回」与「不反噬瘦身目标」。 +pub const TOOL_OFFLOAD_HANDLE_MIN_BYTES: usize = 8 * 1024; /// 判断 tool_result 是否需摘要压缩:content >2KB 或 占比 >40%。 /// @@ -454,14 +465,30 @@ pub fn should_summarize_tool_result( /// - 错误行(在头尾区间外的)额外插入,标注位置。 /// - 中间大段省略为 `... (省略 N 行) ...`。 /// -/// `tool_name` 参与内容判断:`read_file` 是 AI 定向读代码的工具,压缩其 content -/// 等于直接阉割 AI 的代码分析能力(实测 limit=100 读 28KB,被折叠为首尾各 5 行), -/// 故对 `read_file` 一律豁免压缩(原样返回)。其余工具仍走压缩。 +/// `tool_name` 参与内容判断:`read_file` 定向读代码,压缩其 content 会削弱代码分析能力, +/// 故保留**小文件豁免**(≤ READ_FILE_OFFLOAD_BYTES 原样返回);超阈值仍走压缩并落盘 +/// (P1 2026-10-10,句柄可读回,无损)。其余工具仍走压缩。 /// 空内容一律返回空字符串。 pub fn extract_key_info(content: &str, tool_name: &str) -> String { - // read_file 豁免:AI 定向读代码,压缩 content 违背用户/AI 意图。 - // read_file handler 自带 limit 硬上限 2000 行(file.rs:156/168),无爆 prompt 风险。 - if tool_name == "read_file" { + let compressed = extract_key_info_inner(content, tool_name); + // P1 无损化(2026-10-10):确实发生压缩 → 原文落盘 + 追加句柄行,被省略的中段可 read_file 读回。 + // 句柄**只对 ≥ TOOL_OFFLOAD_HANDLE_MIN_BYTES 的大结果**追加——小结果有损摘要丢得少, + // 加句柄行反而净增 token(反噬瘦身目标);大结果丢失量大,句柄占比可忽略,才值得。 + // base 未注入(单测/未初始化)→ 不加句柄,输出与改造前逐字节一致(零行为变化)。 + if content.len() >= TOOL_OFFLOAD_HANDLE_MIN_BYTES && compressed.len() < content.len() { + if let Some(handle) = crate::tool_offload::offload_with_handle(content, tool_name) { + return format!("{compressed}\n{handle}"); + } + } + compressed +} + +/// extract_key_info 的纯压缩实现(零 IO,便于单测与复用)。 +fn extract_key_info_inner(content: &str, tool_name: &str) -> String { + // read_file 高阈值豁免(P1 2026-10-10):实测任务结果体量 read_file 占 29.6%(单条 max 150KB), + // 原「一律豁免」致最大头裸奔(65K 均值 prompt 压不下来的直接原因之一)。 + // 改为「小文件豁免 + 超阈值压缩并落盘」:既保 AI 定向读代码语义,又让超大读取可瘦身。 + if tool_name == "read_file" && content.len() <= READ_FILE_OFFLOAD_BYTES { return content.to_string(); } // JSON 感知压缩:识别对象中的大数组/大字符串并截断 diff --git a/crates/df-ai/src/tool_offload.rs b/crates/df-ai/src/tool_offload.rs new file mode 100644 index 0000000..bba6cb1 --- /dev/null +++ b/crates/df-ai/src/tool_offload.rs @@ -0,0 +1,117 @@ +//! 上下文侧工具结果正文外置(P1 工具结果瘦身)。 +//! +//! 依据 docs/02-架构设计/上下文根治设计-20261006.md §二「工具结果瘦身(治最大头,先行)」: +//! 超阈值工具结果**正文落盘**,上下文只留「摘要 + 句柄路径」;句柄可被 agent 主动展开 +//! (read_file 读句柄路径)——需要时找得回,不需要时不占位。与 src-tauri 审计侧 D2a 同构。 +//! +//! 与 namespace_store 的区别(两者互补,不冲突): +//! - namespace_store:内存态引用化(省进程内存),发 LLM 前展开为全文 → token 零收益。 +//! - 本模块:落盘 + 句柄,被省略的中段**不进 prompt 但可读回** → 真正减 token 且不丢信息。 +//! +//! 降级契约:base 未注入(单测/未初始化)→ 不落盘返回 None,调用方退化为纯摘要(改造前行为)。 +//! 幂等:文件名取内容稳定哈希,同内容重复压缩只落盘一次。 +//! 保留策略:与全局「历史数据默认保留」一致,本模块自产文件只增不减(无清理调用点)。 +//! +//! 测试隔离:核心逻辑 `offload_to(base, ...)` 显式传 base(零全局态),单测只测它; +//! `init_base` 的 OnceLock 全局态一次性不可重置,故测试**绝不调用**它(避免污染同进程 +//! 其他用例——如 extract_key_info 系列断言会被意外注入的句柄行破坏)。 + +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +/// 外置根目录(进程级一次注入;src-tauri lib.rs setup 调 init_base)。 +/// 与审计侧共用 {app_data},文件名前缀 `ctx-` 区分(tool-results/ 目录共存)。 +static BASE: OnceLock = OnceLock::new(); + +/// 注入外置根目录(app data dir)。幂等,首次生效。 +pub fn init_base(base: PathBuf) { + let _ = BASE.set(base); +} + +/// base 是否已注入(降级判定用)。 +pub fn is_enabled() -> bool { + BASE.get().is_some() +} + +/// 内容稳定哈希(非密码学用途;碰撞后果仅同名文件互相覆盖,概率可忽略)。 +fn content_id(content: &str) -> u64 { + use std::collections::hash_map::DefaultHasher; + use std::hash::{Hash, Hasher}; + let mut h = DefaultHasher::new(); + content.hash(&mut h); + h.finish() +} + +/// 落盘工具结果正文,返回可读句柄行(供摘要末尾追加)。base 未注入 → None。 +pub fn offload_with_handle(content: &str, tool_name: &str) -> Option { + offload_to(BASE.get()?, content, tool_name) +} + +/// 核心实现(显式 base,零全局态,可测)。 +/// +/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。 +pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option { + let dir = base.join("tool-results"); + if std::fs::create_dir_all(&dir).is_err() { + return None; + } + let file = dir.join(format!("ctx-{:016x}.txt", content_id(content))); + if !file.exists() && std::fs::write(&file, content).is_err() { + return None; + } + Some(format!( + "[工具 {} 原文 {} 字符已外置: {} —— 需完整内容用 read_file 读该路径]", + tool_name, + content.chars().count(), + file.display() + )) +} + +/// 读回外置文件(查询侧/测试用)。文件缺失返回带语义错误(降级不 panic)。 +pub fn read_offloaded(path: &str) -> Result { + std::fs::read_to_string(path).map_err(|e| format!("外置结果读取失败: {e}")) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp_base(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!("df-ai-offload-{tag}-{}", std::process::id())) + } + + #[test] + fn offload_to_writes_readable_file_and_is_idempotent() { + let base = tmp_base("ok"); + let _ = std::fs::remove_dir_all(&base); + let payload = "large tool output ".repeat(1000); + let handle = offload_to(&base, &payload, "grep").expect("显式 base 应落盘"); + assert!(handle.contains("已外置"), "句柄应含提示文案: {handle}"); + assert!(handle.contains("read_file"), "句柄应指引读回方式: {handle}"); + // 幂等:同内容二次调用返回同一文件名 + let handle2 = offload_to(&base, &payload, "grep").expect("二次调用仍返回句柄"); + assert_eq!(handle, handle2, "同内容应幂等复用同一文件"); + // 落盘文件可读回原文(无损) + let path = std::fs::read_dir(base.join("tool-results")) + .expect("目录应存在") + .flatten() + .map(|e| e.path()) + .find(|p| p.extension().map(|e| e == "txt").unwrap_or(false)) + .expect("应有落盘文件"); + assert_eq!( + read_offloaded(path.to_string_lossy().as_ref()).expect("应可读回"), + payload + ); + let _ = std::fs::remove_dir_all(&base); + } + + #[test] + fn different_content_gets_different_file() { + let base = tmp_base("diff"); + let _ = std::fs::remove_dir_all(&base); + let h1 = offload_to(&base, &"a".repeat(3000), "grep").unwrap(); + let h2 = offload_to(&base, &"b".repeat(3000), "grep").unwrap(); + assert_ne!(h1, h2, "不同内容应落不同文件"); + let _ = std::fs::remove_dir_all(&base); + } +} diff --git a/crates/df-ai/tests/context_offload.rs b/crates/df-ai/tests/context_offload.rs new file mode 100644 index 0000000..d42ff9a --- /dev/null +++ b/crates/df-ai/tests/context_offload.rs @@ -0,0 +1,83 @@ +//! P1 工具结果瘦身 — 端到端集成验证。 +//! +//! 独立测试二进制(独立进程):`tool_offload::init_base` 的 OnceLock 全局态不外泄, +//! 不污染 lib 单测(反之亦然)。这是验证「超阈值 → 落盘句柄 → 可无损读回」全链的唯一安全方式。 + +use df_ai::context_helpers::{extract_key_info, READ_FILE_OFFLOAD_BYTES}; +use df_ai::tool_offload; + +fn tmp_base(tag: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!("df-ai-ctx-offload-{tag}-{}", std::process::id())) +} + +/// 从句柄行里粗取落盘路径(格式:`[...已外置: —— ...]`)。 +fn path_of(handle_line: &str) -> String { + handle_line + .split("已外置: ") + .nth(1) + .and_then(|s| s.split(" ——").next()) + .expect("句柄行应含路径") + .trim() + .to_string() +} + +#[test] +fn large_read_file_summarized_with_lossless_handle() { + let base = tmp_base("large"); + let _ = std::fs::remove_dir_all(&base); + tool_offload::init_base(base.clone()); + + // 造一个超阈值的 read_file 结果(代码风格多行文本) + let body: String = (1..=800) + .map(|i| format!("line {i}: fn sample_{i}() {{ let x = {i}; }}\n")) + .collect(); + assert!(body.len() > READ_FILE_OFFLOAD_BYTES, "夹具须超阈值"); + + let out = extract_key_info(&body, "read_file"); + assert!(out.len() < body.len(), "超阈值 read_file 应被压缩"); + assert!(out.contains("已外置"), "应追加落盘句柄:\n{out}"); + assert!(out.contains("read_file 读该路径"), "句柄应指引读回方式"); + + // 无损性:按句柄路径读回 = 原文逐字节一致 + let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行"); + let back = tool_offload::read_offloaded(&path_of(line)).expect("句柄路径应可读回"); + assert_eq!(back, body, "落盘原文须与输入逐字节一致(无损)"); + + // 摘要确实变小(瘦身生效) + let ratio = out.len() as f64 / body.len() as f64; + assert!(ratio < 0.2, "压缩比应显著(<0.2),实际 {ratio:.3}"); + + let _ = std::fs::remove_dir_all(&base); +} + +#[test] +fn small_read_file_still_exempt() { + let base = tmp_base("small"); + tool_offload::init_base(base); + let small = "line1: fn a() {}\nline2: fn b() {}\n"; + assert_eq!( + extract_key_info(small, "read_file"), + small, + "阈值内 read_file 应原样返回(保 AI 定向读代码语义)" + ); +} + +#[test] +fn non_read_file_large_result_also_gets_handle() { + let base = tmp_base("other"); + let _ = std::fs::remove_dir_all(&base); + tool_offload::init_base(base.clone()); + + let body: String = (1..=1200).map(|i| format!("match {i}: src/foo.rs:{i}\n")).collect(); + assert!(body.len() >= 8 * 1024, "夹具须 ≥8KB(句柄追加下限)"); + let out = extract_key_info(&body, "grep"); + assert!(out.len() < body.len(), "应被压缩"); + assert!(out.contains("已外置"), "≥8KB 结果应带落盘句柄"); + + let line = out.lines().find(|l| l.contains("已外置")).expect("应有句柄行"); + assert_eq!( + tool_offload::read_offloaded(&path_of(line)).expect("应可读回"), + body + ); + let _ = std::fs::remove_dir_all(&base); +} diff --git a/src-tauri/src/commands/ai/agentic/helpers.rs b/src-tauri/src/commands/ai/agentic/helpers.rs index 445b51a..1304788 100644 --- a/src-tauri/src/commands/ai/agentic/helpers.rs +++ b/src-tauri/src/commands/ai/agentic/helpers.rs @@ -104,6 +104,8 @@ pub async fn try_continue_agent_loop( let zero_usage = df_ai::provider::TokenUsage::default(); super::emit_ai_completed_once( app, &emit_conv_id, &zero_usage, false, + // 零 token 收敛信号,非截断(看板 953)。 + false, None, None, None, true, &snap.pinned_goals_snapshot, false, ).await; @@ -177,6 +179,8 @@ pub async fn try_continue_agent_loop( let zero_usage = df_ai::provider::TokenUsage::default(); super::emit_ai_completed_once( app, &conv_id_owned, &zero_usage, false, + // 零 token 收敛信号,非截断(看板 953)。 + false, None, None, None, true, &snap.pinned_goals_snapshot, false, ).await; diff --git a/src-tauri/src/commands/ai/agentic/mod.rs b/src-tauri/src/commands/ai/agentic/mod.rs index b621289..ec3b6c5 100644 --- a/src-tauri/src/commands/ai/agentic/mod.rs +++ b/src-tauri/src/commands/ai/agentic/mod.rs @@ -283,6 +283,21 @@ pub const GOAL_VERIFY_ENABLED: bool = false; /// 保证 loop 必然终止,绝不死循环。LLM 续轮内调了工具(无论成败)计数器重置(说明在行动)。 pub const GOAL_VERIFY_MAX_ROUNDS: u32 = 2; +/// D5 空轮判据自愈开关(默认 true,u-claw 空轮自愈经验,看板 B 类)。 +/// +/// 会话轮次「无产出」(无工具调用且文本纯空白)连续 EMPTY_ROUND_PARK_THRESHOLD 次 +/// → 判定卡死,走停靠止损(emit_task_parked 正典出口,停靠卡「继续」一键续跑), +/// 而非静默空收敛(改动前:空轮直接判 converged,用户收到零内容的「完成」)。 +/// 未达阈值的空轮注入 nudge 续轮自愈(先自愈后止损,对齐 u-claw 经验)。 +/// false(回退):整块跳过,空轮行为逐字节等价改动前(静默收敛),排障/对比用。 +pub const EMPTY_ROUND_GUARD_ENABLED: bool = true; + +/// D5 空轮停靠阈值(默认 3)。 +/// +/// 对齐 STALL_BREAKER_THRESHOLD/CIRCUIT_BREAKER_THRESHOLD=3 量级:连续 3 次无产出才判卡死; +/// 单次空轮(偶发 provider 抖动/纯 reasoning 输出未成文)由 nudge 续轮自愈,不触发停靠。 +pub const EMPTY_ROUND_PARK_THRESHOLD: u32 = 3; + /// G2 警示是否回顾目标(默认 true,需 G1 goal 字段)。 /// /// true(默认):警示文本引用 pinned_goal(若存在)提示「回顾目标: {goal}」,精准; @@ -885,6 +900,12 @@ pub(crate) async fn run_agentic_loop( // 区分"正常收敛退出"与"达 MAX 被截断退出"——后者末轮 tool_calls 仍非空(tool_result 不再回传 LLM),属异常 let mut converged = false; + // 看板 953:收敛轮截断标志。finalize_round_output 判定正常收敛时若该轮流被 provider + // max_tokens 上限腰斩(RoundOutcome::Continue 的 truncated_by_max_tokens),把标志带出 loop, + // 正常完成 AiCompleted 透传前端渲染截断提示条。仅 Converged 分支写;stop/保文/达上限等 + // 退出路径不经过该分支,保持 false(截断只对「用户看到的最终回复」有意义)。 + let mut converged_truncated = false; + // L1 断路器:连续同类工具失败滚动窗口(VecDeque,长度封顶 CIRCUIT_BREAKER_WINDOW)。 // key=结构化信号(tool_name + 错误类别)。每轮 process_tool_calls 后追加末尾失败 key, // 超窗自动淘汰最旧。窗口内同 key 计数达 CIRCUIT_BREAKER_THRESHOLD → 熔断退出。 @@ -913,6 +934,9 @@ pub(crate) async fn run_agentic_loop( // 且无 applied.matched_target 证据时强制续轮,每续 +1。达 GOAL_VERIFY_MAX_ROUNDS 强制收敛 // + AiCompleted 标注 goal_unverified。续轮内调了工具则重置(说明 LLM 在行动非空转)。 let mut goal_verify_rounds: u32 = 0; + // D5 空轮连续计数(EMPTY_ROUND_GUARD_ENABLED 门控):连续「无产出」轮数, + // finalize_round_output 内记账——空轮 +1,有产出轮清零;达阈值停靠止损。 + let mut empty_round_streak: u32 = 0; // DeepSeek thinking 模式推理内容跨轮透传 let mut last_reasoning_content: Option = None; @@ -1102,6 +1126,8 @@ pub(crate) async fn run_agentic_loop( &coord_usage, // Coordinator 路径无 round(未 stream),无估算语义 → false。 false, + // Coordinator 路径无 round,无截断语义 → false(看板 953)。 + false, None, None, None, true, &pinned_goals_snapshot, &app_handle, @@ -1267,6 +1293,7 @@ pub(crate) async fn run_agentic_loop( Some(&save_usage), Some(&resolved_model), false, &provider_config, &llm_concurrency, &mut guard, &emit_usage, last_round_estimated, + false, Some(true), None, Some(reason), true, &pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false, ).await; @@ -1299,6 +1326,7 @@ pub(crate) async fn run_agentic_loop( Some(&save_usage), Some(&resolved_model), false, &provider_config, &llm_concurrency, &mut guard, &emit_usage, last_round_estimated, + false, Some(true), None, Some("external_change"), true, &pinned_goals_snapshot, &app_handle, &loop_epoch_arc, my_epoch, false, ).await; @@ -1434,12 +1462,15 @@ pub(crate) async fn run_agentic_loop( &full_text, &tool_calls_acc, &round_usage, round_is_estimated, &last_reasoning_content, &mut goal_verify_rounds, + &mut empty_round_streak, round_truncated, ).await { // RunTools → 继续工具调用段(工具调用数由该段内部取,与原时序一致) RoundEpilogue::RunTools => {} RoundEpilogue::Converged => { // 无工具调用且无续轮需求 → 正常收敛退出(原 converged = true; break,语义不变) + // 看板 953:收敛轮被 max_tokens 截断时把标志带出 loop(正常完成 emit 透传前端) + converged_truncated = round_truncated; converged = true; break; } @@ -1562,6 +1593,7 @@ pub(crate) async fn run_agentic_loop( &mut guard, &emit_usage, last_round_estimated, + false, Some(true), None, Some("max_rounds"), false, &pinned_goals_snapshot, &app_handle, @@ -1626,6 +1658,7 @@ pub(crate) async fn run_agentic_loop( emit_ai_completed_once( &app_handle, &conv_id, &normal_usage, last_round_estimated, + converged_truncated, None, None, None, true, &pinned_goals_snapshot, true, @@ -1649,6 +1682,7 @@ pub(crate) async fn run_agentic_loop( &mut guard, &emit_usage, last_round_estimated, + false, None, None, None, true, &pinned_goals_snapshot, &app_handle, @@ -1766,6 +1800,8 @@ async fn prepare_round_inputs( &emit_usage, // 入口 stop:本轮尚未 stream,last_round_estimated 仍为初始 false(无估算)。 last_round_estimated, + // 入口 stop:停止语义非截断,恒 false(看板 953)。 + false, None, None, None, true, pinned_goals_snapshot, app_handle, @@ -2062,6 +2098,8 @@ async fn finalize_round_output( round_is_estimated: bool, last_reasoning_content: &Option, goal_verify_rounds: &mut u32, + // D5 空轮连续计数:空轮 +1 / 有产出轮清零,达 EMPTY_ROUND_PARK_THRESHOLD 停靠止损。 + empty_round_streak: &mut u32, // 本轮流因 provider max_tokens 上限截断(P2-1),收敛时据实提示不冒充完整 round_truncated_by_max_tokens: bool, ) -> RoundEpilogue { @@ -2073,6 +2111,11 @@ async fn finalize_round_output( // 追加 assistant 消息到历史 + G1 目标提取(扁平重构,原嵌套 8 层 → 3 层) let has_tool_calls = !tool_calls_acc.is_empty(); + // D5 空轮记账:有产出轮(有工具调用或文本非空)清零连续空轮计数; + // 空轮(+1/止损/nudge)在下方 !has_tool_calls 分支处理。 + if EMPTY_ROUND_GUARD_ENABLED && !round_is_barren(has_tool_calls, full_text) { + *empty_round_streak = 0; + } { let mut session = session_arc.lock().await; if !session.per_conv.contains_key(conv_id) { @@ -2162,6 +2205,8 @@ async fn finalize_round_output( guard, &emit_usage, last_round_estimated, + // stop 语义非截断,恒 false(看板 953)。 + false, None, None, None, true, pinned_goals_snapshot, app_handle, @@ -2173,6 +2218,43 @@ async fn finalize_round_output( // 无工具调用 → 最终文本响应。正常收敛退出,或目标验证续轮(GOAL_VERIFY_ENABLED)。 if !has_tool_calls { + // D5 空轮判据自愈(先于目标验证:空轮无任何产出,谈不上验证目标): + // 本轮无产出(纯空白文本 + 无工具调用,push_assistant_message 已跳过落库)→ 连续计数 +1; + // 达阈值 → 停靠止损(正典 park 序列对齐 H2 挂钟闸:先 emit_task_parked 再 teardown + // 正典收尾,停靠卡「继续」一键续跑),替代改动前的「静默空收敛完成」; + // 未达阈值 → 注入 nudge 续轮自愈(先自愈后止损),落到 RunTools(空 acc)即纯续轮。 + if EMPTY_ROUND_GUARD_ENABLED && round_is_barren(has_tool_calls, full_text) { + *empty_round_streak += 1; + if empty_round_should_park(*empty_round_streak, EMPTY_ROUND_PARK_THRESHOLD) { + tracing::warn!( + conv_id = %conv_id, iteration, streak = *empty_round_streak, + "[ai] 连续 {} 轮无产出,判定卡死,任务停靠止损", *empty_round_streak + ); + let message = format!( + "连续 {} 轮无任何产出(空文本且无工具调用),疑似模型卡死,已安全停靠:点继续或重发消息可续跑", + *empty_round_streak + ); + crate::commands::ai::emit_task_parked( + &app_handle, &conv_id, &message, "empty_convergence", + ); + // teardown 正典收尾(save + guard 复位 + AiCompleted(incomplete))再退, + // 口径对齐 H2 闸停靠(spawn_title=false / emit_incomplete=Some(true))。 + let save_usage = usage_delta_since(tokens, saved_token_snapshot); + let emit_usage = tokens_snapshot(tokens); + teardown_round( + session_arc, db, conv_id, + Some(&save_usage), Some(resolved_model), + false, provider_config, llm_concurrency, guard, + &emit_usage, last_round_estimated, + false, + Some(true), None, Some("empty_convergence"), true, + pinned_goals_snapshot, app_handle, loop_epoch_arc, my_epoch, false, + ).await; + return RoundEpilogue::Exit; + } + insert_empty_round_nudge(session_arc, conv_id, iteration, *empty_round_streak).await; + return RoundEpilogue::RunTools; + } // 目标验证续轮:开关开 + 有钉扎目标 + 本轮无 applied.matched_target 证据 + 未达上限 // → 不收敛,注入 nudge 强制续轮(治过早宣称,机制非 prompt 说教)。 // 任一条件不满足 → 正常收敛(逐字节等价改动前)。 @@ -2433,6 +2515,53 @@ async fn insert_goal_verify_nudge( } } +// ============================================================ +// D5 空轮判据自愈(u-claw 空轮自愈经验,看板 B 类) +// +// 会话轮次「无产出」(无工具调用 + 文本纯空白)连续 N 次 → 判卡死,停靠止损而非静默空收敛。 +// 与既有熔断的边界(实施前查重,确认无重复覆盖): +// - G2/stall_detector/L1 断路器:工具层信号(签名重复/连续同类失败),空轮无工具调用, +// 不进其计数面; +// - ROUND_REENTRY/H2 三闸:网络失败与预算兜底;空轮是「流成功返回但零内容」,两者均不覆盖; +// - sub_agent EmptyConvergence(P3-7):子代理侧同型判据已落(空收敛降级 Truncated), +// 本闸补主会话侧对齐(stop_kind 沿用 empty_convergence 词表,零新契约)。 +// ============================================================ + +/// 纯函数:单轮是否「无产出」。无工具调用且文本纯空白 = 无产出 +/// (判据对齐 push_assistant_message 的空轮不落库口径:纯空白且无 tool_calls 即无任何信息)。 +fn round_is_barren(has_tool_calls: bool, text: &str) -> bool { + !has_tool_calls && text.trim().is_empty() +} + +/// 纯函数:连续无产出是否达停靠阈值(u-claw 判据:连续 N 次空轮 → 卡死止损)。 +fn empty_round_should_park(streak: u32, threshold: u32) -> bool { + streak >= threshold +} + +/// 注入空轮自愈 nudge(强制续轮,非软提示):直给结论或调工具,二选一。 +/// 复用 insert_goal_verify_nudge 的尾部追加范式(nudge 紧邻本轮内容语义不变, +/// 不位移既有前缀字节,自动前缀缓存不失效)。 +async fn insert_empty_round_nudge( + session_arc: &Arc>, + conv_id: &str, + iteration: usize, + streak: u32, +) { + let nudge_text = format!( + "⚠ 你上一轮没有输出任何内容(无文本也无工具调用),已连续 {} 轮。请立即给出有效动作:要么直接输出当前结论与进展,要么调用工具推进任务;不得再次空响应,否则任务将被安全停靠。", + streak + ); + let mut session = session_arc.lock().await; + if session.per_conv.contains_key(conv_id) { + let conv = session.conv(conv_id); + conv.messages.push_system_anchor(ChatMessage::system(&nudge_text)); + tracing::info!( + conv_id = %conv_id, iteration, streak, + "[EMPTY-ROUND] 空轮未达停靠阈值,注入自愈 nudge 续轮" + ); + } +} + // ── emit_circuit_breaker_tripped: L1 断路器熔断统一 emit(扁平抽自原嵌套块) ── // 开关 CIRCUIT_BREAKER_HELP_EVENT: true=求助卡(默认), false=AiError 错误气泡(兜底回退)。 fn emit_circuit_breaker_tripped(app_handle: &AppHandle, conv_id: &str, max_count: u32, sample_key: &str) { @@ -2909,6 +3038,8 @@ pub(crate) async fn emit_ai_completed_once( conv_id: &str, usage: &df_ai::provider::TokenUsage, is_estimated: bool, + // 看板 953:最终收敛回复因 provider max_tokens 截断(仅正常收敛出口传 true,其余 false) + truncated_by_max_tokens: bool, emit_incomplete: Option, publish_incomplete: Option, incomplete_reason: Option<&str>, @@ -2945,7 +3076,7 @@ pub(crate) async fn emit_ai_completed_once( // publish 端:do_publish=false(max_iterations)跳过 publish(保留原无 publish 行为); // MidStream incomplete 分叉(emit=Some(true)/publish=None)事件值不同,不能并成单次 // emit_ai_chat_event 调用,分路构造(其余 5 路径两端同值,走单次双写)。 - let ev = completed_event(usage, is_estimated, emit_incomplete, incomplete_reason, conv_id, pinned_goals); + let ev = completed_event(usage, is_estimated, truncated_by_max_tokens, emit_incomplete, incomplete_reason, conv_id, pinned_goals); if do_publish && emit_incomplete == publish_incomplete { emit_ai_chat_event(app_handle, ev); // P0 修复(2026-10-05):正常完成恒走此早退,完成通知桥必须在此投递—— @@ -2958,7 +3089,7 @@ pub(crate) async fn emit_ai_completed_once( let _ = app_handle.emit("ai-chat-event", ev); if do_publish { let _ = app_handle.state::().ai_event_bus.publish_event( - completed_event(usage, is_estimated, publish_incomplete, None, conv_id, pinned_goals), + completed_event(usage, is_estimated, truncated_by_max_tokens, publish_incomplete, None, conv_id, pinned_goals), ); } // P1 bg 化:完成出口系统通知(条件发送,失败静默;stop 出口经调用方传 false 关闭) @@ -3003,9 +3134,12 @@ pub(crate) async fn last_active_assistant_text( /// 构造 AiCompleted 事件(emit_ai_completed_once 内部去重用,字段口径见其文档)。 /// incomplete_reason 与 incomplete 配对:emit 侧事件随 emit_incomplete 取值,publish 侧 /// 分叉事件(与 emit 不一致时)incomplete 为 None,reason 同步 None。 +/// truncated_by_max_tokens(看板 953):最终收敛回复是否被 provider max_tokens 截断, +/// 透传前端标截断提示条;非正常收敛出口恒 false。 fn completed_event( usage: &df_ai::provider::TokenUsage, is_estimated: bool, + truncated_by_max_tokens: bool, incomplete: Option, incomplete_reason: Option<&str>, conv_id: &str, @@ -3019,6 +3153,7 @@ fn completed_event( prompt_cache_miss_tokens: usage.prompt_cache_miss_tokens, reasoning_tokens: usage.reasoning_tokens, is_estimated, + truncated_by_max_tokens, incomplete, incomplete_reason: incomplete_reason.map(str::to_string), conversation_id: Some(conv_id.to_string()), @@ -3242,6 +3377,9 @@ pub(crate) fn usage_delta_since( // - save_usage/save_model:透传 save_conversation;Coordinator 传 (None, None) 仍会调 save(幂等) // - emit_usage:emit AiCompleted 的 token 三元组来源(各退出点经 tokens.add/round-derived 后传入) // - emit_estimated(G4.3, 2026-08-05):本轮 token 用量是否估算值,透传 emit_ai_completed_once 仅展示 +// - truncated_by_max_tokens(看板 953):最终收敛回复因 provider max_tokens 截断,透传 +// emit_ai_completed_once 仅展示标注;本 helper 覆盖的退出点(stop/保文/达上限等)均非 +// 「正常收敛被腰斩」语义,调用方恒传 false(唯一 true 出口是正常完成直调 emit_ai_completed_once) // - spawn_title:true → spawn_ensure_title(provider_config 后台生成,失败有 extract_title 兜底) // - emit_incomplete/publish_incomplete/do_publish:透传 emit_ai_completed_once(见该 helper 文档) // - loop_epoch_arc/my_epoch:F1 并发 epoch——旧 loop(epoch 不匹配)只 disarm 跳过 save/reset/emit, @@ -3260,6 +3398,7 @@ pub(crate) async fn teardown_round( guard: &mut GeneratingGuard, emit_usage: &df_ai::provider::TokenUsage, emit_estimated: bool, + truncated_by_max_tokens: bool, emit_incomplete: Option, publish_incomplete: Option, // 不完整原因(emit 侧,与 emit_incomplete 配对):Some("max_rounds")=达最大轮次自动收尾, @@ -3295,6 +3434,7 @@ pub(crate) async fn teardown_round( // generating 复位后再 emit Completed(对齐原各路径顺序) emit_ai_completed_once( app_handle, conv_id, emit_usage, emit_estimated, + truncated_by_max_tokens, emit_incomplete, publish_incomplete, incomplete_reason, do_publish, pinned_goals, notify, @@ -3651,3 +3791,25 @@ mod inject_extra_rounds_tests { assert_eq!(inject_extra_rounds_decision(usize::MAX, 0, 5), None); } } + +#[cfg(test)] +mod empty_round_guard_tests { + use super::{empty_round_should_park, round_is_barren}; + + #[test] + fn barren_needs_no_tools_and_blank_text() { + // 空文本/纯空白 + 无工具调用 = 无产出;任一维度有内容即有产出 + assert!(round_is_barren(false, "")); + assert!(round_is_barren(false, " \n\t ")); + assert!(!round_is_barren(false, "结论:已完成")); + assert!(!round_is_barren(true, ""), "有工具调用即有产出(工具轮)"); + } + + #[test] + fn park_exactly_at_consecutive_threshold() { + assert!(!empty_round_should_park(1, 3)); + assert!(!empty_round_should_park(2, 3)); + assert!(empty_round_should_park(3, 3), "达阈值即停靠止损"); + assert!(empty_round_should_park(4, 3)); + } +} diff --git a/src-tauri/src/commands/ai/agentic/stall_detector.rs b/src-tauri/src/commands/ai/agentic/stall_detector.rs index d985ccc..92b112a 100644 --- a/src-tauri/src/commands/ai/agentic/stall_detector.rs +++ b/src-tauri/src/commands/ai/agentic/stall_detector.rs @@ -335,10 +335,13 @@ pub(crate) fn summarize_tool_results( .and_then(|id| id_to_name.get(id)) .map(|(n, _)| n.as_str()) .unwrap_or("tool"); - // read_file 豁免压缩:AI 定向读代码,压缩 content 阉割代码分析能力。 - // read_file handler 自带 limit 硬上限 2000 行,无爆 prompt 风险。 - // 豁免判定在决策记录之前(豁免消息不进决策表,每轮恒豁免无漂移)。 - if resolved_name == "read_file" { + // read_file 小文件豁免(P1 2026-10-10 改高阈值):AI 定向读代码,小文件需完整语义; + // 超 READ_FILE_OFFLOAD_BYTES 的读取不再豁免——实测 read_file 占上下文体量 29.6% + // (单条 max 150KB),原「一律豁免」让最大头裸奔(65K 均值压不下来的直接原因之一)。 + // 超阈值走压缩+落盘,句柄可无损读回(read_file 读句柄路径)。 + if resolved_name == "read_file" + && content_len <= df_ai::context_helpers::READ_FILE_OFFLOAD_BYTES + { tracing::debug!( conv_id = %conv_id, iteration, content_len, diff --git a/src-tauri/src/commands/ai/agentic/stream_relay.rs b/src-tauri/src/commands/ai/agentic/stream_relay.rs index 8b9f080..28cb1ec 100644 --- a/src-tauri/src/commands/ai/agentic/stream_relay.rs +++ b/src-tauri/src/commands/ai/agentic/stream_relay.rs @@ -691,6 +691,8 @@ pub(crate) async fn stream_round( guard, &emit_usage, *last_round_estimated, + // MidStream 已按不完整收尾,截断标志不再叠加(false,看板 953)。 + false, Some(true), None, Some("network"), true, pinned_goals_snapshot, app_handle, diff --git a/src-tauri/src/commands/ai/prompt.rs b/src-tauri/src/commands/ai/prompt.rs index 34d375e..642c283 100644 --- a/src-tauri/src/commands/ai/prompt.rs +++ b/src-tauri/src/commands/ai/prompt.rs @@ -555,6 +555,24 @@ pub(crate) async fn build_system_prompt_with_excluded( prompt } +/// 项目/任务清单单条描述截断上限(字符数,中文一字一符)。 +/// +/// 防清单体量随数据无限增长:描述可粘贴长文档,20 项目 + 20 任务 × 长描述可达数万字符, +/// 截断后接「…」。技能清单同类截断见 agentic/mod.rs `SKILL_DESC_MAX_CHARS`(80)。 +const CONTEXT_DESC_MAX_CHARS: usize = 120; + +/// 清单描述截断(纯函数):超 `max` 字符按 Unicode 标量截断并追加「…」,否则原样返回。 +/// +/// 按字符而非字节计数(中文描述一字一符,按字节会截断 UTF-8 序列产生乱码)。 +fn truncate_desc(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let mut out: String = s.chars().take(max).collect(); + out.push('…'); + out +} + /// 项目/任务全局清单段(DRY:主构建 [`build_system_prompt_with_excluded`] 与 caching 易变段 /// [`build_system_prompt_parts`] 共用)。 /// @@ -578,7 +596,13 @@ async fn build_context_lists( if excluded_project_ids.iter().any(|id| id == &p.id) { continue; } - out.push_str(&format!("- {} (id: {}) ({}): {}\n", p.name, p.id, p.status.as_str(), p.description)); + out.push_str(&format!( + "- {} (id: {}) ({}): {}\n", + p.name, + p.id, + p.status.as_str(), + truncate_desc(&p.description, CONTEXT_DESC_MAX_CHARS) + )); if let Some(ref dir) = p.path { out.push_str(&format!(" 目录: {}\n", dir)); } @@ -597,7 +621,13 @@ async fn build_context_lists( if excluded_task_ids.iter().any(|id| id == &tk.id) { continue; } - out.push_str(&format!("- {} (id: {}) ({}): {}\n", tk.title, tk.id, tk.status.as_str(), tk.description)); + out.push_str(&format!( + "- {} (id: {}) ({}): {}\n", + tk.title, + tk.id, + tk.status.as_str(), + truncate_desc(&tk.description, CONTEXT_DESC_MAX_CHARS) + )); } // 机制层注明语(中/英):仅最近 20 条,全量/按项目查询走 list_tasks out.push_str(&tasks_listed_note(lang)); @@ -631,10 +661,23 @@ pub(crate) fn cache_split_provider_type(provider_type: &str) -> bool { ) } -/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认关) +/// 解析 prompt caching 拆分开关原始值为布尔(纯函数,默认开)。 +/// +/// 值语义(见 [`cache_enabled`]):缺省/空(从未设置过)→ 默认开; +/// 显式 "false"/"0"(设置页开关关)→ 关 = 用户回退; +/// "true"/"1" 沿用旧识别;其余杂值保守按关(与历史默认关行为一致)。 +/// 前端落库为 JSON 布尔字符串("true"/"false",无引号),直接等值比较即可。 +fn parse_cache_flag(v: &str) -> bool { + v.is_empty() || v == "true" || v == "1" +} + +/// 读取 prompt caching 拆分开关(AppState settings KV `df-ai-anthropic-cache`,默认开) /// 并同步 df-ai 进程级显式缓存开关。 /// -/// 值语义:非 "true"/"1"(含缺省/空)一律视为关——默认关、用户对支持的端点显式开启。 +/// 值语义见 [`parse_cache_flag`]:默认开——拆分后易变段(日期/清单)移出稳定前缀, +/// anthropic 系显式缓存与 openai_compat 自动前缀缓存命中率提升;调用方另按 +/// provider_type gate(`cache_split_provider_type`),不支持的端点仍走逐字等价旧路径, +/// 默认开对它们零影响。回退:设置页「AI Prompt 缓存」开关关(落库 "false")即整链回旧行为。 /// 同步:anthropic_compat::convert_request 读取同一进程级开关决定 system 形态 /// (数组+cache_control / 纯字符串),保证开关关时非官方网关零风险。 /// 键名保持历史名(向后兼容,已有键不换名),语义已扩展覆盖 openai_compat 前缀稳定拆分。 @@ -646,7 +689,7 @@ pub(crate) async fn cache_enabled(state: &AppState) -> bool { .ok() .flatten() .unwrap_or_default(); - let enabled = v == "true" || v == "1"; + let enabled = parse_cache_flag(&v); df_ai::anthropic_compat::set_anthropic_cache_enabled(enabled); enabled } @@ -1038,4 +1081,40 @@ mod tests { assert!(en_task.contains("20 most recent tasks")); assert!(en_task.contains("list_tasks")); } + + // 缓存拆分开关默认值断言(看板 939 ⑧:默认关→默认开) + #[test] + fn parse_cache_flag_defaults_on_and_honors_explicit_off() { + // 缺省/空(从未设置过)→ 默认开 + assert!(parse_cache_flag(""), "缺省(未设置)应默认开"); + assert!(parse_cache_flag("true")); + assert!(parse_cache_flag("1")); + // 显式回退:设置页开关关落库 JSON 布尔 "false" + assert!(!parse_cache_flag("false"), "显式 false 应为关(用户回退通道)"); + assert!(!parse_cache_flag("0")); + // 杂值保守按关(与历史默认关行为一致,不因脏数据意外开启) + assert!(!parse_cache_flag("garbage")); + } + + // 清单描述截断(纯函数):短原样 / 恰好上限不截 / 超长截断加 … / 中文按字符非字节 + #[test] + fn truncate_desc_short_exact_and_cjk() { + // 短描述与空串原样 + assert_eq!(truncate_desc("hello", 120), "hello"); + assert_eq!(truncate_desc("", 120), ""); + // 恰好等于上限不截断(无省略号) + let exact = "字".repeat(120); + assert_eq!(truncate_desc(&exact, 120), exact); + // 超长:截到 max 字符 + … + assert_eq!(truncate_desc("abcdef", 5), "abcde…"); + // 中文一字一符(按 Unicode 标量计数,非 UTF-8 字节,不产生乱码截断) + let long = "汉".repeat(130); + let got = truncate_desc(&long, 5); + assert_eq!(got, "汉汉汉汉汉…"); + // 常量绑定:实际清单截断上限 120(防止常量被误改无人知) + assert_eq!(CONTEXT_DESC_MAX_CHARS, 120); + let got120 = truncate_desc(&long, CONTEXT_DESC_MAX_CHARS); + assert_eq!(got120.chars().count(), 121); + assert!(got120.ends_with('…')); + } }