Files
AskaEth 3acc81cec8 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(契约归一建议报塔)。
2026-10-24 22:02:11 +08:00

197 lines
8.8 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}