Files
Cyrene_v2/internal/services/gateway/subagent_backfill_extract.go
T
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

117 lines
5.1 KiB
Go
Raw 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"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// 本文件是「子代理结果注记**绝不进记忆抽取**」这道防线(M32)。它是
// `memory.Extractor` 的装饰器,由**组合根**套在循环的提取器上
// (`cmd/cyrene/wire_runtime.go` 的 `Extractor:` 一行)。
//
// 为什么需要它(不是理论问题,是「看着有、库里没有」的反面):
//
// - 回填注记是一条**已落库的系统消息**,因此它会被下一回合的转录播种
// (`transcript.Read`)读进该回合的 `msgs`,而循环在回合落定时把这整份 `msgs`
// 交给提取器(`loop.extractMemoryAsync`)。不挡,子代理的结论就会被当成对话,
// 由 LLM 提取器判断「值得记住」后写进记忆库 —— 那正是刚查过的那条污染线
// (84% 记忆来自内部会话),绝不能再添一条。
// - **判据必须是元数据**(AGENTS §9「身份/判据靠元数据,不靠提示词」):这里按
// `role == system` **且** 正文以 `subagentNoteMarker` 开头来识别,两者都是
// **结构性事实**(该标记由本包的注记写入方唯一产出,与 loop 的 `summaryMarker`
// 同一手法),绝不靠「提示词里让模型别提取」。
//
// 它只影响**喂给提取器的输入**:落库行、`message_persisted` 帧、下一回合模型看到的
// 系统注记、历史接口全部逐字不变 —— 被挡掉的只有「这条注记变成一条记忆」这一件事。
// memoryAsyncExtractor 是 memory.LLMExtractor 的异步能力(与 loop 包内部的
// asyncExtractor 同形)。装饰器必须在**装饰后仍然异步**,否则一次慢提取会从后台
// goroutine 掉回回合落定的同步路径上,直接拖慢回复。
type memoryAsyncExtractor interface {
ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error)
}
// WithoutSubagentNotes 用「剔除子代理结果注记」装饰 inner。inner 为 nil 时返回 nil
// (未配置提取器的部署因此逐字不变:循环那边同样是 nil 即跳过)。
//
// 内层支持异步(LLM 提取器)时返回的装饰器也支持异步;确定性提取器
// (ManualExtractor)不实现它,因此保持原有的就地执行路径。
func WithoutSubagentNotes(inner memory.Extractor) memory.Extractor {
if inner == nil {
return nil
}
sync := &subagentNoteFreeExtractor{inner: inner}
if _, ok := inner.(memoryAsyncExtractor); ok {
return &subagentNoteFreeAsyncExtractor{subagentNoteFreeExtractor: sync}
}
return sync
}
// subagentNoteFreeExtractor 是同步装饰器:抽取前把注记从对话里剔掉。
type subagentNoteFreeExtractor struct {
inner memory.Extractor
}
// Extract 在剔除注记后的对话上运行内层提取器。
func (e *subagentNoteFreeExtractor) Extract(ctx context.Context, conversation []model.Message) ([]memory.MemoryItem, error) {
return e.inner.Extract(ctx, dialogueOnly(conversation))
}
// subagentNoteFreeAsyncExtractor 是异步装饰器:异步通道原样透传,只是输入先被剔除。
type subagentNoteFreeAsyncExtractor struct {
*subagentNoteFreeExtractor
}
// ExtractAsync 把剔除注记后的对话交给内层的异步提取。内层意外不支持异步时退化为
// 同步执行并包成一个立即关闭的通道 —— 语义仍正确,只是不再后台跑(该分支不可达:
// 构造器只在它支持异步时才选本类型)。
func (e *subagentNoteFreeAsyncExtractor) ExtractAsync(ctx context.Context, conversation []model.Message) (<-chan memory.AsyncResult, error) {
if async, ok := e.inner.(memoryAsyncExtractor); ok {
return async.ExtractAsync(ctx, dialogueOnly(conversation))
}
out := make(chan memory.AsyncResult, 1)
items, err := e.Extract(ctx, conversation)
out <- memory.AsyncResult{Items: items, Err: err}
close(out)
return out, nil
}
// dialogueOnly 返回剔除全部子代理结果注记后的对话副本。没有注记时原样返回入参
// (不做任何分配 —— 绝大多数回合因此零成本)。
func dialogueOnly(conversation []model.Message) []model.Message {
first := -1
for i, m := range conversation {
if isSubagentNote(m) {
first = i
break
}
}
if first < 0 {
return conversation
}
out := make([]model.Message, 0, len(conversation)-(first/2+1))
out = append(out, conversation[:first]...)
for _, m := range conversation[first+1:] {
if isSubagentNote(m) {
continue
}
out = append(out, m)
}
return out
}
// isSubagentNote 报告一条消息是否是子代理结果注记。判据是**元数据 + 结构性标记**:
// `role == system` **且** 正文以 `subagentNoteMarker` 开头。只认标记而不认 role 会
// 误伤主人自己打的那串字;只认 role 会把压缩摘要(同样是 system 角色,且是对话的
// 合法压缩,理应继续参与抽取)一起挡掉。
func isSubagentNote(m model.Message) bool {
if m.Role != model.RoleSystem {
return false
}
text, _ := messageTextImage(m)
return strings.HasPrefix(strings.TrimSpace(text), subagentNoteMarker)
}