package gateway import ( "context" "fmt" "log/slog" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration" ) // 本文件实现 design/05 §2/§5/§9 要求的**结果回填主会话**(M32)。修复前的断点不是 // 传输(帧发了、hub 订阅了、前端 handler 也注册了),而是「**回填这一环根本没有 // 实现**」:后台批次结算后的载荷只被中继成一个 WS 帧,落到面板的旁路日志面 —— // `chat_messages` 无行、模型下一轮也看不到,于是「她拍了个子代理出去,但没有下文」。 // // 回填被拆成**互不冒充**的两条面(项目红线:人格 = 身份,子代理的结论绝不能读成 // 她自己说的话): // // - **可观测 / 历史面**:往父会话写一条 `role = "system"` 的注记行(**绝不**是 // user / assistant),内容以 `subagentNoteMarker` 开头并点名 `sub-`,随后按 // 既有 `message_persisted` 帧扇出 ⇒ 刷新/重连后仍在(design/05 §9「提示用户」); // - **模型面**:同一行被下一回合的转录播种(`transcript.Read`)读进上下文,因此 // 模型在**下一次**组装时看到的是**系统注记**(`model.RoleSystem`),不是一句 // 「她说过的话」,也不是她自己的语气。 // // 落库口径(防「看着有、库里没有」): // // - **角色恒为 system**:`chat_messages.role` 的合法取值是 user/assistant/system // (0003 迁移的列注释),子代理结论既不是主人说的、也不是她说的; // - **PersonaID 留空**:这条注记的作者**不是任何角色**,写上角色 id 等于让客户端 // 用那个角色的头像渲染「她说过」; // - **TurnID 留空**:后台批次可能在派发它的那个回合结束**之后**才结算,它不属于 // 任何一路回合(与主动推送的同一取舍),空值诚实地表示这一点; // - **绝不被记忆抽取**:抽取输入是回合的 `msgs`(含播种的转录行),因此这条注记 // 会出现在抽取器面前。排除由 `WithoutSubagentNotes`(subagent_backfill_extract.go) // 在**组合根**套在提取器上完成 —— 判据是元数据(role=system + 注记标记),不是 // 提示词;否则子代理结论会被当成对话吞进记忆库(那条污染线刚查过)。 // // 刻意**不做**的事:不改 `subagent_result` 帧形状、不把它加进 `frameReachesAll`、 // 不新开路由/帧族、不往注记里注入任何人设。 const backfillLogScope = "subagent-backfill" // SubagentNoteStore 是回填所需的最小历史写面。*history.Store 满足它;测试替身 // 只需实现这一个方法。 type SubagentNoteStore interface { AppendFull(ctx context.Context, msg history.Message) (history.Message, error) } // SubagentNoteSink 是回填的下行出口(`message_persisted` / `channel_persisted` // 的既有扇出)。*Server 满足它;为 nil 时只落库不推帧(落库仍是唯一事实来源)。 type SubagentNoteSink interface { BroadcastPersisted(msg history.Message) } // SubagentBackfillDeps 是回填订阅者的装配输入(组合根按实际依赖填充)。 type SubagentBackfillDeps struct { // Bus 是进程内事件总线(与调度器、网关 hub **同一个实例**)。 Bus bus.Bus // History 是父会话注记的落库面。为 nil 时装配即失败(没有落库面就没有回填)。 History SubagentNoteStore // Broadcast 是该注记的实时扇出出口。为 nil 时只落库。 Broadcast SubagentNoteSink // Log 是日志基底;来源由 NewSubagentBackfill 统一盖成 // `source=gateway/subagent-backfill`。 Log *slog.Logger } // SubagentBackfill 是 `orchestration.SubagentResultSubject` 的**独立订阅者**:它把 // 后台批次的结果写成父会话的一条系统注记并扇出。 // // 它刻意**不是** hub 的一部分(hub 只负责投递,写入会话属会话编排职责,混进去会让 // 「投递」与「落库」耦合),也与 hub 的中继互不相识:两者订阅同一个主题,各干各的 // 一半(hub 出帧、本订阅者落行)。 // // 并发:总线对每条订阅串行派发(bus 包文档),因此 `lastDropped` 不需要加锁。 type SubagentBackfill struct { bus bus.Bus history SubagentNoteStore broadcast SubagentNoteSink log *slog.Logger // dropProbe 是总线的可选背压计数器(*bus.busImpl 的 Dropped())。总线在缓冲区 // 满时**丢弃事件且不告诉任何人**,而丢掉的恰恰就是回填事件 —— 因此每次成功 // 处理一条事件时顺手读一次计数,涨了就记一条 WARN(负证据变成正面可见)。 dropProbe dropProbe lastDropped uint64 } // dropProbe 是总线背压计数器的可选窄视图。 type dropProbe interface { Dropped() uint64 } // NewSubagentBackfill 构建回填订阅者。History 为 nil 时返回错误:一个只会丢结果的 // 订阅者比没有订阅者更糟(它会让「已接线」看起来成立)。 func NewSubagentBackfill(deps SubagentBackfillDeps) (*SubagentBackfill, error) { if deps.History == nil { return nil, fmt.Errorf("gateway: subagent backfill requires a history store") } if deps.Bus == nil { return nil, fmt.Errorf("gateway: subagent backfill requires a bus") } probe, _ := deps.Bus.(dropProbe) return &SubagentBackfill{ bus: deps.Bus, history: deps.History, broadcast: deps.Broadcast, log: logger.Scoped(sourcedLog(deps.Log), source, backfillLogScope), dropProbe: probe, }, nil } // Start 把订阅者挂上总线。ctx 取消即自动退订(进程关停时 `bus.Close` 是另一道)。 // // 订阅失败**必须**可见:返回包装后的错误,由组合根记 WARN —— 回填静默缺席正是 // 这次缺陷的形状。 func (b *SubagentBackfill) Start(ctx context.Context) error { if b == nil || b.bus == nil { return fmt.Errorf("gateway: subagent backfill is not wired") } if ctx == nil { ctx = context.Background() } if _, err := b.bus.Subscribe(ctx, orchestration.SubagentResultSubject, b.Handle); err != nil { return fmt.Errorf("gateway: subscribe %s: %w", orchestration.SubagentResultSubject, err) } b.log.Info("subagent backfill subscribed", "subject", orchestration.SubagentResultSubject, "role", history.RoleSystem) return nil } // Handle 是总线处理器:把一条后台批次结果写成父会话的系统注记并扇出。 // // 它绝不返回错误(总线会忽略返回值,只把失败留在没人看的地方):每一种失败都在 // 原地记带来源的日志,且**绝不**为了「有话可说」而编造一条注记。 func (b *SubagentBackfill) Handle(ctx context.Context, e bus.Event) error { if b == nil || e.Subject != orchestration.SubagentResultSubject { return nil } ev, ok := e.Payload.(*orchestration.SubagentResultEvent) if !ok || ev == nil { b.log.Warn("subagent backfill: unexpected payload type; result not persisted", "subject", e.Subject, "type", fmt.Sprintf("%T", e.Payload)) return nil } // 内嵌的 *SpawnResult 为 nil 时,任何字段访问都会空指针 —— 而总线会吞掉 panic, // 那等于把这条结果静默丢掉。因此显式拒绝并留痕。 if ev.SpawnResult == nil { b.log.Warn("subagent backfill: result payload carries no spawn result; not persisted", "session", ev.SessionID) return nil } if ev.SessionID == "" { b.log.Warn("subagent backfill: result has no parent session; not persisted", "status", ev.Status, "agents", len(ev.Agents)) return nil } note := subagentNote(ev) full, err := b.history.AppendFull(ctx, history.Message{ SessionID: ev.SessionID, Role: history.RoleSystem, Content: note, Mode: modeChat, }) if err != nil { b.log.Error("subagent backfill: persist result note", "err", err, "session", ev.SessionID, "agents", len(ev.Agents)) return nil } if b.broadcast != nil { b.broadcast.BroadcastPersisted(full) } b.log.Info("subagent result backfilled", "session", ev.SessionID, "message", full.ID, "status", ev.Status, "agents", len(ev.Agents), "runes", len([]rune(note))) b.reportDrops() return nil } // reportDrops 把「总线自上次以来丢过多少条事件」记成一条 WARN。它在成功处理一条 // 回填事件之后触发,因此这条负面事实至少会在下一次投递时浮出,而不是永远消失。 func (b *SubagentBackfill) reportDrops() { if b.dropProbe == nil { return } now := b.dropProbe.Dropped() if now == b.lastDropped { return } b.log.Warn("bus dropped events (subagent results may be missing)", "dropped_total", now, "dropped_since_last", now-b.lastDropped, "subject", orchestration.SubagentResultSubject) b.lastDropped = now }