新增: 工具结果落盘瘦身与空轮自愈
- 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔) - read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回 - 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退) - 零 token 收敛信号透传(非截断语义分离) - 上下文清单描述按字符截断, 防清单体量无限膨胀
This commit is contained in:
@@ -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 感知压缩:识别对象中的大数组/大字符串并截断
|
||||
|
||||
@@ -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<PathBuf> = 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<String> {
|
||||
offload_to(BASE.get()?, content, tool_name)
|
||||
}
|
||||
|
||||
/// 核心实现(显式 base,零全局态,可测)。
|
||||
///
|
||||
/// 建目录失败 / 写文件失败 → None(降级:调用方仅用摘要,不丢数据)。
|
||||
pub fn offload_to(base: &Path, content: &str, tool_name: &str) -> Option<String> {
|
||||
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<String, String> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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()))
|
||||
}
|
||||
|
||||
/// 从句柄行里粗取落盘路径(格式:`[...已外置: <path> —— ...]`)。
|
||||
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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<key>,长度封顶 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<String> = 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<String>,
|
||||
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<Mutex<AiSession>>,
|
||||
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<bool>,
|
||||
publish_incomplete: Option<bool>,
|
||||
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::<AppState>().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<bool>,
|
||||
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<bool>,
|
||||
publish_incomplete: Option<bool>,
|
||||
// 不完整原因(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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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('…'));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user