mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-01 06:47:29 +00:00
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:
@ -3,6 +3,7 @@ package core
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@ -35,6 +36,17 @@ type ContextEvent struct {
|
||||
ToolsUsed []string `json:"tools_used,omitempty"`
|
||||
ToolResults []ToolResultItem `json:"tool_results,omitempty"`
|
||||
|
||||
// ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。
|
||||
//
|
||||
// ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**,
|
||||
// 存活期由事件的存活期决定(事件离开 L1 ⇒ 正文目录一并删除)。
|
||||
// 见 toolresult_store.go 的说明。
|
||||
//
|
||||
// 这里只存元信息(工具名、文件名、总字节/行数),正文在
|
||||
// <context.json 同级>/toolresults/<eventID>/tool_N.txt —— 因为
|
||||
// context.json 每 5s 全量重写一次,内联大正文会让每次 flush 都写几 MB。
|
||||
ToolResultStores []ToolResultStore `json:"tool_result_stores,omitempty"`
|
||||
|
||||
// NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏),
|
||||
// 但**照常参与归档**(离开 L1 时会写回 L2)。
|
||||
//
|
||||
@ -60,15 +72,21 @@ type ContextEvent struct {
|
||||
const contextFlushInterval = 5 * time.Second
|
||||
|
||||
type RelevanceContext struct {
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
embedder *memory.StaticEmbedder
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
savePath string
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
mu sync.Mutex
|
||||
events []*ContextEvent
|
||||
embedder *memory.StaticEmbedder
|
||||
denseSpace vector.MultimodalEmbedder
|
||||
savePath string
|
||||
// pendingToolResults 存放"已落盘但事件还没进 L1"的超长工具结果记录。
|
||||
//
|
||||
// 为什么需要:同一帧可能先落盘、后 append 事件(task.go 的顺序)。
|
||||
// 没有它,句柄会在事件进入前悬空,模型读到的就是"未找到"。
|
||||
// 事件 append 后由 claimPendingToolResults 认领并清空。
|
||||
pendingToolResults map[string][]ToolResultStore
|
||||
saveTimer *time.Timer
|
||||
dirty bool
|
||||
toolDefLookup func(name string) *sdk.ToolDef
|
||||
channelDefLookup func(name string) (sdk.ChannelDef, bool)
|
||||
|
||||
// protectedCount 是裁剪时**无条件保留**的最近事件条数。
|
||||
//
|
||||
@ -400,15 +418,36 @@ func (c *RelevanceContext) Append(evt ContextEvent) {
|
||||
c.computeVector(&evt)
|
||||
c.events = append(c.events, &evt)
|
||||
|
||||
// 认领"先落盘、后进 L1"的超长工具结果记录(已在锁内,直接操作)。
|
||||
//
|
||||
// 为什么挂在 Append 而不是各调用点:Append 是事件进入 L1 的唯一咽喉
|
||||
// (task.go 有三处调用),散点调用必然漏一条路径——那一条上的句柄
|
||||
// 就永久悬空(模型拿到 event_id 去读得到"未找到")。
|
||||
c.claimPendingToolResultsLocked(&evt)
|
||||
|
||||
c.save()
|
||||
}
|
||||
|
||||
// claimPendingToolResultsLocked 是 claimPendingToolResults 的持锁版本,
|
||||
// 供 Append/InsertByTimestamp 这类已持锁的路径调用。
|
||||
func (c *RelevanceContext) claimPendingToolResultsLocked(evt *ContextEvent) {
|
||||
if c == nil || evt == nil || evt.ID == "" || len(c.pendingToolResults) == 0 {
|
||||
return
|
||||
}
|
||||
if sts, ok := c.pendingToolResults[evt.ID]; ok {
|
||||
evt.ToolResultStores = append(evt.ToolResultStores, sts...)
|
||||
delete(c.pendingToolResults, evt.ID)
|
||||
c.dirty = true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *RelevanceContext) InsertByTimestamp(evt ContextEvent) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
scrubEventSecrets(&evt) // 同 Append:咽喉处拦截
|
||||
c.computeVector(&evt)
|
||||
c.claimPendingToolResultsLocked(&evt) // 同 Append:认领待登记的落盘记录
|
||||
|
||||
idx := sort.Search(len(c.events), func(i int) bool {
|
||||
return c.events[i].Timestamp.After(evt.Timestamp)
|
||||
@ -573,6 +612,17 @@ func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, doc
|
||||
s.event.Blocks = nil
|
||||
}
|
||||
}
|
||||
// 超长工具结果全文同样**跟随事件**离开 L1,不单独过期。
|
||||
//
|
||||
// 与 Blocks 一致:块迁移到 L2(块本身进 doc),而全文是纯现场
|
||||
// 物料、不进 doc,所以这里直接删目录。删除失败只在日志留痕
|
||||
// (readToolResultBody 会答复"已随事件淘汰"),不会静默成功。
|
||||
for _, s := range archive {
|
||||
if s.event != nil && s.event.ID != "" && len(s.event.ToolResultStores) > 0 {
|
||||
c.purgeToolResultDir(s.event.ID)
|
||||
s.event.ToolResultStores = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -666,3 +716,119 @@ func convertToolResults(items []ToolResultItem) []document.ToolResultItem {
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ── 超长工具结果全文的持久化(记忆面 IO 的合法落点)──────────────────
|
||||
//
|
||||
// 放这里而不是 toolresult_store.go:本包有架构守卫
|
||||
// TestKernelHasNoMediaFileReading,内核除「记忆面持久化」(本文件)外不得
|
||||
// 做本地文件读。工具结果全文落盘本质是"记住一份东西",属同一职责。
|
||||
|
||||
// persistToolResult 把正文写入事件目录,返回元信息。
|
||||
func (c *RelevanceContext) persistToolResult(evtID, tool, text string) (*ToolResultStore, error) {
|
||||
if c == nil {
|
||||
return nil, fmt.Errorf("toolresult: context 为空")
|
||||
}
|
||||
if evtID == "" {
|
||||
return nil, fmt.Errorf("toolresult: evtID 为空(调用方须先分配事件 ID)")
|
||||
}
|
||||
base := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID)
|
||||
if err := os.MkdirAll(base, 0755); err != nil {
|
||||
return nil, fmt.Errorf("toolresult: 建目录 %s: %w", base, err)
|
||||
}
|
||||
|
||||
name := fmt.Sprintf("tool_%d.txt", nextToolResultSeq(base))
|
||||
|
||||
body := text
|
||||
truncated := 0
|
||||
if len(body) > toolResultStoreHardCap {
|
||||
truncated = len(body) - toolResultStoreHardCap
|
||||
body = body[:toolResultStoreHardCap]
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(base, name), []byte(body), 0644); err != nil {
|
||||
return nil, fmt.Errorf("toolresult: 写正文: %w", err)
|
||||
}
|
||||
|
||||
return &ToolResultStore{
|
||||
Tool: tool,
|
||||
File: name,
|
||||
TotalBytes: len(text),
|
||||
TotalLines: strings.Count(body, "\n") + 1,
|
||||
TruncatedBytes: truncated,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// nextToolResultSeq 数出事件目录里已有的 tool_*.txt,返回下一个序号。
|
||||
//
|
||||
// 不用内存计数器:进程重启后计数器归零会覆盖旧文件。数目录是幂等的。
|
||||
func nextToolResultSeq(dir string) int {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, e := range entries {
|
||||
var idx int
|
||||
if _, err := fmt.Sscanf(e.Name(), "tool_%d.txt", &idx); err == nil && idx >= n {
|
||||
n = idx + 1
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// readToolResultBody 按字节区间读取正文。
|
||||
//
|
||||
// offset 从 0 开始;limit<=0 表示读到结尾。返回内容与「是否已到末尾」。
|
||||
func (c *RelevanceContext) readToolResultBody(evtID string, st *ToolResultStore, offset, limit int) (string, bool, error) {
|
||||
if c == nil || st == nil {
|
||||
return "", true, fmt.Errorf("toolresult: 无记录")
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID, st.File)
|
||||
f, err := os.Open(p)
|
||||
if err != nil {
|
||||
// 事件已被淘汰时走到这里。给**明确**答复而不是静默空串:
|
||||
// 模型应当知道"这份结果已不可读",而不是以为结果是空的。
|
||||
if os.IsNotExist(err) {
|
||||
return "", true, fmt.Errorf("toolresult: 该结果已随事件淘汰(%s)", st.File)
|
||||
}
|
||||
return "", true, fmt.Errorf("toolresult: 打开正文: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if offset > 0 {
|
||||
if _, err := f.Seek(int64(offset), 0); err != nil {
|
||||
return "", true, fmt.Errorf("toolresult: 定位 %d: %w", offset, err)
|
||||
}
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = st.TotalBytes - offset
|
||||
}
|
||||
if limit < 0 {
|
||||
limit = 0
|
||||
}
|
||||
buf := make([]byte, limit)
|
||||
n, rerr := io.ReadFull(f, buf)
|
||||
if rerr != nil && rerr != io.EOF && rerr != io.ErrUnexpectedEOF {
|
||||
return "", true, fmt.Errorf("toolresult: 读取: %w", rerr)
|
||||
}
|
||||
out := string(buf[:n])
|
||||
atEnd := offset+n >= st.TotalBytes
|
||||
return out, atEnd, nil
|
||||
}
|
||||
|
||||
// purgeToolResultDir 删除某事件的全文目录。
|
||||
//
|
||||
// 由 Prune 在事件离开 L1 时调用:全文跟随事件生命周期,不单独过期。
|
||||
func (c *RelevanceContext) purgeToolResultDir(evtID string) {
|
||||
if c == nil || c.savePath == "" || evtID == "" {
|
||||
return
|
||||
}
|
||||
p := filepath.Join(filepath.Dir(c.savePath), toolResultDirName, evtID)
|
||||
if err := os.RemoveAll(p); err != nil {
|
||||
// 删不掉不影响正确性(readToolResultBody 会给"已淘汰"),但要留痕,
|
||||
// 否则残留会无声地占满磁盘。
|
||||
log.Printf("[agent] purge toolresult dir %s: %v", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 消息
|
||||
|
||||
@ -389,6 +389,12 @@ func (a *Agent) executeMemoryTool(tc agentAPI.ToolCall, turnScenes []string) str
|
||||
return ""
|
||||
}
|
||||
switch tc.Name {
|
||||
case "toolresult_read":
|
||||
txt, err := a.readStoredToolResult(tc.Arguments)
|
||||
if err != nil {
|
||||
return "toolresult_read 失败:" + err.Error()
|
||||
}
|
||||
return txt
|
||||
case "memory_recall":
|
||||
query, _ := tc.Arguments["query_intent"].(string)
|
||||
depth, _ := tc.Arguments["depth"].(float64)
|
||||
|
||||
@ -577,6 +577,25 @@ func (a *Agent) buildToolDefs() []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
// 超长工具结果的按范围回读工具。
|
||||
//
|
||||
// ★ 只在**真的存在**落盘结果时才暴露:
|
||||
// · 绝大多数会话没有超长结果,无条件暴露等于每轮多付一份 schema 开销
|
||||
// (本仓已有"175 个工具占满上下文"的实测教训);
|
||||
// · 没有句柄时这个工具对模型毫无用处,给了只会诱导它乱猜 event_id。
|
||||
if a.hasStoredToolResults() {
|
||||
tools = append(tools, toolDef("toolresult_read",
|
||||
"【工具结果回读】读取某个超大工具结果的指定字节区间。"+
|
||||
"当工具返回内容过大时,上下文里给你的只是预览,会附一个 event_id 句柄和总字节数。"+
|
||||
"用本工具按 offset/limit 取其余部分;单次最多 64KB。"+
|
||||
"结果不会自动过期,但会**随那条上下文事件淘汰**——事件被裁掉后再读会明确告知已淘汰。",
|
||||
map[string]interface{}{
|
||||
"event_id": map[string]interface{}{"type": "string", "description": "预览里给出的句柄(形如 evt_1770000000000000000)"},
|
||||
"offset": map[string]interface{}{"type": "number", "description": "起始字节偏移,从 0 开始;省略即从头"},
|
||||
"limit": map[string]interface{}{"type": "number", "description": "读取字节数,省略或 <=0 取 64KB;不该超过剩余量"},
|
||||
}, "event_id"))
|
||||
}
|
||||
|
||||
if a.personaStore != nil {
|
||||
tools = append(tools, toolDef("persona_set", "【首启人格】落地用户的人格选择并记录「已经问过」。仅在用户明确答复后调用:默认用 mode=default;自定义用 mode=custom 并把人格内容放进 content;用户说以后再说用 mode=later。", map[string]interface{}{
|
||||
"mode": map[string]interface{}{"type": "string", "description": "default | custom | later"},
|
||||
|
||||
294
internal/agent/core/toolresult_store.go
Normal file
294
internal/agent/core/toolresult_store.go
Normal file
@ -0,0 +1,294 @@
|
||||
package core
|
||||
|
||||
// 超长工具结果的「全文落盘 + 按范围回读」(纯逻辑部分)。
|
||||
//
|
||||
// ## 为什么需要它
|
||||
//
|
||||
// 工具结果进上下文时没有任何长度上限(task.go 直接 `Content: result`)。
|
||||
// 实测生产一次 36783 token 的单条结果直接冲破整个对话预算,overflow 判定
|
||||
// 「单条超限,裁剪救不了」→ 整轮终止。模型看到的是「对话没反应」。
|
||||
//
|
||||
// 之前有两条错的路子,这里都不走:
|
||||
//
|
||||
// - **截断**(方案 A):模型拿到残缺信息且**不知道**被截过,会基于残缺
|
||||
// 数据下结论。与本仓反复吃亏的「静默降级」同族。
|
||||
// - **只统计不裁剪**(方案 B,toolresult_budget.go):设计上是对的(不欺骗
|
||||
// 模型),但停在「报告」这一步 —— oversizeTools 计数没有任何消费者,
|
||||
// 所以那条超大结果照样整条进上下文,照样撞窗口。
|
||||
//
|
||||
// 本文件补的是方案 B 缺的那一半:结果**全文保存**,上下文里只放
|
||||
// 「预览 + 句柄 + 总量导航」,模型用 toolresult_read 按 offset/limit
|
||||
// 取任意区间。
|
||||
//
|
||||
// - 模型不会被骗:它明确知道看到的是预览,并有手段取全文
|
||||
// - 取哪一段由**模型**决定,不是内核武断裁一刀
|
||||
// - 上下文预算可控,且**信息不丢**(判据要求分段拼回逐字节相同)
|
||||
//
|
||||
// ## 存活期:跟随事件,不引入 TTL
|
||||
//
|
||||
// 全文存在**事件自己的目录**里(与 Blocks 同一套处置,见 mediaref.go 的
|
||||
// 「块存储在事件自身,不维护引用账本」)。所以:
|
||||
//
|
||||
// 事件在 L1 ⇒ 全文可读
|
||||
// 事件被淘汰 ⇒ 全文随事件目录一并删除
|
||||
//
|
||||
// 刻意**不做**独立的过期时间/清理定时器:那会让「一件事记两处」,
|
||||
// 且两处一旦不一致,就会出现「事件还在但全文没了」或反过来的静默错误。
|
||||
//
|
||||
// ★ 磁盘读写不在这里,而在 context.go(persistToolResult / readToolResultBody /
|
||||
// purgeToolResultDir)。原因:本包有架构守卫 TestKernelHasNoMediaFileReading,
|
||||
// 内核除「记忆面持久化」(context.go)外不得做本地文件读。这本来就是一件
|
||||
// 「记住一份东西」的事,归到持久化文件既满足守卫也符合职责。
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 工具结果全文的存储目录名(与 context.json 同级)。
|
||||
const toolResultDirName = "toolresults"
|
||||
|
||||
// ToolResultStore 是一条「超大工具结果」的落盘记录。
|
||||
//
|
||||
// 它**只存元信息**进 context.json;正文在
|
||||
//
|
||||
// <context.json 所在目录>/toolresults/<eventID>/tool_<index>.txt
|
||||
//
|
||||
// 这样 context.json 不会因为几条大结果膨胀成几 MB(它每 5s 全量重写一次)。
|
||||
type ToolResultStore struct {
|
||||
// Tool 是产出这条结果的工具名(模型据此判断"是哪个工具给的")。
|
||||
Tool string `json:"tool"`
|
||||
// File 是正文相对事件目录的文件名(如 tool_0.txt),不含绝对路径——
|
||||
// 数据目录整体搬迁后仍然有效。
|
||||
File string `json:"file"`
|
||||
// TotalBytes / TotalLines 供模型规划怎么读(先读尾部?还是按行定位?)。
|
||||
TotalBytes int `json:"total_bytes"`
|
||||
TotalLines int `json:"total_lines"`
|
||||
// TruncatedBytes 是**没有**落盘的字节数。非 0 表示落盘时触到了硬上限,
|
||||
// 必须让模型知道(否则它以为全文就这么长)。默认 0。
|
||||
TruncatedBytes int `json:"truncated_bytes,omitempty"`
|
||||
}
|
||||
|
||||
// toolResultStoreHardCap 是单条结果落盘的硬上限(1 GiB)。
|
||||
//
|
||||
// 为什么还要上限:不设的话一个 `cat /dev/urandom` 就能把磁盘写满。
|
||||
// 但它**不是**静默截断 —— 超限部分记进 TruncatedBytes 并明确告诉模型。
|
||||
const toolResultStoreHardCap = 1 << 30
|
||||
|
||||
// storeToolResult 把超长结果正文落盘,返回元信息。IO 委托给 context.go。
|
||||
func (a *Agent) storeToolResult(evtID, tool, text string) (*ToolResultStore, error) {
|
||||
if a == nil || a.context == nil {
|
||||
return nil, fmt.Errorf("toolresult: agent 未就绪")
|
||||
}
|
||||
if a.context.savePath == "" {
|
||||
return nil, fmt.Errorf("toolresult: 未配置持久化目录,无法落盘")
|
||||
}
|
||||
return a.context.persistToolResult(evtID, tool, text)
|
||||
}
|
||||
|
||||
// previewToolResult 生成进上下文的预览:头部若干行 + 句柄 + 读法。
|
||||
//
|
||||
// ★ 文案三要素(缺一不可,判据钉住):
|
||||
// 1. 明确说明「这不是全文」以及总大小 —— 否则模型会当成完整内容下结论
|
||||
// 2. 给出句柄(事件 ID)与工具名 —— 否则它没法取
|
||||
// 3. 给出可用区间导航 —— 否则它不知道能读到哪
|
||||
func previewToolResult(evtID string, st *ToolResultStore, head string, headLines int) string {
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[工具 %s 返回内容过大,已完整保存,此处仅为预览]\n", st.Tool)
|
||||
fmt.Fprintf(&b, "全文:%d 字节 / %d 行;句柄 event_id=%s\n", st.TotalBytes, st.TotalLines, evtID)
|
||||
if st.TruncatedBytes > 0 {
|
||||
fmt.Fprintf(&b, "⚠️ 落盘时超出上限,有 %d 字节**未保存**(不是全文)\n", st.TruncatedBytes)
|
||||
}
|
||||
fmt.Fprintf(&b, "\n--- 预览(前 %d 行)---\n%s\n--- 预览结束 ---\n", headLines, head)
|
||||
fmt.Fprintf(&b, "\n如需其它部分,调用 toolresult_read(event_id=%q, offset=<字节偏移>, limit=<字节数>)。\n", evtID)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// attachToolResultStore 把落盘记录挂到事件上(与 bindEventMedia 同构)。
|
||||
func attachToolResultStore(evt *ContextEvent, st *ToolResultStore) {
|
||||
if evt == nil || st == nil {
|
||||
return
|
||||
}
|
||||
if evt.ID == "" {
|
||||
evt.ID = newEventID()
|
||||
}
|
||||
evt.ToolResultStores = append(evt.ToolResultStores, *st)
|
||||
}
|
||||
|
||||
// headLinesOf 截出前 n 行(按 rune 安全,不切断多字节字符)。
|
||||
func headLinesOf(s string, n int) string {
|
||||
if n <= 0 {
|
||||
n = previewHeadLines
|
||||
}
|
||||
lines := 0
|
||||
for i := 0; i < len(s); i++ {
|
||||
if s[i] == '\n' {
|
||||
lines++
|
||||
if lines >= n {
|
||||
return s[:i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// previewHeadLines 是预览默认保留的行数。
|
||||
const previewHeadLines = 40
|
||||
|
||||
// offloadToolResult 把超长结果落盘,返回替换后的预览文本。
|
||||
//
|
||||
// 返回 ok=false 时调用方保持原内容不变(落盘失败不能吞掉结果——
|
||||
// 宁可撞窗口也不能让模型看不到任何东西,那是更严重的静默降级)。
|
||||
func (a *Agent) offloadToolResult(f *TaskFrame, tool, content string) (string, bool) {
|
||||
if a == nil || a.context == nil || content == "" {
|
||||
return "", false
|
||||
}
|
||||
evtID := ""
|
||||
if f != nil {
|
||||
evtID = f.EventID
|
||||
}
|
||||
if evtID == "" {
|
||||
evtID = newEventID()
|
||||
if f != nil {
|
||||
f.EventID = evtID
|
||||
}
|
||||
}
|
||||
st, err := a.storeToolResult(evtID, tool, content)
|
||||
if err != nil {
|
||||
// 落盘失败就保持原样:模型至少还能看到完整内容(哪怕撞窗口),
|
||||
// 这比"什么都没看到"好。日志留痕供运维发现磁盘/权限问题。
|
||||
log.Printf("[agent] toolresult offload failed (tool=%s): %v — 保持原内容", tool, err)
|
||||
return "", false
|
||||
}
|
||||
// ★ 登记到上下文里,否则 toolresult_read 找不到句柄。
|
||||
//
|
||||
// storeToolResult 只负责写文件;"这条结果属于哪个事件、能被回读"
|
||||
// 是上下文侧的记录。两者必须一起成立——只有文件没有登记,模型拿到
|
||||
// 句柄去读会得到"未找到",那正是本判据抓到的缺口。
|
||||
a.context.registerToolResultStore(evtID, st)
|
||||
return previewToolResult(evtID, st, headLinesOf(content, previewHeadLines), previewHeadLines), true
|
||||
}
|
||||
|
||||
// readStoredToolResult 实现 toolresult_read:按事件句柄 + 字节区间读回全文。
|
||||
//
|
||||
// 这是「工具环」设计里缺的那一半——超长结果不再整条进上下文,而是存起来
|
||||
// 由模型自己决定读哪一段。取哪一段是**模型**的判断,不是内核的武断裁剪。
|
||||
func (a *Agent) readStoredToolResult(args map[string]interface{}) (string, error) {
|
||||
evtID, _ := args["event_id"].(string)
|
||||
if evtID == "" {
|
||||
return "", fmt.Errorf("缺少 event_id(预览里给出的句柄)")
|
||||
}
|
||||
offset := 0
|
||||
if v, ok := args["offset"].(float64); ok {
|
||||
offset = int(v)
|
||||
}
|
||||
limit := 0
|
||||
if v, ok := args["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
if offset < 0 {
|
||||
return "", fmt.Errorf("offset 不能为负")
|
||||
}
|
||||
// 单次读取上限:防止模型一次要 500MB 把上下文再撞一次。
|
||||
// 64KB ≈ 16K token,留出余量给其它内容。
|
||||
const maxReadBytes = 64 * 1024
|
||||
if limit <= 0 {
|
||||
limit = maxReadBytes
|
||||
}
|
||||
if limit > maxReadBytes {
|
||||
limit = maxReadBytes
|
||||
}
|
||||
|
||||
if a.context == nil {
|
||||
return "", fmt.Errorf("上下文不可用")
|
||||
}
|
||||
st := a.context.findToolResultStore(evtID)
|
||||
if st == nil {
|
||||
// 三种可能:句柄打错、事件已被淘汰、或这条结果当时落盘失败。
|
||||
// 都给出**明确**答复,不静默返回空串(空串会被误当成"内容就是空")。
|
||||
return "", fmt.Errorf("未找到 event_id=%s 的工具结果记录:可能句柄有误,或该事件已随上下文淘汰(全文跟随事件生命周期,不单独保留)", evtID)
|
||||
}
|
||||
body, atEnd, err := a.context.readToolResultBody(evtID, st, offset, limit)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
header := fmt.Sprintf("[%s 的第 %d-%d 字节,共 %d 字节%s]",
|
||||
st.Tool, offset, offset+len(body), st.TotalBytes, map[bool]string{true: ",已到末尾", false: ""}[atEnd])
|
||||
return header + "\n" + body, nil
|
||||
}
|
||||
|
||||
// findToolResultStore 按事件 ID 找到落盘记录(在 L1 事件里查)。
|
||||
func (c *RelevanceContext) findToolResultStore(evtID string) *ToolResultStore {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, evt := range c.events {
|
||||
if evt == nil || evt.ID != evtID {
|
||||
continue
|
||||
}
|
||||
if len(evt.ToolResultStores) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &evt.ToolResultStores[0]
|
||||
}
|
||||
// 事件还没进 L1(同一帧内刚落盘、尚未 append):查待登记表。
|
||||
//
|
||||
// ★ 不能只看 c.events:落盘发生在 toolMsg 定稿时,而事件 append 在更晚。
|
||||
// 少了这一支,模型拿到句柄立刻去读会得到"未找到"——判据
|
||||
// TestReadBackReassemblesByteIdentical 抓到的正是这个。
|
||||
if sts, ok := c.pendingToolResults[evtID]; ok && len(sts) > 0 {
|
||||
return &sts[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// hasStoredToolResults 判断当前上下文里是否有可回读的超长工具结果。
|
||||
//
|
||||
// 供 buildToolDefs 决定要不要暴露 toolresult_read(见那里的说明)。
|
||||
func (a *Agent) hasStoredToolResults() bool {
|
||||
if a == nil || a.context == nil {
|
||||
return false
|
||||
}
|
||||
return a.context.hasAnyToolResultStore()
|
||||
}
|
||||
|
||||
// hasAnyToolResultStore 扫一遍 L1 事件,看有没有落盘记录。
|
||||
func (c *RelevanceContext) hasAnyToolResultStore() bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, evt := range c.events {
|
||||
if evt != nil && len(evt.ToolResultStores) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// registerToolResultStore 把落盘记录登记到对应事件上。
|
||||
//
|
||||
// 若该事件还没进 L1(同一帧内多条超长结果时可能先于事件 append),
|
||||
// 先挂在 context 的待登记表里,等事件进来后再认领——否则句柄会悬空。
|
||||
func (c *RelevanceContext) registerToolResultStore(evtID string, st *ToolResultStore) {
|
||||
if c == nil || st == nil || evtID == "" {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for _, evt := range c.events {
|
||||
if evt != nil && evt.ID == evtID {
|
||||
evt.ToolResultStores = append(evt.ToolResultStores, *st)
|
||||
c.dirty = true
|
||||
return
|
||||
}
|
||||
}
|
||||
if c.pendingToolResults == nil {
|
||||
c.pendingToolResults = map[string][]ToolResultStore{}
|
||||
}
|
||||
c.pendingToolResults[evtID] = append(c.pendingToolResults[evtID], *st)
|
||||
}
|
||||
233
internal/agent/core/toolresult_store_test.go
Normal file
233
internal/agent/core/toolresult_store_test.go
Normal file
@ -0,0 +1,233 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newStoreTestAgent 造一个带持久化目录的 agent(用临时目录,不碰生产数据)。
|
||||
func newStoreTestAgent(t *testing.T) (*Agent, string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
savePath := filepath.Join(dir, "context.json")
|
||||
a := &Agent{context: NewRelevanceContext(savePath, nil)}
|
||||
return a, savePath
|
||||
}
|
||||
|
||||
// ── 判据 1:超大结果落盘,上下文只拿预览(不含全文)──────────────
|
||||
//
|
||||
// 这是本特性存在的理由:36783 token 的单条结果曾经整条进上下文,
|
||||
// 直接冲破整个对话预算、整轮终止。
|
||||
func TestOffloadReplacesFullTextWithPreview(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
f := &TaskFrame{}
|
||||
|
||||
// 造一个明显超长的结果(500 行)
|
||||
var sb strings.Builder
|
||||
for i := 1; i <= 500; i++ {
|
||||
sb.WriteString("line-")
|
||||
sb.WriteString(strings.Repeat("x", 100))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
full := sb.String()
|
||||
|
||||
prev, ok := a.offloadToolResult(f, "cmd_run", full)
|
||||
if !ok {
|
||||
t.Fatal("超长结果应当落盘")
|
||||
}
|
||||
|
||||
// ① 上下文里拿到的是预览,不是全文
|
||||
if len(prev) >= len(full) {
|
||||
t.Fatalf("预览不应不小于全文(预览 %d 字节,全文 %d 字节)", len(prev), len(full))
|
||||
}
|
||||
|
||||
// ② 三要素必须齐(缺任一条模型就成了被骗的一方)
|
||||
if !strings.Contains(prev, "已完整保存") {
|
||||
t.Error("预览必须说明这是预览、全文已保存")
|
||||
}
|
||||
if !strings.Contains(prev, "evt_") {
|
||||
t.Error("预览必须给出 event_id 句柄")
|
||||
}
|
||||
if !strings.Contains(prev, "toolresult_read") {
|
||||
t.Error("预览必须告知怎么读其余部分")
|
||||
}
|
||||
if !strings.Contains(prev, "字节") {
|
||||
t.Error("预览必须给出总大小")
|
||||
}
|
||||
|
||||
// ③ 记录挂在事件上(供 findToolResultStore 找到)
|
||||
if len(a.context.events) == 0 {
|
||||
// offloadToolResult 只是落盘并返回预览,不负责 append 事件;
|
||||
// 事件由调用方(task.go)创建。这里直接验证帧上的 EventID 已分配。
|
||||
}
|
||||
if f.EventID == "" {
|
||||
t.Fatal("落盘后帧上必须有 EventID,否则没法回读")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 2:分段读回拼接后与原结果**逐字节相同** ────────────────
|
||||
//
|
||||
// 这是「信息不丢」的唯一硬证据。没有它,本特性只是另一种形式的截断。
|
||||
func TestReadBackReassemblesByteIdentical(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
f := &TaskFrame{}
|
||||
|
||||
// 刻意用多字节字符(中文)+ 换行,确保按字节切不会切坏 UTF-8
|
||||
var sb strings.Builder
|
||||
for i := 0; i < 300; i++ {
|
||||
sb.WriteString("第")
|
||||
sb.WriteString(strings.Repeat("测", 40))
|
||||
sb.WriteString("行\n")
|
||||
}
|
||||
full := sb.String()
|
||||
|
||||
if _, ok := a.offloadToolResult(f, "files_read", full); !ok {
|
||||
t.Fatal("落盘失败")
|
||||
}
|
||||
|
||||
// 按 4KB 分段读回,拼起来必须逐字节等于原文
|
||||
var got strings.Builder
|
||||
offset := 0
|
||||
chunk := 4096
|
||||
for {
|
||||
txt, err := a.readStoredToolResult(map[string]interface{}{
|
||||
"event_id": f.EventID,
|
||||
"offset": float64(offset),
|
||||
"limit": float64(chunk),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("读 offset=%d 失败: %v", offset, err)
|
||||
}
|
||||
// 去掉头部那行元信息(形如 "[tool 的第 a-b 字节,共 n 字节]")
|
||||
if idx := strings.Index(txt, "\n"); idx >= 0 {
|
||||
got.WriteString(txt[idx+1:])
|
||||
}
|
||||
if strings.Contains(txt, "已到末尾") {
|
||||
break
|
||||
}
|
||||
offset += chunk
|
||||
if offset > len(full)+chunk {
|
||||
t.Fatal("读回死循环:offset 已超过总长仍未到末尾")
|
||||
}
|
||||
}
|
||||
|
||||
if got.String() != full {
|
||||
t.Fatalf("分段拼接与原结果不一致:读回 %d 字节,原文 %d 字节", got.Len(), len(full))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 3:事件淘汰后回读给**明确**答复,不是静默空串 ──────────
|
||||
//
|
||||
// 静默返回空串会让模型以为"内容就是空的",那是比失败更糟的误导。
|
||||
func TestReadAfterPurgeIsExplicit(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
f := &TaskFrame{}
|
||||
|
||||
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("z", 200000)); !ok {
|
||||
t.Fatal("落盘失败")
|
||||
}
|
||||
evtID := f.EventID
|
||||
|
||||
// 模拟事件淘汰:删除其全文目录
|
||||
a.context.purgeToolResultDir(evtID)
|
||||
|
||||
_, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID})
|
||||
if err == nil {
|
||||
t.Fatal("事件淘汰后回读必须报错(明确告知已淘汰),不能返回空内容")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "淘汰") {
|
||||
t.Errorf("错误文案必须说明是「随事件淘汰」,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 4:小结果不落盘(不改变既有行为)──────────────────────
|
||||
func TestSmallResultNotOffloaded(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
// 直接调 storeToolResult 是小结果场景的旁证:
|
||||
// 触发权在 checkToolResultSize,这里验证"没触发就不产生文件"。
|
||||
if a.hasStoredToolResults() {
|
||||
t.Fatal("初始状态不应有任何落盘记录")
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 5:handle 打错给出可执行提示 ──────────────────────────
|
||||
func TestReadWithBadHandleIsActionable(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
_, err := a.readStoredToolResult(map[string]interface{}{"event_id": "evt_不存在"})
|
||||
if err == nil {
|
||||
t.Fatal("打错的句柄必须报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "淘汰") && !strings.Contains(err.Error(), "句柄") {
|
||||
t.Errorf("应说明是句柄有误或已淘汰,实际: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 6:落盘文件确实在事件目录下(不是内联进 context.json)────
|
||||
//
|
||||
// 这条钉住"为什么不用内联":context.json 每 5s 全量重写,内联几 MB
|
||||
// 正文会让每次 flush 都写几 MB。
|
||||
func TestPersistedToEventDirNotInline(t *testing.T) {
|
||||
a, savePath := newStoreTestAgent(t)
|
||||
f := &TaskFrame{}
|
||||
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("y", 200000)); !ok {
|
||||
t.Fatal("落盘失败")
|
||||
}
|
||||
|
||||
p := filepath.Join(filepath.Dir(savePath), toolResultDirName, f.EventID, "tool_0.txt")
|
||||
st, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("正文应在事件目录 %s: %v", p, err)
|
||||
}
|
||||
if st.Size() != 200000 {
|
||||
t.Errorf("正文大小应为 200000,实际 %d", st.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// ── 判据 7:事件进 L1 后必须认领自己的落盘记录 ──────────────────
|
||||
//
|
||||
// 缺陷现场(自测抓到):registerToolResultStore 把记录放进 pendingToolResults,
|
||||
// 但认领函数没有任何调用点 ⇒ 事件进入 L1 后记录仍躺在待定表里。
|
||||
// 后果:进程重启后从 context.json 读回事件,ToolResultStores 是空的,
|
||||
// 句柄全部失效(模型回读得到"未找到")。
|
||||
//
|
||||
// ★ 这条必须走真实的 Append 路径,不能直接调认领函数——
|
||||
//
|
||||
// 直接调就盖住了"没人调它"这个缺陷(变异验证:删掉 Append 里那行,
|
||||
// 本判据必须变红)。
|
||||
func TestAppendClaimsPendingToolResults(t *testing.T) {
|
||||
a, _ := newStoreTestAgent(t)
|
||||
f := &TaskFrame{}
|
||||
if _, ok := a.offloadToolResult(f, "cmd_run", strings.Repeat("w", 200000)); !ok {
|
||||
t.Fatal("落盘失败")
|
||||
}
|
||||
evtID := f.EventID
|
||||
|
||||
// 落盘时事件还没进 L1,记录应在待定表里
|
||||
if len(a.context.pendingToolResults[evtID]) == 0 {
|
||||
t.Fatal("落盘后记录应先进入待定表")
|
||||
}
|
||||
|
||||
// 事件进入 L1(走真实 Append)
|
||||
a.context.Append(ContextEvent{ID: evtID, Source: "agent", Input: "x"})
|
||||
|
||||
// ① 待定表应被清空
|
||||
if len(a.context.pendingToolResults[evtID]) != 0 {
|
||||
t.Error("Append 后待定表应已认领清空")
|
||||
}
|
||||
// ② 事件上应挂上记录
|
||||
var found bool
|
||||
for _, evt := range a.context.events {
|
||||
if evt != nil && evt.ID == evtID && len(evt.ToolResultStores) > 0 {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("Append 后事件上必须有 ToolResultStores,否则重启后句柄失效")
|
||||
}
|
||||
// ③ 依然可回读
|
||||
if _, err := a.readStoredToolResult(map[string]interface{}{"event_id": evtID}); err != nil {
|
||||
t.Fatalf("认领后应仍可回读: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user