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:
AskaEth committed 2026-10-24 22:02:11 +08:00
1 parent bd49080bd1
commit 3acc81cec8
13 files changed
+1589 -4

No files matched your search

+19 -3
View File
@@ -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 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,