package core import ( "encoding/json" "fmt" "io" "log" "os" "path/filepath" "sort" "strings" "sync" "time" "github.com/JianFeeeee/HologramAgent/internal/memory" "github.com/JianFeeeee/HologramAgent/internal/memory/document" "github.com/JianFeeeee/HologramAgent/internal/memory/vector" sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" ) type ToolResultItem struct { Name string `json:"name"` Output string `json:"output"` } type ContextEvent struct { // ID 是事件的稳定标识。惰性生成:只有真的要挂媒体块时才赋值。 // // 全量生成会让每条事件都多一个字段进 context.json,而绝大多数对话没有媒体。 // omitempty 保证存量 context.json 读回来时该字段为空,不影响任何既有行为。 ID string `json:"id,omitempty"` Timestamp time.Time `json:"timestamp"` Source string `json:"source"` Input string `json:"input"` Response string `json:"response,omitempty"` ToolsUsed []string `json:"tools_used,omitempty"` ToolResults []ToolResultItem `json:"tool_results,omitempty"` // ToolResultStores 是本事件捕获的「超大工具结果」落盘记录。 // // ★ 与 Blocks 同一套处置:正文随事件目录持久化,**不维护引用账本**, // 存活期由事件的存活期决定(事件离开 L1 ⇒ 正文目录一并删除)。 // 见 toolresult_store.go 的说明。 // // 这里只存元信息(工具名、文件名、总字节/行数),正文在 // /toolresults//tool_N.txt —— 因为 // context.json 每 5s 全量重写一次,内联大正文会让每次 flush 都写几 MB。 ToolResultStores []ToolResultStore `json:"tool_result_stores,omitempty"` // NoMemory 标记这条事件**不参与记忆计算**(不向量化、不提关键词、不蒸馏), // 但**照常参与归档**(离开 L1 时会写回 L2)。 // // ★ 与 ToolDef.NoMemory 语义一致:只影响计算层,不影响存储层。 // // ★★ 为什么不能顺手把归档也关掉(2026-10-22 踩过的坑): // // doc_query 的 Consume 已经**从 L2 删除**了原文。若回填事件在 L1 // 被裁掉时又不归档,它就**三层都不在**了 —— 内容被永久丢弃。 // L1 是它最后的落脚点,必须能落回去。 // // 所以"回填事件不再归档"是错的。它确实该重新归档(那才是迁移闭环), // 该抑制的只是"重新计算向量/再次进蒸馏"—— 那才是自激的入口。 NoMemory bool `json:"no_memory,omitempty"` // --- 原生多模态记忆 --- // 一等记忆块:块本身随事件在层间迁移,身份不变,不建引用计数。 Blocks []memory.MemoryBlock `json:"blocks,omitempty"` // 一等记忆块(text/image/video/audio) Vector vector.Vector `json:"-"` // 稀疏词向量(TF-IDF/fastText 空间) DenseVec []float64 `json:"-"` // 稠密多模态向量(与媒体/文档共享空间) DenseFP string `json:"-"` // DenseVec 所属统一空间指纹(缓存字段,不持久化) } const contextFlushInterval = 5 * time.Second type RelevanceContext struct { 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 是裁剪时**无条件保留**的最近事件条数。 // // 为何要可调:它决定「近处信息」与「向量检索」的权重 —— // 条数大则不容易丢近处,小则更依赖检索准确度。不同窗口/不同用法 // (如长期助手 vs 短任务)适合的值不同,所以从 core.agent.context.* 传入。 // 零值 ⇒ defaultProtectedCount(与历史硬编码 10 一致)。 // // ★ 它现在是**上限**而非固定值:实际保护条数由 effectiveProtectedCount // 按预算收紧(见该函数)。原因见那里。 protectedCount int } // effectiveProtectedCount 返回本次裁剪**实际**保护多少条。 // // ★ 为什么不能固定用 protectedCount(T10c 实测踩出来的自相矛盾): // 保护条数 × 单条事件平均 token 可能**超过整个 ContextTokens 预算**—— // 实测 HA 在 50k 窗口下单条事件约 5800 token(工具回灌型),而预算约 40000, // 于是「钉住 10 条」本身就装不下。后果是裁剪无解:每次只能裁掉零星1-2 条 // 就撞到 protected 下限,积累量仍超页 ⇒ 每轮触发两次超页 ⇒ 两轮后恢复预算 // 耗尽 ⇒ 任务直接终止(实测轮5/6 的 prompt=0)。召回率因此从 44% 掉到 22%。 // // 现在的规则:**保护上限仍由配置给,但不得超过预算能容纳的条数**。 // budgetCap = (ContextTokens 预算) / (单条平均 token)。取min(配置值, budgetCap)。 // 于是: // - 小窗口 / 大事件 ⇒ 自动收紧(宁可有取舍,也不让裁剪无解); // - 大窗口(如 1M)或小事件 ⇒ 仍用满配置值 —— 这正是「1M 下这套调度器 // 能更好」的实现方式:预算越大,可保护的近处越多。 // // 下限保1:protected=0 会让「最近 N 条一定在候选里」这条保证消失, // 而 Prune 的 protected 切片是 `events[len-protected:]` —— 0 值会切出空切片 // 后仍走 keep 逻辑,虽不panic,但等于放弃了近处保护的意义。 func (a *Agent) effectiveProtectedCount() int { cfgCap := defaultProtectedCount if n := a.context.ProtectedCount(); n > 0 { cfgCap = n } budget := a.computeTokenBudget() tokens := budget.ContextTokens if tokens <= 0 || a.context == nil || a.context.Len() == 0 { return cfgCap // 没有样本/预算,按配置来 } avg := a.accumulatedTokens() / a.context.Len() if avg <= 0 { avg = defaultAvgEventTokens } budgetCap := tokens / avg if budgetCap < 1 { budgetCap = 1 } if budgetCap < cfgCap { return budgetCap } return cfgCap } // singleEventFitsBudget 报告「单条事件是否就装不进预算」。 // // 为什么单独判:effectiveProtectedCount 收紧到 1 条仍可能装不下 —— 当单条 // 事件本身就大于 ContextTokens 预算时(实测 T10c:工具回灌型事件可达 40026 // token 而预算只有 10667),裁剪在**任何**保护数下都无解。 // // 此时正确做法不是继续收紧(收紧也没用),而是让调用方知道「裁剪帮不上忙」, // 由超页处理走终止路径并报出真实原因,而不是反复裁剪直到预算耗尽。 func (a *Agent) singleEventFitsBudget() bool { if a == nil || a.context == nil || a.context.Len() == 0 { return true } avg := a.accumulatedTokens() / a.context.Len() if avg <= 0 { avg = defaultAvgEventTokens } return avg <= a.computeTokenBudget().ContextTokens } // ProtectedCount 返回配置给的保护条数**上限**(实际值见 effectiveProtectedCount)。 func (c *RelevanceContext) ProtectedCount() int { if c == nil || c.protectedCount <= 0 { return defaultProtectedCount } return c.protectedCount } func NewRelevanceContext(savePath string, embedder *memory.StaticEmbedder) *RelevanceContext { rc := &RelevanceContext{ embedder: embedder, savePath: savePath, protectedCount: defaultProtectedCount, } if savePath != "" { rc.load() } return rc } // SetDenseSpace 注入稠密多模态向量空间。配置后 L0 相关性裁剪可用稠密向量 // 余弦(与媒体检索、文档检索共享同一空间),未配置时退化到稀疏词向量。 // // 注入时**回填已有事件**的稠密向量。为什么必须回填:NewRelevanceContext 先 // load()、再 SetDenseSpace,载入时 c.denseSpace 还是 nil,旧事件只算了稀疏 // 向量;若这里只赋值不回填,Prune 里旧事件因 DenseFP 为空、长度不符而全部 // 走稀疏余弦,新事件走稠密余弦 —— 同一次排序里两种尺度混排,谁留下谁归档 // 取决于事件新旧而非相关性。对齐 DocStore.BuildDenseIndex 的做法。 // // 注意 DenseVec/DenseFP 刻意不持久化(json:"-"):这是每次启动一次性重算的 // 缓存,不落盘,因此这里也不需要 Save。 func (c *RelevanceContext) SetDenseSpace(ds vector.MultimodalEmbedder) { c.mu.Lock() defer c.mu.Unlock() c.denseSpace = ds if ds == nil || !ds.Loaded() { return } fp := ds.Fingerprint() dim := ds.Dim() filled := 0 for _, evt := range c.events { if evt == nil { continue } if evt.DenseFP == fp && len(evt.DenseVec) == dim { continue } c.computeVector(evt) filled++ } if filled > 0 { log.Printf("[agent] context dense backfill: %d events", filled) } } func (c *RelevanceContext) SetToolDefLookup(fn func(name string) *sdk.ToolDef) { c.mu.Lock() defer c.mu.Unlock() c.toolDefLookup = fn } func (c *RelevanceContext) SetChannelDefLookup(fn func(name string) (sdk.ChannelDef, bool)) { c.mu.Lock() defer c.mu.Unlock() c.channelDefLookup = fn } func (c *RelevanceContext) load() { data, err := os.ReadFile(c.savePath) if err != nil { return } var events []*ContextEvent if err := json.Unmarshal(data, &events); err != nil { return } for _, evt := range events { c.computeVector(evt) } c.events = events } func textForVector(evt *ContextEvent, toolDefLookup func(name string) *sdk.ToolDef, channelDefLookup func(name string) (sdk.ChannelDef, bool)) string { // 事件自带 NoMemory(doc_query 回填的历史事件):不进计算层。 // // 为什么回填事件不参与计算:它们的内容刚刚在 L2 里被向量化过一次, // 再算一次只会让"同一内容"在向量空间里被重复加权。但它们**照常归档**。 if evt.NoMemory { return "" } var text string switch { case evt.Source == "agent" && evt.Response != "": text = evt.Response case evt.Source == "cold_storage": text = evt.Input + " " + evt.Response default: text = evt.Input } // 计算层:应用输入通道的 Cleaner(不改原文,仅在计算层清洗) if channelDefLookup != nil { if chDef, ok := channelDefLookup(evt.Source); ok && chDef.Cleaner != nil { text = chDef.Cleaner(text) } if chDef, ok := channelDefLookup(evt.Source); ok && chDef.NoMemory { return "" } } // 计算层:附加工具输出,NoMemory 跳过,其余经 Cleaner 过滤 { noMemory := make(map[string]bool) for _, tr := range evt.ToolResults { // ① 插件工具(含 proc 桥):走 StageHost 的 ToolDef 声明 if toolDefLookup != nil { if def := toolDefLookup(tr.Name); def != nil && def.NoMemory { noMemory[tr.Name] = true continue } } // ② 内置工具(core 自己的 switch 分派):走包级声明表。 // // ★ doc_query 属于这一类 —— 它的返回值含被召回事件的正文片段, // 而那些正文已在记忆体系里(来自 L2/L3)。若返回值再参与向量化 // 与蒸馏,就是把同一内容重新注入记忆,与「回填事件不再归档」 // 构成同一个自激回路的两个入口。 if noMemoryOf(tr.Name) { noMemory[tr.Name] = true } } for _, tr := range evt.ToolResults { if noMemory[tr.Name] { continue } cleaned := tr.Output if toolDefLookup != nil { if def := toolDefLookup(tr.Name); def != nil && def.Cleaner != nil { cleaned = def.Cleaner(cleaned) } } text += " " + cleaned } } return memory.CleanText(text) } // toolOutputClean 根据工具定义的 NoMemory/Cleaner 清洗输出,用于计算层。 // 返回 "" 表示跳过(NoMemory),否则返回清洗后文本(Cleaner 或原文)。 func (c *RelevanceContext) toolOutputClean(name, output string) string { if c.toolDefLookup == nil { return output } def := c.toolDefLookup(name) if def == nil { return output } if def.NoMemory { return "" } if def.Cleaner != nil { return def.Cleaner(output) } return output } // inputChannelClean 根据输入通道的 Def 清洗输入文本,用于计算层。 func (c *RelevanceContext) inputChannelClean(source, input string) string { if c.channelDefLookup == nil { return input } chDef, ok := c.channelDefLookup(source) if !ok { return input } if chDef.Cleaner != nil { return chDef.Cleaner(input) } return input } // channelCleanerForDoc 返回 ChannelCleaner,使 document 包在存档时能按来源查找 Cleaner。 func (c *RelevanceContext) channelCleanerForDoc() document.ChannelCleaner { if c.channelDefLookup == nil { return nil } return func(source string) func(string) string { chDef, ok := c.channelDefLookup(source) if !ok { return nil } return chDef.Cleaner } } func (c *RelevanceContext) computeVector(evt *ContextEvent) { text := textForVector(evt, c.toolDefLookup, c.channelDefLookup) // 稀疏向量始终计算(TF-IDF/fastText,退化时仍可用) if text != "" { evt.Vector = c.embedder.Vectorize(text) } // 稠密向量:文本向量 ⊕ 本事件持有的一等记忆块媒体向量(同一统一空间)。 // 只有媒体的输入(无文本)也要有可比较的坐标,因此不再按 text=="" 提前返回。 if c.denseSpace != nil && c.denseSpace.Loaded() { fp := c.denseSpace.Fingerprint() var parts [][]float64 if text != "" { if dv, err := c.denseSpace.VectorizeDense(text); err == nil && len(dv) > 0 { parts = append(parts, dv) } } for _, b := range evt.Blocks { // 只融合同指纹的块向量:另一套坐标系的向量混进来会算出 // 两边都不像的方向。 if len(b.Vector) > 0 && b.Fingerprint == fp { parts = append(parts, b.Vector) } } evt.DenseVec = vector.FuseVectors(parts...) evt.DenseFP = fp } } func (c *RelevanceContext) Save() error { if c.savePath == "" { return nil } if err := os.MkdirAll(filepath.Dir(c.savePath), 0755); err != nil { return err } data, err := json.Marshal(c.events) if err != nil { return err } return os.WriteFile(c.savePath, data, 0644) } // Append 追加一条事件。 // // ★ 这里是**持久化记忆的唯一咽喉**:全部 5 处写 context 的调用都经过它 // (另一处 InsertByTimestamp 同样处理)。所以明文拦截放在这里, // 而不是在每个调用点 —— 逐个补必漏(实测踩到:只拦了 f.Msgs, // 而 f.CleanInput / f.ToolResults / f.Response 都是独立副本)。 func (c *RelevanceContext) Append(evt ContextEvent) { c.mu.Lock() defer c.mu.Unlock() scrubEventSecrets(&evt) 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) }) c.events = append(c.events, nil) copy(c.events[idx+1:], c.events[idx:]) c.events[idx] = &evt c.save() } func (c *RelevanceContext) save() error { if c.savePath == "" { return nil } if !c.dirty { c.dirty = true if c.saveTimer == nil { c.saveTimer = time.AfterFunc(contextFlushInterval, c.flush) } else { c.saveTimer.Reset(contextFlushInterval) } } return nil } func (c *RelevanceContext) flush() { c.mu.Lock() defer c.mu.Unlock() if !c.dirty { return } data, err := json.Marshal(c.events) if err != nil { return } if err := os.WriteFile(c.savePath, data, 0644); err != nil { return } c.dirty = false } // scoredEvent 是 Prune 里按相关度排序的事件。 // // 提为包级类型:Prune 需要把待归档列表传给后续处理。 type scoredEvent struct { event *ContextEvent score float64 idx int } // pCount <= 0 时用protectedCount 上限。调用方(pruneByQuery)传 // effectiveProtectedCount,按预算收紧后的值。 func (c *RelevanceContext) Prune(currentInput string, topK int, docStore *document.Store) int { return c.PruneWithProtected(currentInput, topK, docStore, 0) } // PruneWithProtected 是带显式保护条数的 Prune。 func (c *RelevanceContext) PruneWithProtected(currentInput string, topK int, docStore *document.Store, pCount int) int { c.mu.Lock() defer c.mu.Unlock() if len(c.events) <= topK { return 0 } if pCount <= 0 { pCount = c.ProtectedCount() } if pCount > len(c.events) { pCount = len(c.events) } protected := c.events[len(c.events)-pCount:] candidates := c.events[:len(c.events)-pCount] if len(candidates) == 0 { return 0 } // 优先使用稠密向量余弦(与媒体/文档共享空间);退化到稀疏词向量。 var queryDense []float64 useDense := false queryFP := "" if c.denseSpace != nil && c.denseSpace.Loaded() { if dv, err := c.denseSpace.VectorizeDense(currentInput); err == nil { queryDense = dv queryFP = c.denseSpace.Fingerprint() useDense = true } } queryVec := c.embedder.VectorizeClean(currentInput) scoredEvents := make([]scoredEvent, len(candidates)) for i, evt := range candidates { var score float64 // 只在同一统一空间内比稠密余弦:换了模型/维度后旧事件的向量 // 属于另一个坐标系,拿来比会得到无意义的分数。 if useDense && evt.DenseFP == queryFP && len(evt.DenseVec) == len(queryDense) { score = vector.DenseCosine(queryDense, evt.DenseVec) } else { score = vector.CosineSimilarity(queryVec, evt.Vector) } scoredEvents[i] = scoredEvent{event: evt, score: score, idx: i} } sort.Slice(scoredEvents, func(i, j int) bool { return scoredEvents[i].score > scoredEvents[j].score }) keepCount := topK - len(protected) if keepCount < 0 { keepCount = 0 } keep := scoredEvents if len(keep) > keepCount { keep = keep[:keepCount] } archive := scoredEvents[keepCount:] c.events = make([]*ContextEvent, 0, len(keep)+len(protected)) for _, s := range keep { c.events = append(c.events, s.event) } c.events = append(c.events, protected...) sort.Slice(c.events, func(i, j int) bool { return c.events[i].Timestamp.Before(c.events[j].Timestamp) }) archived := 0 if docStore != nil && len(archive) > 0 { // ★ 回填事件(NoMemory)**照常归档** —— 这是数据不丢的关键。 // // 曾经在这里跳过它们(理由是"防自激"),那是错的: // doc_query 的 Consume 已经把 L2 副本删掉了,L1 是唯一副本。 // 跳过归档 = 裁掉即丢失(L1、L2、L3 三层都不在)。 // // 自激的入口不在"归档"这一侧,而在"重新计算向量"那一侧 —— // 那已经由 textForVector 里 `if evt.NoMemory { return "" }` 挡住。 } if docStore != nil && len(archive) > 0 { entries := make([]document.ContextEntry, len(archive)) for i, s := range archive { entries[i] = document.ContextEntry{ Timestamp: s.event.Timestamp, Source: s.event.Source, Content: s.event.Input, Response: s.event.Response, ToolResults: convertToolResults(s.event.ToolResults), Blocks: append([]memory.MemoryBlock(nil), s.event.Blocks...), } } doc, err := docStore.ContextToDoc("context_archived", entries, c.embedder, nil, c.toolOutputClean, c.channelCleanerForDoc()) if err == nil && doc != nil { archived = len(entries) // 一等记忆块的迁移:块随归档事件离开 L0、进入 L2。 // 迁移的是块本身(ID 不变、只换持有层),不是复制也不是保活引用; // 因此归档后清空源事件的块,确保同一块不同时留在两层。 for _, s := range archive { if s.event != nil { 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 } } } } c.save() return archived } func (c *RelevanceContext) Format() string { c.mu.Lock() defer c.mu.Unlock() if len(c.events) == 0 { return "" } var sb strings.Builder sb.WriteString("【近期事件】\n") for _, e := range c.events { sb.WriteString(fmt.Sprintf("[%s] %s: %s", e.Timestamp.Format("15:04:05"), e.Source, e.Input)) if e.Response != "" { sb.WriteString(fmt.Sprintf(" → %s", truncateStr(e.Response, 80))) } sb.WriteString("\n") } return sb.String() } func (c *RelevanceContext) Recent(n int) []ContextEvent { c.mu.Lock() defer c.mu.Unlock() if n <= 0 || n > len(c.events) { n = len(c.events) } result := make([]ContextEvent, n) for i, evt := range c.events[len(c.events)-n:] { result[i] = *evt } return result } // Blocks 返回当前上下文持有的一等记忆块(供跨层存活判定)。 // 迁移后源事件已被清空,因此这里只会拿到真正属于 L0 的块。 func (c *RelevanceContext) Blocks() []memory.MemoryBlock { c.mu.Lock() defer c.mu.Unlock() var out []memory.MemoryBlock for _, e := range c.events { out = append(out, e.Blocks...) } return out } // TrimKeepRecent 只保留最近 n 条事件,丢弃更旧的(返回丢弃条数)。 // // 这是**压缩上下文**(保留语义)的机械原语:不归档、不写任何记忆,直接丢弃旧事件。 // 用于轻量内核(驻留子):它没有 doc 记忆与记忆整理流水线,压缩只能是"保留最近的"。 func (c *RelevanceContext) TrimKeepRecent(n int) int { c.mu.Lock() if n < 1 { n = 1 } if len(c.events) <= n { c.mu.Unlock() return 0 } dropped := len(c.events) - n kept := make([]*ContextEvent, n) copy(kept, c.events[dropped:]) c.events = kept c.dirty = true c.mu.Unlock() c.save() return dropped } func (c *RelevanceContext) Len() int { c.mu.Lock() defer c.mu.Unlock() return len(c.events) } func convertToolResults(items []ToolResultItem) []document.ToolResultItem { if items == nil { return nil } result := make([]document.ToolResultItem, len(items)) for i, item := range items { result[i] = document.ToolResultItem{Name: item.Name, Output: item.Output} } 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) } }