package gateway import ( "context" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // 本文件是「子代理结果注记**绝不进记忆抽取**」这道防线(M32)。它是 // `memory.Extractor` 的装饰器,由**组合根**套在循环的提取器上 // (`cmd/cyrene/wire_runtime.go` 的 `Extractor:` 一行)。 // // 为什么需要它(不是理论问题,是「看着有、库里没有」的反面): // // - 回填注记是一条**已落库的系统消息**,因此它会被下一回合的转录播种 // (`transcript.Read`)读进该回合的 `msgs`,而循环在回合落定时把这整份 `msgs` // 交给提取器(`loop.extractMemoryAsync`)。不挡,子代理的结论就会被当成对话, // 由 LLM 提取器判断「值得记住」后写进记忆库 —— 那正是刚查过的那条污染线 // (84% 记忆来自内部会话),绝不能再添一条。 // - **判据必须是元数据**(AGENTS §9「身份/判据靠元数据,不靠提示词」):这里按 // `role == system` **且** 正文以 `subagentNoteMarker` 开头来识别,两者都是 // **结构性事实**(该标记由本包的注记写入方唯一产出,与 loop 的 `summaryMarker` // 同一手法),绝不靠「提示词里让模型别提取」。 // // 它只影响**喂给提取器的输入**:落库行、`message_persisted` 帧、下一回合模型看到的 // 系统注记、历史接口全部逐字不变 —— 被挡掉的只有「这条注记变成一条记忆」这一件事。 // memoryAsyncExtractor 是 memory.LLMExtractor 的异步能力(与 loop 包内部的 // asyncExtractor 同形)。装饰器必须在**装饰后仍然异步**,否则一次慢提取会从后台 // goroutine 掉回回合落定的同步路径上,直接拖慢回复。 type memoryAsyncExtractor interface { ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) } // WithoutSubagentNotes 用「剔除子代理结果注记」装饰 inner。inner 为 nil 时返回 nil // (未配置提取器的部署因此逐字不变:循环那边同样是 nil 即跳过)。 // // 内层支持异步(LLM 提取器)时返回的装饰器也支持异步;确定性提取器 // (ManualExtractor)不实现它,因此保持原有的就地执行路径。 func WithoutSubagentNotes(inner memory.Extractor) memory.Extractor { if inner == nil { return nil } sync := &subagentNoteFreeExtractor{inner: inner} if _, ok := inner.(memoryAsyncExtractor); ok { return &subagentNoteFreeAsyncExtractor{subagentNoteFreeExtractor: sync} } return sync } // subagentNoteFreeExtractor 是同步装饰器:抽取前把注记从对话里剔掉。 type subagentNoteFreeExtractor struct { inner memory.Extractor } // Extract 在剔除注记后的对话上运行内层提取器。 func (e *subagentNoteFreeExtractor) Extract(ctx context.Context, conversation []model.Message) ([]memory.MemoryItem, error) { return e.inner.Extract(ctx, dialogueOnly(conversation)) } // subagentNoteFreeAsyncExtractor 是异步装饰器:异步通道原样透传,只是输入先被剔除。 type subagentNoteFreeAsyncExtractor struct { *subagentNoteFreeExtractor } // ExtractAsync 把剔除注记后的对话交给内层的异步提取。内层意外不支持异步时退化为 // 同步执行并包成一个立即关闭的通道 —— 语义仍正确,只是不再后台跑(该分支不可达: // 构造器只在它支持异步时才选本类型)。 func (e *subagentNoteFreeAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) { if async, ok := e.inner.(memoryAsyncExtractor); ok { return async.ExtractAsync(ctx, dialogueOnly(conversation)) } out := make(chan memory.AsyncResult, 1) items, err := e.Extract(ctx, conversation) out <- memory.AsyncResult{Items: items, Err: err} close(out) return out, nil } // dialogueOnly 返回剔除全部子代理结果注记后的对话副本。没有注记时原样返回入参 // (不做任何分配 —— 绝大多数回合因此零成本)。 func dialogueOnly(conversation []model.Message) []model.Message { first := -1 for i, m := range conversation { if isSubagentNote(m) { first = i break } } if first < 0 { return conversation } out := make([]model.Message, 0, len(conversation)-(first/2+1)) out = append(out, conversation[:first]...) for _, m := range conversation[first+1:] { if isSubagentNote(m) { continue } out = append(out, m) } return out } // isSubagentNote 报告一条消息是否是子代理结果注记。判据是**元数据 + 结构性标记**: // `role == system` **且** 正文以 `subagentNoteMarker` 开头。只认标记而不认 role 会 // 误伤主人自己打的那串字;只认 role 会把压缩摘要(同样是 system 角色,且是对话的 // 合法压缩,理应继续参与抽取)一起挡掉。 func isSubagentNote(m model.Message) bool { if m.Role != model.RoleSystem { return false } text, _ := messageTextImage(m) return strings.HasPrefix(strings.TrimSpace(text), subagentNoteMarker) }