Files
Cyrene_v2/internal/services/gateway/subagent_backfill_note.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

197 lines
6.9 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 (
"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]) + "…"
}