Files
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

188 lines
6.1 KiB
Go

package orchestration
import (
"context"
"errors"
"log/slog"
"sync"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
)
// 本文件锁住 M29 勘测 §7 的可观测性缺口(M32 顺手补掉的那一半):`await` 的
// **每一条出口都必须留痕**。修复前「发布成功零日志、无总线静默 return」,于是
// 「后台结果到底发没发」只能靠负证据推断 —— 那正是这次缺陷最费时间的地方。
//
// 三条被测出口,与日志一一对应:
// - 发布成功 ⇒ INFO `sub-agent results published`(带 source、父会话、条数、状态);
// - 没有总线 ⇒ WARN `... no bus`(以前是静默 return);
// - 发布失败 ⇒ WARN `publish sub-agent results`(带 err)。
// failingPublishBus 是一个 Publish 必定失败的 Bus:用于证明「发布失败不静默」。
type failingPublishBus struct {
err error
}
func (b failingPublishBus) Publish(context.Context, string, any) error { return b.err }
func (b failingPublishBus) Subscribe(context.Context, string, bus.Handler) (func(), error) {
return func() {}, nil
}
func (b failingPublishBus) Close() error { return nil }
// awaitLogSink 收集结构化日志记录(spawntool 的 logger 已带 source)。
type awaitLogSink struct {
mu sync.Mutex
recs []slog.Record
}
// awaitLogCapture 是一个会把 WithAttrs 累积进记录里的处理器:source 等标签是经
// `logger.With` 挂上的,只读 Record.Attrs 会看不到它们。
type awaitLogCapture struct {
sink *awaitLogSink
attrs []slog.Attr
}
func newAwaitLogCapture() *awaitLogCapture { return &awaitLogCapture{sink: &awaitLogSink{}} }
func (c *awaitLogCapture) Enabled(context.Context, slog.Level) bool { return true }
func (c *awaitLogCapture) Handle(_ context.Context, rec slog.Record) error {
rec.AddAttrs(c.attrs...)
c.sink.mu.Lock()
defer c.sink.mu.Unlock()
c.sink.recs = append(c.sink.recs, rec.Clone())
return nil
}
func (c *awaitLogCapture) WithAttrs(as []slog.Attr) slog.Handler {
merged := make([]slog.Attr, 0, len(c.attrs)+len(as))
merged = append(merged, c.attrs...)
merged = append(merged, as...)
return &awaitLogCapture{sink: c.sink, attrs: merged}
}
func (c *awaitLogCapture) WithGroup(string) slog.Handler { return c }
// find 返回第一条级别匹配且 msg 等值的记录。
func (c *awaitLogCapture) find(level slog.Level, want string) (slog.Record, bool) {
c.sink.mu.Lock()
defer c.sink.mu.Unlock()
for _, rec := range c.sink.recs {
if rec.Level == level && rec.Message == want {
return rec, true
}
}
return slog.Record{}, false
}
// attr 读取一条记录上的字符串属性(含经 WithAttrs 挂上的标签)。
func (c *awaitLogCapture) attr(rec slog.Record, key string) (string, bool) {
var out string
var ok bool
rec.Attrs(func(a slog.Attr) bool {
if a.Key != key {
return true
}
out, ok = a.Value.String(), true
return false
})
return out, ok
}
// settledTool 构建一个派发并结算一个子代理的工具,返回工具、该子代理 id 与日志捕获。
func settledTool(t *testing.T, opts Options) (*SpawnTool, string, *awaitLogCapture) {
t.Helper()
s := New(opts)
s.runner = &mockRunner{}
sub, err := s.Spawn(context.Background(), AgentSpec{Goal: "查一下 X", ParentSession: "web"})
if err != nil {
t.Fatalf("Spawn: %v", err)
}
capture := newAwaitLogCapture()
return NewSpawnTool(s, WithSpawnLogger(slog.New(capture)), WithSpawnWait(time.Second)), sub.ID, capture
}
// TestAwaitLogsSuccessfulPublish 锁住「发成功要能从日志正面看出来」。
func TestAwaitLogsSuccessfulPublish(t *testing.T) {
t.Parallel()
b := bus.New()
t.Cleanup(func() { _ = b.Close() })
sp, id, capture := settledTool(t, Options{MaxParallel: 1, Bus: b})
published := make(chan *SubagentResultEvent, 1)
unsub, err := b.Subscribe(context.Background(), SubagentResultSubject,
func(_ context.Context, e bus.Event) error {
if ev, ok := e.Payload.(*SubagentResultEvent); ok {
select {
case published <- ev:
default:
}
}
return nil
})
if err != nil {
t.Fatalf("subscribe: %v", err)
}
defer unsub()
sp.await([]string{id}, "web")
select {
case ev := <-published:
if ev.SessionID != "web" || ev.Status != "done" {
t.Fatalf("payload = %+v, want the settled batch for session web", ev)
}
case <-time.After(waitTimeout):
t.Fatal("the settled batch was never published")
}
rec, ok := capture.find(slog.LevelInfo, "sub-agent results published")
if !ok {
t.Fatal("a successful publish left no INFO: 'did it publish?' must be answerable from the log")
}
if src, _ := capture.attr(rec, "source"); src != "orchestration/spawn" {
t.Fatalf("log source = %q, want %q (AGENTS §10: no bare logs)", src, "orchestration/spawn")
}
if session, _ := capture.attr(rec, "session"); session != "web" {
t.Fatalf("log session = %q, want the parent session", session)
}
if status, _ := capture.attr(rec, "status"); status != "done" {
t.Fatalf("log status = %q, want done", status)
}
}
// TestAwaitWarnsWithoutBus 锁住「没有总线」不再是静默 return。
func TestAwaitWarnsWithoutBus(t *testing.T) {
t.Parallel()
sp, id, capture := settledTool(t, Options{MaxParallel: 1})
if sp.sched.Bus() != nil {
t.Skip("this test needs a scheduler without a bus")
}
sp.await([]string{id}, "web")
rec, ok := capture.find(slog.LevelWarn, "sub-agent results not published: no bus")
if !ok {
t.Fatal("a scheduler with no bus dropped the results silently")
}
if session, _ := capture.attr(rec, "session"); session != "web" {
t.Fatalf("log session = %q, want the parent session (traceability)", session)
}
}
// TestAwaitWarnsOnPublishFailure 锁住「发布失败必须留下 err」。
func TestAwaitWarnsOnPublishFailure(t *testing.T) {
t.Parallel()
sp, id, capture := settledTool(t, Options{MaxParallel: 1, Bus: failingPublishBus{err: errors.New("bus down")}})
sp.await([]string{id}, "web")
rec, ok := capture.find(slog.LevelWarn, "publish sub-agent results")
if !ok {
t.Fatal("a failed publish left no WARN: the result would silently never arrive")
}
if msg, _ := capture.attr(rec, "error"); msg == "" {
t.Fatal("the publish-failure WARN carries no error detail")
}
}