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