新增: 工具结果落盘瘦身与空轮自愈

- 超阈值工具结果正文落盘+句柄可读回(治 read_file 29.6% 体量裸奔)
- read_file 改小文件豁免(8KB), 超阈值压缩+落盘句柄, 无损可找回
- 空轮判据自愈: 连续空轮先 nudge 续轮, 达阈值停靠止损(开关可回退)
- 零 token 收敛信号透传(非截断语义分离)
- 上下文清单描述按字符截断, 防清单体量无限膨胀
This commit is contained in:
lxy
2026-10-11 07:33:27 +08:00
parent 18e50c5af7
commit 31510d3555
8 changed files with 494 additions and 17 deletions
+33 -6
View File
@@ -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 感知压缩:识别对象中的大数组/大字符串并截断
+117
View File
@@ -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);
}
}
+83
View File
@@ -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);
}