断点(M29 勘测定论):后台批次结算后 `subagent.result` 发了、hub 也把它中继成 WS 帧,但**没有任何代码把结果回填父会话** —— `chat_messages` 无行、模型下一轮 看不到,于是「她拍了个子代理出去,但没有下文」。 回填拆成互不冒充的两条面(人格=身份红线:子代理结论绝不写成她的人格发言): - 可观测/历史面(新文件 subagent_backfill.go):独立订阅者(不塞进 hub)订阅 `subagent.result`,写父会话一条 `role=system` 注记(内容以 `[子代理结果]` 开头 并点名 `sub-<id>`),随后走既有 `message_persisted` 扇出 ⇒ 刷新/重连后仍在; - 模型面:同一行经转录播种成为下一回合的**系统注记**(`model.RoleSystem`), 不是「她说过的话」。 落库口径:角色恒为 system(不冒充 user/assistant)、PersonaID 与 TurnID 留空 (注记不属于任何角色、也不属于任何一路回合)。 记忆抽取排除(新文件 subagent_backfill_extract.go):`WithoutSubagentNotes` 装饰 `memory.Extractor`,判据是元数据(role=system + 注记标记,与 loop 的 summaryMarker 同一手法),组合根经 `loopMemoryExtractor` 套上 ⇒ 子代理结论绝不进记忆库;LLM 提取器的异步能力原样保留,压缩摘要(同样是 system)不被误剔。 可观测补齐(M29 §7):`SpawnTool.await` 发布成功补带 source 的 INFO,`b==nil` 与 「无结果」的静默 return 改为 WARN,发布失败 WARN 带上 count/session;回填订阅者 顺手读 `bus.Dropped()`,涨了就记一条 WARN(总线丢事件不再是黑洞)。 测试(均可变异打红):父会话恰一条 system 行且零伪造助手发言;注记/摘要/批次正文 有界且逐代理点名;不可用载荷与落库失败各留 WARN 且不广播;Start 真订阅总线; 结果不进记忆抽取(同步/异步/端到端接缝);组合根真装配(真 bus 发布 → 父会话多 一行)。回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿。 不做:不改 `subagent_result` 帧形状、不加进 frameReachesAll、不新开路由/帧族、 不注入人设、不改 Docs(契约归一建议报塔)。
117 lines
5.1 KiB
Go
117 lines
5.1 KiB
Go
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)
|
||
}
|