断点(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(契约归一建议报塔)。
188 lines
6.1 KiB
Go
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")
|
|
}
|
|
}
|