feat(agent): 超长工具结果落盘 + toolresult_read 按范围回读(补齐工具环)

问题(WSL2 真机诊断报告问题 2):单条工具结果 36783 token,直接冲破
整个对话预算 → overflow 判「单条超限,裁剪救不了」→ 整轮终止。
模型看到的只是"对话没反应"。日志:max_ctx=32768 target=26214
fixed=498 mem=8572 ctx=17144,而单条 36783 是它的 2.1 倍。

此前两条路都不通,本提交走第三条:

· **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺
  数据下结论 —— 与本仓反复吃亏的「静默降级」同族。
· **只统计不裁剪**(方案 B,toolresult_budget.go):设计上正确(不欺骗
  模型),但停在「报告」一步 —— oversizeTools 计数**没有任何消费者**
  (OversizeToolReports/OversizeToolNames 全仓零调用者),所以那条超大
  结果照样整条进上下文、照样撞窗口。

本提交把方案 B 缺的那一半补上:

  结果超限(沿用 toolresult_budget.go 的 ContextTokens/8 阈值)
    → 正文完整写入 <context.json 同级>/toolresults/<evt_id>/tool_N.txt
    → 上下文里换成「预览 + event_id 句柄 + 总字节/行数 + 读法」
    → 模型调 toolresult_read(event_id, offset, limit) 取任意区间(≤64KB/次)
    → 事件离开 L1 时全文目录一并删除

设计要点:
· **存活期跟随事件,不引入 TTL**。与 Blocks 同一套处置(见 mediaref.go
  「块存储在事件自身,不维护引用账本」)。刻意不做独立过期/清理定时器
  ——那会让"一件事记两处",两处一旦不一致就出现"事件还在但全文没了"。
· **正文不内联进 context.json**:它每 5s 全量重写,内联几 MB 正文会让
  每次 flush 都写几 MB。
· **磁盘 IO 放 context.go**(记忆面持久化,架构守卫允许的唯一落点),
  toolresult_store.go 只留纯逻辑 —— 满足 TestKernelHasNoMediaFileReading。
· **toolresult_read 按需暴露**:只在真有落盘结果时注册 schema
  (本仓已有"175 个工具占满上下文"的教训)。
· 落盘失败**保持原内容**:宁可撞窗口也不让模型什么都看不到。

判据(toolresult_store_test.go,7 条,3 条经变异验证):
· TestOffloadReplacesFullTextWithPreview —— 上下文拿预览非全文,且三要素
  齐全(说明是预览/给句柄/给读法)。变异:返回全文 ⇒ 红。
· TestReadBackReassemblesByteIdentical —— 分段拼回**逐字节相同**,含中文
  多字节字符。这是"信息不丢"的唯一硬证据。变异:少读 1 字节 ⇒ 红。
· TestAppendClaimsPendingToolResults —— 事件进 L1 必须认领落盘记录,
  否则重启后句柄全失效。变异:不认领 ⇒ 红。
· TestReadAfterPurgeIsExplicit —— 淘汰后明确告知,不静默返回空串。
· TestPersistedToEventDirNotInline / TestReadWithBadHandleIsActionable /
  TestSmallResultNotOffloaded
This commit is contained in:
JianFeeeee
2026-10-25 20:16:16 +08:00
parent f833897d40
commit c9a32a98f0
6 changed files with 751 additions and 12 deletions

View File

@ -117,6 +117,14 @@ type TaskFrame struct {
// 在若干帧后突然失去超页处理能力(而那时恰恰最需要它)。
OverflowRecover int
// EventID 是本帧工具结果落盘时使用的**事件标识**。
//
// 为什么要挂在帧上:同一帧内可能有多条超长结果,它们都属于同一个
// 上下文事件,应当落在同一个事件目录里(tool_0.txt / tool_1.txt)。
// 惰性生成(首次落盘时分配),因为绝大多数帧没有任何超长结果——
// 全量生成会给每条事件多加一个字段,与 ContextEvent.ID 的取舍一致。
EventID string
// 当前工具批
PendingTools []agentAPI.ToolCall
ToolIdx int
@ -1173,14 +1181,27 @@ func (a *Agent) stepToolAfter(f *TaskFrame) stepOutcome {
}
f.Msgs = append(f.Msgs, toolMsg)
// 工具结果大小统计(方案 B:**只统计不裁剪**)。
// 工具结果大小处置:超限则**全文落盘 + 上下文只放预览**。
//
// 为什么在这里而不是 stepToolExec:那里拿到的 outcome.Text 尚未经
// after_toolcall 阶段改写,而模型最终看到的是**这里**的内容。
// ⚠️ 刻意不截断 —— 截断会让模型基于残缺数据下结论,且它不知道被截过
// (与本仓「静默降级」同族)。处置权交回调度器/上层。
//
// ★ 刻意**不截断**(截断会让模型拿到残缺信息且不知道被截过,与本仓
// 「静默降级」同族)。也刻意**不原样放行**(那正是 36783 token 单条
// 结果冲破预算、整轮终止的成因)。
//
// 走的是第三条路:正文完整保存到事件目录,上下文里换成
// 「预览 + 句柄 + 总量」+ toolresult_read 工具按需取区间。
// 取哪一段由模型决定,信息不丢(判据要求分段拼回逐字节相同)。
if a.checkToolResultSize(tc.Name, toolMsg.Content) {
f.noteOversizeTool(tc.Name)
if replaced, ok := a.offloadToolResult(f, tc.Name, toolMsg.Content); ok {
toolMsg.Content = replaced
result = replaced
if len(f.ToolResults) > 0 {
f.ToolResults[len(f.ToolResults)-1].Output = replaced
}
}
}
if mediaMsg != nil {
// 延迟到批末(见 batchAttachments 的注释):当场追加会把后续 tool 消息