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(契约归一建议报塔)。
This commit is contained in:
1 parent
bd49080bd1
commit
3acc81cec8
13 files changed
+1589
-4
No files matched your search
@@ -205,25 +205,41 @@ func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
|
||||
}
|
||||
|
||||
// await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」
|
||||
// 的交接广播出去。它运行在脱离的 goroutine 上;总线为 nil 或 id 未知时安静结束。
|
||||
// 的交接广播出去。它运行在脱离的 goroutine 上。
|
||||
//
|
||||
// 🔴 **每一条出口都要留下痕迹**(M29 勘测 §7 的可观测性缺口):修复前「发布成功零
|
||||
// 日志、无总线静默 return」,于是「到底发没发」只能靠负证据推断(这次缺陷最费时的
|
||||
// 地方)。现在成功发布记一条带来源的 INFO,缺总线与发布失败各记一条 WARN ——
|
||||
// 「压根没发 / 发了但失败 / 发了」三态都能从日志正面判定。
|
||||
//
|
||||
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
|
||||
// 归属到父会话的实时流,而不是发一张无归属的帧。
|
||||
// 归属到父会话的实时流,而不是发一张无归属的帧;gateway 的回填订阅者也按它写
|
||||
// 父会话的系统注记(M32)。
|
||||
func (t *SpawnTool) await(ids []string, parentSession string) {
|
||||
res, err := t.sched.Wait(context.Background(), ids)
|
||||
if err != nil {
|
||||
t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err)
|
||||
if res == nil {
|
||||
t.log.Warn("sub-agent results not published: batch produced no result",
|
||||
"count", len(ids), "session", parentSession)
|
||||
return
|
||||
}
|
||||
}
|
||||
b := t.sched.Bus()
|
||||
if b == nil {
|
||||
t.log.Warn("sub-agent results not published: no bus",
|
||||
"count", len(ids), "session", parentSession)
|
||||
return
|
||||
}
|
||||
payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)}
|
||||
if perr := b.Publish(context.Background(), SubagentResultSubject, payload); perr != nil {
|
||||
t.log.Warn("publish sub-agent results", "error", perr)
|
||||
t.log.Warn("publish sub-agent results", "error", perr,
|
||||
"count", len(ids), "session", parentSession)
|
||||
return
|
||||
}
|
||||
t.log.Info("sub-agent results published",
|
||||
"subject", SubagentResultSubject, "session", parentSession,
|
||||
"count", len(ids), "status", payload.Status, "agents", len(payload.Agents))
|
||||
}
|
||||
|
||||
// SpawnResult 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user