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

@@ -0,0 +1,196 @@
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-<id>`,随后按
// 既有 `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
}