断点(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(契约归一建议报塔)。
197 lines
6.9 KiB
Go
197 lines
6.9 KiB
Go
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]) + "…"
|
||
}
|