新增: 工具结果落盘瘦身与空轮自愈
- 超阈值工具结果正文落盘+句柄可读回(治 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user