Files
Cyrene_v2/internal/services/gateway/subagent_backfill_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

345 lines
13 KiB
Go

package gateway
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync"
"testing"
"time"
"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/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/transcript"
)
// 本文件锁住 M32 的两条面(design/05 §2/§5/§9)与它们**不互相冒充**这条红线:
//
// - 可观测 / 历史面:父会话多出**恰好一条** `role=system` 的注记行(不是 user、
// 不是 assistant ⇒ 不是「她说的」),并随既有的 `message_persisted` 扇出;
// - 模型面:同一行经转录播种后在下一回合是一条 **system** 消息,且**绝不**进入
// 记忆抽取的输入面。
//
// 每条断言都对着一个可能被写坏的实现:把角色写成 assistant(人格冒充)、把
// `turn_id` 编一个(冒充某一路回合)、把注记漏掉标记(于是它会被当成对话吞进记忆
// 库)、或者落库失败还照样广播(假的「已回填」)。
// noteStore 是回填落库面的测试替身:记录每次写入并按需注入失败。
type noteStore struct {
mu sync.Mutex
written []history.Message
err error
}
func (s *noteStore) AppendFull(_ context.Context, msg history.Message) (history.Message, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.err != nil {
return history.Message{}, s.err
}
if msg.ID == "" {
msg.ID = "msg-backfill-1"
}
if msg.CreatedAt == 0 {
msg.CreatedAt = 1
}
s.written = append(s.written, msg)
return msg, nil
}
func (s *noteStore) rows() []history.Message {
s.mu.Lock()
defer s.mu.Unlock()
return append([]history.Message(nil), s.written...)
}
// noteSink 是下行扇出的测试替身。
type noteSink struct {
mu sync.Mutex
sent []history.Message
}
func (s *noteSink) BroadcastPersisted(msg history.Message) {
s.mu.Lock()
defer s.mu.Unlock()
s.sent = append(s.sent, msg)
}
func (s *noteSink) frames() []history.Message {
s.mu.Lock()
defer s.mu.Unlock()
return append([]history.Message(nil), s.sent...)
}
// recordSink 收集结构化日志记录,使「失败必须可见」能被断言而不是靠人看输出。
type recordSink struct {
mu sync.Mutex
recs []slog.Record
}
func (r *recordSink) Enabled(context.Context, slog.Level) bool { return true }
func (r *recordSink) Handle(_ context.Context, rec slog.Record) error {
r.mu.Lock()
defer r.mu.Unlock()
r.recs = append(r.recs, rec.Clone())
return nil
}
func (r *recordSink) WithAttrs([]slog.Attr) slog.Handler { return r }
func (r *recordSink) WithGroup(string) slog.Handler { return r }
// has 报告是否有一条指定级别且 msg 含 want 的记录。
func (r *recordSink) has(level slog.Level, want string) bool {
r.mu.Lock()
defer r.mu.Unlock()
for _, rec := range r.recs {
if rec.Level == level && strings.Contains(rec.Message, want) {
return true
}
}
return false
}
// backfillFor 构建一个带真 bus(不订阅)的回填订阅者与它的两个替身。
func backfillFor(t *testing.T, log *slog.Logger) (*SubagentBackfill, *noteStore, *noteSink) {
t.Helper()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store, sink := &noteStore{}, &noteSink{}
if log == nil {
log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store, Broadcast: sink, Log: log})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
return bf, store, sink
}
// settledResult 构造一个与 SpawnTool.await 同形同键集的已结算批次载荷。
func settledResult(session string, agents ...orchestration.SpawnAgentResult) *orchestration.SubagentResultEvent {
return &orchestration.SubagentResultEvent{
SessionID: session,
SpawnResult: &orchestration.SpawnResult{
Status: "done",
Summary: "子代理汇总: 共 1 个, 1 成功",
Agents: agents,
},
}
}
// TestSubagentBackfillWritesSystemNoteOnly 是 M32 的第一条面:结果写成父会话的一条
// **系统注记**,绝不写成她的人格发言,也不冒充任何一路回合。
func TestSubagentBackfillWritesSystemNoteOnly(t *testing.T) {
t.Parallel()
bf, store, sink := backfillFor(t, nil)
payload := settledResult("web", orchestration.SpawnAgentResult{
ID: "sub-a1", Goal: "调研 X", Role: "cyrene", Mode: "engineer",
Status: "done", Summary: "结论:X 可行", Text: "结论:X 可行", Steps: 2,
})
if err := bf.Handle(context.Background(), bus.Event{Subject: orchestration.SubagentResultSubject, Payload: payload}); err != nil {
t.Fatalf("Handle: %v", err)
}
rows := store.rows()
if len(rows) != 1 {
t.Fatalf("persisted %d rows, want exactly one system note (no fabricated speech): %+v", len(rows), rows)
}
row := rows[0]
if row.SessionID != "web" {
t.Fatalf("row session = %q, want the parent session %q", row.SessionID, "web")
}
if row.Role != history.RoleSystem {
t.Fatalf("row role = %q, want %q (the note is not the owner's words and not the persona's)", row.Role, history.RoleSystem)
}
// 显式锁住「不冒充」:既不是 user、也不是 assistant —— 这两条是本条红线的全部内容。
for _, forbidden := range []string{history.RoleUser, history.RoleAssistant} {
if row.Role == forbidden {
t.Fatalf("row role = %q: a sub-agent conclusion must never be written as %s", row.Role, forbidden)
}
}
if !strings.HasPrefix(row.Content, subagentNoteMarker) {
t.Fatalf("content = %q, want the %q marker (it is what keeps the note out of memory extraction)", row.Content, subagentNoteMarker)
}
for _, want := range []string{"sub-a1", "cyrene/engineer", "结论:X 可行"} {
if !strings.Contains(row.Content, want) {
t.Fatalf("content = %q, want it to name %q (attribution must be traceable)", row.Content, want)
}
}
if row.TurnID != "" {
t.Fatalf("turn_id = %q, want empty: a background batch may settle after its turn ended and belongs to no lane", row.TurnID)
}
if row.PersonaID != "" {
t.Fatalf("persona_id = %q, want empty: the note's author is no persona", row.PersonaID)
}
frames := sink.frames()
if len(frames) != 1 || frames[0].ID != row.ID {
t.Fatalf("broadcast %+v, want exactly the persisted row %q", frames, row.ID)
}
}
// TestSubagentBackfillNoteIsBoundedAndPerAgent 锁住注记的**形状**:批次逐代理一行、
// 每行点名 id,且整条有界(失控子代理不能淹掉父会话上下文)。
func TestSubagentBackfillNoteIsBoundedAndPerAgent(t *testing.T) {
t.Parallel()
long := strings.Repeat("长", maxSubagentNoteBodyRunes*3)
payload := settledResult("web",
orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论A"},
orchestration.SpawnAgentResult{ID: "sub-b", Status: "failed", Summary: long},
orchestration.SpawnAgentResult{ID: "sub-c", Status: "aborted"},
)
note := subagentNote(payload)
for _, want := range []string{"[子代理结果] 3 个子代理批次已完成", "- sub-a 已完成:结论A", "- sub-b 失败:", "已中止", subagentNoteEmptyBody} {
if !strings.Contains(note, want) {
t.Fatalf("note = %q, want it to contain %q", note, want)
}
}
if got := len([]rune(note)); got > maxSubagentNoteRunes {
t.Fatalf("note is %d runes, want at most %d", got, maxSubagentNoteRunes)
}
// 单代理批次的正文也不能无限长。
single := subagentNote(settledResult("web", orchestration.SpawnAgentResult{ID: "sub-x", Status: "done", Text: long}))
if got := len([]rune(single)); got > maxSubagentNoteRunes {
t.Fatalf("single-agent note is %d runes, want at most %d", got, maxSubagentNoteRunes)
}
if !strings.Contains(single, "子代理 sub-x 已完成") {
t.Fatalf("single-agent note = %q, want the one-line shape", single)
}
}
// TestSubagentBackfillSkipsUnusablePayloads 锁住两种「不能写」的情形:载荷形状不对,
// 或没有父会话可归属。两者都必须留一条 WARN(绝不静默丢结果)。
func TestSubagentBackfillSkipsUnusablePayloads(t *testing.T) {
t.Parallel()
sinkLog := &recordSink{}
bf, store, sink := backfillFor(t, slog.New(sinkLog))
cases := []struct {
name string
subject string
payload any
}{
{name: "wrong payload type", subject: orchestration.SubagentResultSubject, payload: map[string]any{"sessionId": "web"}},
{name: "no spawn result", subject: orchestration.SubagentResultSubject, payload: &orchestration.SubagentResultEvent{SessionID: "web"}},
{name: "no parent session", subject: orchestration.SubagentResultSubject, payload: settledResult("", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done"})},
{name: "other subject", subject: "task.done", payload: settledResult("web")},
}
for _, tc := range cases {
if err := bf.Handle(context.Background(), bus.Event{Subject: tc.subject, Payload: tc.payload}); err != nil {
t.Fatalf("%s: Handle: %v", tc.name, err)
}
}
if rows := store.rows(); len(rows) != 0 {
t.Fatalf("persisted %+v, want no rows for unusable payloads", rows)
}
if frames := sink.frames(); len(frames) != 0 {
t.Fatalf("broadcast %+v, want no frames for unusable payloads", frames)
}
if !sinkLog.has(slog.LevelWarn, "unexpected payload") {
t.Fatal("a malformed payload must leave a WARN, not vanish silently")
}
if !sinkLog.has(slog.LevelWarn, "no parent session") {
t.Fatal("a result with no parent session must leave a WARN, not vanish silently")
}
if !sinkLog.has(slog.LevelWarn, "carries no spawn result") {
t.Fatal("a payload without a spawn result must leave a WARN (the bus swallows panics)")
}
}
// TestSubagentBackfillStoreFailureIsNotSilent 锁住「落库失败 ⇒ 既不广播也不静默」:
// 广播一条并不存在的行会让客户端以为回填成立(假成功)。
func TestSubagentBackfillStoreFailureIsNotSilent(t *testing.T) {
t.Parallel()
sinkLog := &recordSink{}
bf, store, sink := backfillFor(t, slog.New(sinkLog))
store.err = errors.New("db down")
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)
}
if frames := sink.frames(); len(frames) != 0 {
t.Fatalf("broadcast %+v after a failed write, want none (never claim a row that does not exist)", frames)
}
if !sinkLog.has(slog.LevelError, "persist result note") {
t.Fatal("a failed write must leave an ERROR with its source, not vanish silently")
}
}
// TestSubagentBackfillStartSubscribesBus 锁住「Start 真的挂上总线」:真 bus 上发布
// 一条结果 ⇒ 父会话多出一条注记行。少了这一步,整条回填就只是一个没人调用的方法。
func TestSubagentBackfillStartSubscribesBus(t *testing.T) {
t.Parallel()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
store := &noteStore{}
bf, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: b, History: store, Log: slog.New(slog.NewTextHandler(io.Discard, nil))})
if err != nil {
t.Fatalf("NewSubagentBackfill: %v", err)
}
if err := bf.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
if err := b.Publish(context.Background(), orchestration.SubagentResultSubject,
settledResult("web", orchestration.SpawnAgentResult{ID: "sub-a", Status: "done", Summary: "结论"})); err != nil {
t.Fatalf("publish: %v", err)
}
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if len(store.rows()) > 0 {
break
}
time.Sleep(5 * time.Millisecond)
}
rows := store.rows()
if len(rows) != 1 || rows[0].Role != history.RoleSystem {
t.Fatalf("rows = %+v, want one system note from the bus subscription", rows)
}
}
// TestSubagentBackfillRequiresStoreAndBus 锁住装配期的诚实失败:没有落库面的订阅者
// 只会把结果丢掉,却让「已接线」看起来成立。
func TestSubagentBackfillRequiresStoreAndBus(t *testing.T) {
t.Parallel()
if _, err := NewSubagentBackfill(SubagentBackfillDeps{Bus: bus.New()}); err == nil {
t.Fatal("NewSubagentBackfill without a history store must fail")
}
if _, err := NewSubagentBackfill(SubagentBackfillDeps{History: &noteStore{}}); err == nil {
t.Fatal("NewSubagentBackfill without a bus must fail")
}
}
// TestBackfilledNoteNeverReframedAsAssistant 是两条面之间的**接缝**断言:落库行经
// 转录播种(下一回合真正会走的那条路)之后,是一条 `model.RoleSystem` 消息 —— 不是
// assistant,因此模型不会把它读成「她自己说过的话」。
func TestBackfilledNoteNeverReframedAsAssistant(t *testing.T) {
t.Parallel()
bf, store, _ := backfillFor(t, nil)
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)
}
seeded := transcript.Message(rows[0])
if seeded.Role != model.RoleSystem {
t.Fatalf("seeded role = %q, want system: the next turn must read the note as a system note", seeded.Role)
}
if seeded.SenderName != "" {
t.Fatalf("seeded sender = %q, want empty: no persona may be credited with the sub-agent's words", seeded.SenderName)
}
}