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

+80
View File
@@ -0,0 +1,80 @@
package main
// 本文件把**子代理结果回填**接进组合根(M32;design/05 §2/§5/§9)。
//
// 为什么必须在组合根装配,而不是在网关 `New` 里顺手订阅:
//
// - 回填要写的是**父会话的聊天历史**,而网关 New 拿到的 History 与组合根
// `as.history` 是同一个 `*history.Store`,但它此刻还没被 hub 的广播出口包上 ——
// 落库之后必须用**同一个** `message_persisted` 扇出(`Server.BroadcastPersisted`)
// 才能让在线端与刷新/重连的端看到同一行;
// - 与 hub 的分工:hub 只把主题归一成 `subagent_result` 帧(投递),回填订阅者只把
// 结果写成父会话的一行系统注记(落库)。两者订阅**同一个主题**、互不相识,
// 因此以后改帧不会牵连落库,改落库也不会牵连帧。
//
// 订阅用的 ctx 是 `context.Background()`:组合根这一步没有任何长生命周期 ctx 可用
// (`newApp` 的 ctx 只用于构造期的库操作),而进程关停时 `app.close` 会
// `a.bus.Close()` —— 它取消所有订阅的处理器 ctx,因此这里不需要第二个停止开关。
// 这与 hub(`Server.Start` 里自建 ctx 并在 `Close` 取消)是同一取舍。
import (
"context"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway"
)
// backfillWireLogSource 是这条接线自己的日志来源:订阅成功/失败要能按来源定位,
// 而不是混在 `main` 或 `gateway` 的裸日志里(AGENTS §10 的日志来源铁律)。
const backfillWireLogSource = "cmd/cyrene/wire_subagent_backfill"
// wireSubagentBackfill 把「后台子代理结果 → 父会话系统注记」的订阅者接到组合根的
// 进程内总线上(与调度器、网关 hub **同一个** bus 实例)。
//
// 三条降级口径(都**必须可见**,绝不静默):
//
// - 没有 bus 或没有历史存储:这条回填此刻做不了(未接持久化的构建);
// - 订阅失败:总线已关闭或主题非法 —— 那意味着后台结果永远不会回填;
// - 广播出口缺失(网关为 nil):仍然落库(历史是唯一事实来源),只是不推帧。
func wireSubagentBackfill(a *app, gw *gateway.Server) {
log := logger.WithSource(a.log, backfillWireLogSource)
if a.bus == nil || a.history == nil {
log.Warn("subagent backfill not wired: needs both a bus and a history store",
"bus", a.bus != nil, "history", a.history != nil)
return
}
backfill, err := gateway.NewSubagentBackfill(gateway.SubagentBackfillDeps{
Bus: a.bus,
History: a.history,
Broadcast: backfillSink(gw),
Log: a.log,
})
if err != nil {
log.Warn("subagent backfill not wired", "err", err)
return
}
if err := backfill.Start(context.Background()); err != nil {
log.Warn("subagent backfill subscribe failed; background results will not reach the parent session",
"err", err)
return
}
log.Info("subagent backfill wired",
"subject", "subagent.result", "session_role", history.RoleSystem)
}
// backfillSink 返回回填的下行出口。网关为 nil 时返回**真 nil 接口**(带类型的 nil
// 指针会让订阅者里的 `!= nil` 判断失效,落库之后去调一个空接收者)。
func backfillSink(gw *gateway.Server) gateway.SubagentNoteSink {
if gw == nil {
return nil
}
return gw
}
// 编译期锁住这条链路的两端:网关仍是回填的下行出口,历史存储仍是它的落库面。
// 任何一边改签名,这里先红,而不是等到运行期回填静默缺席。
var (
_ gateway.SubagentNoteSink = (*gateway.Server)(nil)
_ gateway.SubagentNoteStore = (*history.Store)(nil)
)