断点(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(契约归一建议报塔)。
127 lines
4.1 KiB
Go
127 lines
4.1 KiB
Go
package main
|
||
|
||
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"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
|
||
)
|
||
|
||
// 本文件锁住组合根那一条红线:循环用的提取器**必须**套上「剔除子代理注记」这一层
|
||
// (loopMemoryExtractor)。删掉那层装饰,子代理的结论就会被当成对话写进记忆库 ——
|
||
// 那条污染线刚查过(84% 记忆来自内部会话),不能再添一条。
|
||
//
|
||
// 断言的是**组合根这个入口**的语义,因此即使 gateway 那边的装饰器自己写对,把这里
|
||
// 换成 `base` 直通也会红。
|
||
|
||
// noteCaptureStore 是回填落库面的替身:只留下注记正文。
|
||
type noteCaptureStore struct {
|
||
mu sync.Mutex
|
||
rows []history.Message
|
||
}
|
||
|
||
func (s *noteCaptureStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
if msg.ID == "" {
|
||
msg.ID = "msg-note"
|
||
}
|
||
s.rows = append(s.rows, msg)
|
||
return msg, nil
|
||
}
|
||
|
||
// seenCaptureExtractor 记录内层提取器实际收到的对话。
|
||
type seenCaptureExtractor struct {
|
||
mu sync.Mutex
|
||
seen []model.Message
|
||
}
|
||
|
||
func (e *seenCaptureExtractor) Extract(_ context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
|
||
e.mu.Lock()
|
||
defer e.mu.Unlock()
|
||
e.seen = append([]model.Message(nil), conversation...)
|
||
return nil, nil
|
||
}
|
||
|
||
// TestLoopMemoryExtractorStripsBackfilledNotes 走真链路取一条注记(回填 → 落库 →
|
||
// 转录播种),再把它连同一条主人输入交给组合根返回的提取器:内层必须只看得到主人
|
||
// 那一句。
|
||
func TestLoopMemoryExtractorStripsBackfilledNotes(t *testing.T) {
|
||
b := bus.New()
|
||
t.Cleanup(func() { _ = b.Close() })
|
||
store := ¬eCaptureStore{}
|
||
bf, err := gateway.NewSubagentBackfill(gateway.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: &orchestration.SubagentResultEvent{
|
||
SessionID: history.MainSessionID,
|
||
SpawnResult: &orchestration.SpawnResult{
|
||
Status: "done",
|
||
Summary: "子代理汇总: 共 1 个, 1 成功",
|
||
Agents: []orchestration.SpawnAgentResult{{
|
||
ID: "sub-a1", Goal: "调研 X", Status: "done", Summary: "结论:X 可行",
|
||
}},
|
||
},
|
||
},
|
||
}); err != nil {
|
||
t.Fatalf("Handle: %v", err)
|
||
}
|
||
if len(store.rows) != 1 {
|
||
t.Fatalf("backfill wrote %d rows, want one system note", len(store.rows))
|
||
}
|
||
note := transcript.Message(store.rows[0])
|
||
if note.Role != model.RoleSystem {
|
||
t.Fatalf("seeded note role = %q, want system", note.Role)
|
||
}
|
||
|
||
inner := &seenCaptureExtractor{}
|
||
extractor := loopMemoryExtractor(inner)
|
||
if extractor == nil {
|
||
t.Fatal("loopMemoryExtractor(non-nil) = nil")
|
||
}
|
||
if _, err := extractor.Extract(context.Background(), []model.Message{
|
||
note,
|
||
model.TextMessage(model.RoleUser, "接着说"),
|
||
}); err != nil {
|
||
t.Fatalf("Extract: %v", err)
|
||
}
|
||
inner.mu.Lock()
|
||
seen := inner.seen
|
||
inner.mu.Unlock()
|
||
if len(seen) != 1 {
|
||
t.Fatalf("inner extractor saw %d messages, want only the user turn: the composition root dropped the note filter", len(seen))
|
||
}
|
||
if !strings.Contains(messageTextOf(seen[0]), "接着说") {
|
||
t.Fatalf("inner extractor lost the real dialogue: %+v", seen)
|
||
}
|
||
}
|
||
|
||
// TestLoopMemoryExtractorKeepsNil 锁住未配置提取器的部署逐字不变。
|
||
func TestLoopMemoryExtractorKeepsNil(t *testing.T) {
|
||
if got := loopMemoryExtractor(nil); got != nil {
|
||
t.Fatalf("loopMemoryExtractor(nil) = %v, want nil", got)
|
||
}
|
||
}
|
||
|
||
// messageTextOf 返回一条模型消息的正文(测试窄视图)。
|
||
func messageTextOf(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()
|
||
}
|