Files
Cyrene_v2/internal/services/gateway/subagent_backfill_extract_test.go
T
AskaEth 3acc81cec8 feat(gateway): 子代理结果回填父会话(系统注记)+ 记忆抽取排除(M32)
断点(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(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00

209 lines
7.7 KiB
Go

package gateway
import (
"context"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
)
// 本文件锁住 M32 的第二道红线:**子代理结果绝不进记忆抽取**。
//
// 它不是「提示词里让模型别提取」,而是一条元数据判据(role=system + 注记标记):
// 抽取器的输入在到达内层之前就被剔干净。测试因此断言的是**内层实际收到了什么**
// —— 这正是缺陷的判定点(那条线刚查过:84% 记忆来自内部会话,不能再添污染源)。
// captureExtractor 记录每次抽取收到的对话,返回预设条目。
type captureExtractor struct {
mu sync.Mutex
seen [][]model.Message
items []memory.MemoryItem
}
func (e *captureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
e.mu.Lock()
defer e.mu.Unlock()
e.seen = append(e.seen, append([]model.Message(nil), conversation...))
return e.items, nil
}
func (e *captureExtractor) last() []model.Message {
e.mu.Lock()
defer e.mu.Unlock()
if len(e.seen) == 0 {
return nil
}
return e.seen[len(e.seen)-1]
}
// captureAsyncExtractor 是带异步能力的捕获提取器(对应 memory.LLMExtractor)。
type captureAsyncExtractor struct {
*captureExtractor
}
func (e *captureAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) {
items, err := e.Extract(ctx, conversation)
out := make(chan memory.AsyncResult, 1)
out <- memory.AsyncResult{Items: items, Err: err}
close(out)
return out, nil
}
// noteMessage 返回一条与回填落库行**同源**的系统注记模型消息:它经过
// `transcript.Message` 的映射,因此走的就是下一回合真正播种的那条路。
func noteMessage(t *testing.T) model.Message {
t.Helper()
row := history.Message{
ID: "msg-note",
SessionID: "web",
Role: history.RoleSystem,
Content: subagentNote(settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "子代理的结论"})),
}
return transcript.Message(row)
}
// userMessage 构造一条普通主人输入。
func userMessage(text string) model.Message {
return model.TextMessage(model.RoleUser, text)
}
// TestWithoutSubagentNotesStripsBackfilledNotes 锁住同步路径:注记在内层提取器看到
// 之前就被剔掉,其余对话逐字保留,提取结果原样透传。
func TestWithoutSubagentNotesStripsBackfilledNotes(t *testing.T) {
t.Parallel()
inner := &captureExtractor{items: []memory.MemoryItem{{Content: "记住的事"}}}
wrapped := WithoutSubagentNotes(inner)
conv := []model.Message{
userMessage("你好"),
noteMessage(t),
model.TextMessage(model.RoleAssistant, "在的"),
}
items, err := wrapped.Extract(context.Background(), conv)
if err != nil {
t.Fatalf("Extract: %v", err)
}
if len(items) != 1 || items[0].Content != "记住的事" {
t.Fatalf("items = %+v, want the inner extractor's result passed through", items)
}
seen := inner.last()
if len(seen) != 2 {
t.Fatalf("inner extractor saw %d messages, want 2 (the note must be removed): %+v", len(seen), seen)
}
for _, m := range seen {
if strings.HasPrefix(modelText(m), subagentNoteMarker) {
t.Fatalf("inner extractor saw the sub-agent note %q: it would be ingested as dialogue", modelText(m))
}
}
if modelText(seen[1]) != "在的" {
t.Fatalf("conversation order changed: %+v", seen)
}
}
// TestWithoutSubagentNotesKeepsCompactionSummary 锁住**不过度剔除**:压缩摘要同样是
// system 角色(loop 的 summaryMarker),它是对话的合法压缩,必须继续参与抽取。
func TestWithoutSubagentNotesKeepsCompactionSummary(t *testing.T) {
t.Parallel()
inner := &captureExtractor{}
wrapped := WithoutSubagentNotes(inner)
summary := model.TextMessage(model.RoleSystem, "[前文摘要 · history summary]\n\n之前聊过口味")
if _, err := wrapped.Extract(context.Background(), []model.Message{summary, userMessage("我喜欢淡一点")}); err != nil {
t.Fatalf("Extract: %v", err)
}
if got := len(inner.last()); got != 2 {
t.Fatalf("inner extractor saw %d messages, want both the summary and the user turn", got)
}
}
// TestWithoutSubagentNotesKeepsAsync 锁住「装饰之后仍然异步」:慢提取绝不能从后台
// goroutine 掉回回合落定的同步路径上(那会直接拖慢回复)。
func TestWithoutSubagentNotesKeepsAsync(t *testing.T) {
t.Parallel()
inner := &captureAsyncExtractor{&captureExtractor{}}
wrapped := WithoutSubagentNotes(inner)
async, ok := wrapped.(memoryAsyncExtractor)
if !ok {
t.Fatal("the decorator dropped ExtractAsync: a slow LLM extraction would now block turn settlement")
}
ch, err := async.ExtractAsync(context.Background(), []model.Message{noteMessage(t), userMessage("你好")})
if err != nil {
t.Fatalf("ExtractAsync: %v", err)
}
<-ch
seen := inner.last()
if len(seen) != 1 || modelText(seen[0]) != "你好" {
t.Fatalf("async extractor saw %+v, want only the user turn", seen)
}
}
// TestWithoutSubagentNotesIsNoopWithoutNotes 锁住零成本路径:没有注记时原样返回入参
// (不分配、不改顺序),且 nil 提取器仍是 nil(未配置提取器的部署逐字不变)。
func TestWithoutSubagentNotesIsNoopWithoutNotes(t *testing.T) {
t.Parallel()
if got := WithoutSubagentNotes(nil); got != nil {
t.Fatalf("WithoutSubagentNotes(nil) = %v, want nil", got)
}
conv := []model.Message{userMessage("你好")}
got := dialogueOnly(conv)
if len(got) != len(conv) {
t.Fatalf("dialogueOnly changed a note-free conversation: %+v", got)
}
if &got[0] != &conv[0] {
t.Fatal("a note-free conversation must be returned as-is (no copy on the hot path)")
}
}
// TestBackfilledNoteStaysOutOfMemoryExtraction 是这条防线的**端到端**接缝断言:
// 真回填落库 → 转录播种 → 抽取。注记在整条链上看得到(历史与模型上下文都有它),
// 但抽取器**一次也看不到**它。
func TestBackfilledNoteStaysOutOfMemoryExtraction(t *testing.T) {
t.Parallel()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteStore{}
bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Handle(context.Background(), bus.Event{
Subject: orchestration.SubagentResultSubject,
Payload: settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "子代理的结论"}),
}); err != nil {
t.Fatalf("Handle: %v", err)
}
rows := store.rows()
if len(rows) != 1 {
t.Fatalf("rows = %+v, want one", rows)
}
// 下一回合的模型上下文 = 播种的转录行(这就是 loop 交给提取器的 msgs 的来源)。
nextTurn := []model.Message{transcript.Message(rows[0]), userMessage("接着说")}
inner := &captureExtractor{}
if _, err := WithoutSubagentNotes(inner).Extract(context.Background(), nextTurn); err != nil {
t.Fatalf("Extract: %v", err)
}
seen := inner.last()
if len(seen) != 1 {
t.Fatalf("extractor input = %+v, want only the user turn", seen)
}
if !strings.Contains(modelText(seen[0]), "接着说") {
t.Fatalf("extractor lost the real dialogue: %+v", seen)
}
}
// modelText 返回一条模型消息的正文(测试窄视图)。
func modelText(m model.Message) string {
var b strings.Builder
for _, c := range m.Content {
if c.Type == model.ContentTypeText {
b.WriteString(c.Text)
}
}
return b.String()
}