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