断点(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(契约归一建议报塔)。
209 lines
7.7 KiB
Go
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 := ¬eStore{}
|
|
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()
|
|
}
|