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 (
"fmt"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/orchestration"
)
// 本文件拥有「后台批次结果写成什么一行注记」这一件事(subagent_backfill.go 的正文
// 构造)。它单独成篇的理由与 history_write.go 拆自 history.go 相同:**措辞与上限**
// 是可以在评审里被逐字审的东西,与总线/落库的生命周期无关。
//
// 三条硬口径:
//
// 1. **必须点名 `sub-<id>`**:归属可追溯(design/05 §3 的可审计访问也依赖它)。
// 读者与模型都要能分辨「这句是哪个子代理说的」,而不是她说的。
// 2. **绝不写成人格发言**:没有「我说」「我让 XX 去做」,也没有人设口吻 —— 注记是
// 一条**系统事实**,作者是 system(project 红线:人格 = 身份)。
// 3. **有界**:整条注记 ≤ maxSubagentNoteRunes(单代理正文 ≤ maxSubagentNoteBodyRunes),
// 使一条失控的子代理无法把父会话上下文淹掉(与 maxSpawnTextRunes 同一理由)。
const (
// subagentNoteMarker 标记「这是一条子代理结果注记」。它是**结构性标记**:同一个
// 包里的 `WithoutSubagentNotes`(subagent_backfill_extract.go)按它把注记挡在
// 记忆抽取之外,两处共用这一个常量,因此判据不会漂移。取法照抄 loop 的
// `summaryMarker`(压缩摘要的系统消息同一手法)。
subagentNoteMarker = "[子代理结果]"
// maxSubagentNoteRunes 是整条注记的上限。批次上限是 8 个子代理
// (orchestration 的 maxSpawnBatch),逐个正文 400 rune 时整条仍会被收到这里。
maxSubagentNoteRunes = 2000
// maxSubagentNoteBodyRunes 是单个子代理结论的上限:够读出结论,不够塞进整篇。
maxSubagentNoteBodyRunes = 400
// maxSubagentNoteGoalRunes 是注记里回显的目标上限(目标只用来认出是哪个任务)。
maxSubagentNoteGoalRunes = 120
// maxSubagentNoteSummaryRunes 是批次汇总额的上限。
maxSubagentNoteSummaryRunes = 400
// subagentNoteEmptyBody 是「该子代理什么都没写」时的诚实占位,绝不空着让读者
// 以为注记被截断了。
subagentNoteEmptyBody = "(无结论正文)"
)
// subagentNote 把一条后台批次结果渲染成父会话的注记正文。单代理批次写成一行
// (最常见、也最可读);多代理批次写汇总行 + 逐代理一行。
func subagentNote(ev *orchestration.SubagentResultEvent) string {
if ev == nil || ev.SpawnResult == nil {
return subagentNoteMarker
}
var b strings.Builder
b.WriteString(subagentNoteMarker)
b.WriteString(" ")
if len(ev.Agents) == 1 {
b.WriteString(subagentNoteSingleLine(ev.Agents[0]))
return clampNote(b.String(), maxSubagentNoteRunes)
}
b.WriteString(subagentNoteBatchLine(ev))
for _, a := range ev.Agents {
b.WriteString("\n- ")
b.WriteString(subagentNoteAgentLine(a))
}
return clampNote(b.String(), maxSubagentNoteRunes)
}
// subagentNoteSingleLine 渲染单代理批次的正文行:
// `子代理 sub-x(role/mode)已完成(目标:…):结论`。
func subagentNoteSingleLine(a orchestration.SpawnAgentResult) string {
var b strings.Builder
b.WriteString("子代理 ")
b.WriteString(subagentAgentName(a.ID))
if bind := subagentAgentBinding(a); bind != "" {
b.WriteString("(")
b.WriteString(bind)
b.WriteString(")")
}
b.WriteString(" ")
b.WriteString(subagentStatusWord(a.Status))
if goal := strings.TrimSpace(a.Goal); goal != "" {
fmt.Fprintf(&b, "(目标:%s)", clampNote(goal, maxSubagentNoteGoalRunes))
}
b.WriteString(":")
b.WriteString(subagentAgentBody(a))
return b.String()
}
// subagentNoteBatchLine 渲染批次汇总行:`2 个子代理批次已完成:<汇总>`。
func subagentNoteBatchLine(ev *orchestration.SubagentResultEvent) string {
var b strings.Builder
fmt.Fprintf(&b, "%d 个子代理批次%s", len(ev.Agents), subagentStatusWord(ev.Status))
if summary := strings.TrimSpace(ev.Summary); summary != "" {
b.WriteString(":")
b.WriteString(clampNote(summary, maxSubagentNoteSummaryRunes))
}
return b.String()
}
// subagentNoteAgentLine 渲染批次里的一行:`sub-x(role/mode)已完成:结论`。
func subagentNoteAgentLine(a orchestration.SpawnAgentResult) string {
var b strings.Builder
b.WriteString(subagentAgentName(a.ID))
if bind := subagentAgentBinding(a); bind != "" {
b.WriteString("(")
b.WriteString(bind)
b.WriteString(")")
}
b.WriteString(" ")
b.WriteString(subagentStatusWord(a.Status))
b.WriteString(":")
b.WriteString(subagentAgentBody(a))
return b.String()
}
// subagentAgentName 返回注记里点名的子代理标识。id 为空时退化为 `sub-?`(绝不留空,
// 否则「谁说的」就丢了)。
func subagentAgentName(id string) string {
if id = strings.TrimSpace(id); id != "" {
return id
}
return "sub-?"
}
// subagentAgentBinding 返回该子代理绑定的角色/模式标签(只作归属,不冒充发言人)。
func subagentAgentBinding(a orchestration.SpawnAgentResult) string {
parts := make([]string, 0, 2)
if role := strings.TrimSpace(a.Role); role != "" {
parts = append(parts, role)
}
if mode := strings.TrimSpace(a.Mode); mode != "" {
parts = append(parts, mode)
}
return strings.Join(parts, "/")
}
// subagentAgentBody 取该子代理可读的结论:优先执行摘要,其次结果正文;两者都没有
// 时给一个诚实的占位。
func subagentAgentBody(a orchestration.SpawnAgentResult) string {
for _, s := range []string{a.Summary, a.Text} {
if s = strings.TrimSpace(s); s != "" {
return clampNote(collapseBlankLines(s), maxSubagentNoteBodyRunes)
}
}
return subagentNoteEmptyBody
}
// subagentStatusWord 把编排层的生命周期状态翻成注记里可读的中文。未知状态原样保留
// (绝不把它当成「完成」—— 那正是只看状态会误判的地方)。
func subagentStatusWord(status string) string {
switch status {
case "done":
return "已完成"
case "running":
return "仍在运行"
case "failed":
return "失败"
case "aborted":
return "已中止"
case "":
return "已结束"
default:
return status
}
}
// collapseBlankLines 把连续空行收成一行空行,使注记在气泡里不会拉出一大片空白
// (子代理正文常带 markdown 段落间距)。
func collapseBlankLines(s string) string {
var b strings.Builder
blank := 0
for _, line := range strings.Split(s, "\n") {
if strings.TrimSpace(line) == "" {
blank++
if blank > 1 {
continue
}
} else {
blank = 0
}
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(strings.TrimRight(line, " \t\r"))
}
return strings.TrimSpace(b.String())
}
// clampNote 把 s 收到 n 个 rune 以内(超出时以省略号收尾)。n 非正时返回空串。
func clampNote(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n-1]) + "…"
}