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

502 lines
20 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 orchestration
import (
"context"
"fmt"
"io"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
)
// 子代理派发工具(design/05 §1 创建, §7 并行与安全)的常量。
const (
// SpawnToolName 是 agent 调用以派发后台子代理的工具的注册名。
SpawnToolName = "subagent_spawn"
// DefaultSpawnWait 是前台派发在把回合交还模型之前等待其批次的时间。它沿用运行时
// 既定的「前台运行,超过 2 秒自动移入后台」阈值
// (DevDocs/design/04-运行时.md §8.5, internal/runtime/task)。子代理无论如何都
// 继续运行;变的只是工具结果是否已带上它们的输出。
DefaultSpawnWait = 2 * time.Second
// maxSpawnBatch 限制一次派发最多创建多少个子代理。它约束单次模型调用能堆积的
// 队列长度(design/05 §7 防失控);并行执行上限本身仍由调度器的 MaxParallel 负责。
maxSpawnBatch = 8
// maxSpawnTextRunes 限制交回派发模型的单个子代理结论文本的字符数,使失控的子代理
// 无法淹没前台会话的上下文。调度器在其快照上保留完整文本。
maxSpawnTextRunes = 4000
// minSpawnTimeoutSeconds 与 maxSpawnTimeoutSeconds 界定按调用覆盖
// timeout_seconds 的合法区间(design/05 §7 裁决 02.4.4 的「按调用覆盖」):
// 下限挡住「立即超时」的畸形调用,上限挡住「永远不超时」的失控委托。
minSpawnTimeoutSeconds = 1
maxSpawnTimeoutSeconds = 24 * 60 * 60
// SubagentResultSubject 是后台批次结算后广播其结果的主题,载荷是
// *SubagentResultEvent(父会话 + 批次结果)。它是 design/05 §9「结果回填主
// 会话并提示用户」的载体,消费者是 gateway 的中继:该主题被登记为 WS 下行帧
// subagent_result(Docs/api-client.md §3.10),帧按载荷里的父会话归属,因此结果
// 落回父会话的实时流而非悬空 hook;逐代理进行中状态仍走调度器进度事件。
SubagentResultSubject = "subagent.result"
)
// SpawnTool 是让运行中的 agent 派发子代理的运行时入口(design/05 §1)。它是
// (*Scheduler).Spawn 唯一的生产调用方:模型像调用任何其他工具一样调用它,调度器施加
// 并行上限、按代理时限与中止机制,工具则交回合并结果,或者 —— 前台窗口内批次仍在跑时
// —— 一条派发确认。
//
// 它可安全并发使用,且不持有任何按调用的状态。
type SpawnTool struct {
sched *Scheduler
wait time.Duration
timeout time.Duration
log *slog.Logger
}
// SpawnOption 定制由 NewSpawnTool 构建的 SpawnTool。
type SpawnOption func(*SpawnTool)
// WithSpawnWait 覆盖前台派发等待其批次的时间。非正值保持 DefaultSpawnWait。
func WithSpawnWait(d time.Duration) SpawnOption {
return func(t *SpawnTool) {
if d > 0 {
t.wait = d
}
}
}
// WithSpawnLogger 替换该工具的 logger(标注来源 "orchestration/spawn")。
func WithSpawnLogger(l *slog.Logger) SpawnOption {
return func(t *SpawnTool) { t.log = spawnLog(l) }
}
// WithSpawnTimeout 设置派发层的默认子代理时限(design/05 §7 裁决 02.4.4
// 「agent_timeout 可配置」的接法):正数时作为未按调用覆盖的批次默认,
// 覆盖调度器的 PerAgentTimeout;非正保持「不覆盖」,沿用调度器默认。
// 生产接线用 SUBAGENT_TIMEOUT_SEC(默认 10min)填充它。
func WithSpawnTimeout(d time.Duration) SpawnOption {
return func(t *SpawnTool) {
if d > 0 {
t.timeout = d
}
}
}
// NewSpawnTool 在 sched 上构建派发工具。调度器为 nil 也被容忍:Execute 随后以
// ErrNoScheduler 快速失败,而不是 panic。
func NewSpawnTool(sched *Scheduler, opts ...SpawnOption) *SpawnTool {
t := &SpawnTool{sched: sched, wait: DefaultSpawnWait, log: spawnLog(nil)}
for _, opt := range opts {
if opt != nil {
opt(t)
}
}
return t
}
// Name 返回工具标识符。
func (t *SpawnTool) Name() string { return SpawnToolName }
// Description 向模型解释该工具:什么是子代理、它在隔离上下文中并行运行、慢批次会被
// 交给后台而不阻塞对话(design/05 §1/§2)。
func (t *SpawnTool) Description() string {
return "Dispatch one or more sub-agents to work in parallel on independent parts of a task. " +
"Each sub-agent runs in its own isolated context and returns a summary plus its result. " +
"A batch that does not finish within 2 seconds keeps running in the background and reports its " +
"results later; prefer several sub-agents over one serial chain for independent work."
}
// Parameters 返回输入的 JSON Schema:单个 goal 简写,或用于并行批次的 tasks 数组,
// 外加可选的 persona(子代理运行时所充当的角色,design/22 §8-2)、可选的 background
// 标志、可选的按派发工具白名单(design/05 §7)与可选的按调用超时覆盖。
func (t *SpawnTool) Parameters() (map[string]any, error) {
return map[string]any{
"type": "object",
"properties": map[string]any{
"goal": spawnStringProp("Objective the sub-agent works toward alone. Required unless tasks is given."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona, which is the role the sub-agent actually runs as."),
"mode": spawnStringProp("Behavior mode for the role, e.g. engineer (optional)."),
"attach_info": spawnStringProp("Extra context or data to hand the sub-agent (optional)."),
"tasks": spawnTasksProp(),
"tool_whitelist": spawnWhitelistProp("Applies to every sub-agent of this dispatch when a task does not set its own."),
"timeout_seconds": spawnTimeoutProp(t.timeout),
"background": map[string]any{
"type": "boolean",
"description": "Dispatch and return immediately without waiting for the foreground window (optional, default false).",
},
},
"anyOf": []any{
map[string]any{"required": []any{"goal"}},
map[string]any{"required": []any{"tasks"}},
},
}, nil
}
// Execute 派发所请求的子代理,随后要么返回它们的合并结果(批次在前台窗口内结算),
// 要么返回一条派发确认(批次已转入后台,稍后经总线报告)。
//
// 子代理在一个脱离的上下文上派发:它们的生命周期属于自己(受调度器的按代理时限与
// Abort/CancelAll 约束),从不是派发回合的,因此慢批次不会在工具返回的那一刻被杀
// (design/04 §2 后台不阻塞前台)。上下文值被保留,使子代理 span —— 以及它作为根的
// 泳道 —— 仍串在派发它的那一步之下。
func (t *SpawnTool) Execute(ctx context.Context, args any) (any, error) {
m, ok := args.(map[string]any)
if !ok {
return nil, fmt.Errorf("%s: args must be an object", SpawnToolName)
}
if t.sched == nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, ErrNoScheduler)
}
specs, err := spawnSpecs(m, loop.FlowSessionFromContext(ctx), t.timeout)
if err != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, err)
}
// flow 归属桶(`{会话}[#{路}]`)另取一次:ParentSession 必须是**真会话 id**
// (后台批次的 `subagent_result` 帧按它归属到父会话),而图归属要精确到**哪一路**,
// 否则真并行时两路子代理会混进同一张图(design/15 §2.5.10-1)。
owner := loop.FlowOwnerFromContext(ctx)
for i := range specs {
specs[i].FlowOwner = owner
}
// 在任何东西启动之前就拒绝未知的目标角色:批次必须是全有或全无,否则已经派发出的
// 子代理会继续运行,而它们的结果永远不上报给模型(design/22 §8-2: 未知角色明确报错,
// 不静默回落到默认角色).
if perr := t.sched.CheckPersonas(specs); perr != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, perr)
}
spawnCtx := context.WithoutCancel(ctx)
ids := make([]string, 0, len(specs))
for _, spec := range specs {
sub, serr := t.sched.Spawn(spawnCtx, spec)
if serr != nil {
return nil, fmt.Errorf("%s: %w", SpawnToolName, serr)
}
ids = append(ids, sub.ID)
}
t.log.Info("sub-agents dispatched", "count", len(ids), "role", specs[0].RoleID,
"persona", specs[0].PersonaID, "mode", specs[0].Mode, "session", specs[0].ParentSession)
if boolArg(m, "background") {
go t.await(ids, specs[0].ParentSession)
return runningSpawnResult(ids, specs), nil
}
return t.foreground(ids, specs), nil
}
// foreground 至多等待 t.wait 让已派发的批次结算。结算的批次被合并进工具结果;窗口
// 关闭时仍在运行的批次改到后台宣告,并立即被确认。
func (t *SpawnTool) foreground(ids []string, specs []AgentSpec) any {
waitCtx, cancel := context.WithTimeout(context.Background(), t.wait)
defer cancel()
res, err := t.sched.Wait(waitCtx, ids)
if err != nil {
t.log.Info("sub-agent batch moved to background", "count", len(ids), "waited", t.wait.String())
go t.await(ids, specs[0].ParentSession)
return runningSpawnResult(ids, specs)
}
return doneSpawnResult(res)
}
// await 阻塞到批次结算,并把合并结果作为 design/05 §2「结束后写执行总结 → 返回主会话」
// 的交接广播出去。它运行在脱离的 goroutine 上。
//
// 🔴 **每一条出口都要留下痕迹**(M29 勘测 §7 的可观测性缺口):修复前「发布成功零
// 日志、无总线静默 return」,于是「到底发没发」只能靠负证据推断(这次缺陷最费时的
// 地方)。现在成功发布记一条带来源的 INFO,缺总线与发布失败各记一条 WARN ——
// 「压根没发 / 发了但失败 / 发了」三态都能从日志正面判定。
//
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
// 归属到父会话的实时流,而不是发一张无归属的帧;gateway 的回填订阅者也按它写
// 父会话的系统注记(M32)。
func (t *SpawnTool) await(ids []string, parentSession string) {
res, err := t.sched.Wait(context.Background(), ids)
if err != nil {
t.log.Warn("sub-agent batch wait failed", "count", len(ids), "error", err)
if res == nil {
t.log.Warn("sub-agent results not published: batch produced no result",
"count", len(ids), "session", parentSession)
return
}
}
b := t.sched.Bus()
if b == nil {
t.log.Warn("sub-agent results not published: no bus",
"count", len(ids), "session", parentSession)
return
}
payload := &SubagentResultEvent{SessionID: parentSession, SpawnResult: doneSpawnResult(res)}
if perr := b.Publish(context.Background(), SubagentResultSubject, payload); perr != nil {
t.log.Warn("publish sub-agent results", "error", perr,
"count", len(ids), "session", parentSession)
return
}
t.log.Info("sub-agent results published",
"subject", SubagentResultSubject, "session", parentSession,
"count", len(ids), "status", payload.Status, "agents", len(payload.Agents))
}
// SpawnResult 是派发工具返回给派发模型的载荷:批次在前台窗口内结算时是它的合并结果,
// 转入后台时是一条派发确认。
type SpawnResult struct {
// Status 是 "done"(已结算)或 "running"(已转入后台)。
Status string `json:"status"`
// Summary 是对该批次的单行聚合。
Summary string `json:"summary"`
// Agents 持有每个已派发子代理的一个条目,按派发顺序排列。
Agents []SpawnAgentResult `json:"agents"`
}
// SpawnAgentResult 是 SpawnResult 中单个子代理的条目。
type SpawnAgentResult struct {
// ID 是子代理 id(也是 WS subagent_event 帧所携带的 id)。
ID string `json:"id"`
// Goal 是交给该子代理的目标。
Goal string `json:"goal"`
// Role 是绑定的角色/persona(若有):指名了派发目标角色时用该角色,否则是旧的
// 角色标签。
Role string `json:"role,omitempty"`
// Mode 是绑定的行为模式(若有)。
Mode string `json:"mode,omitempty"`
// Status 是返回时刻的生命周期状态。
Status string `json:"status"`
// Summary 是子代理结算时写入的执行摘要。
Summary string `json:"summary,omitempty"`
// Text 是子代理的结果文本(上限 maxSpawnTextRunes)。
Text string `json:"text,omitempty"`
// Steps 是子代理执行的循环步数。
Steps int `json:"steps,omitempty"`
}
// SubagentResultEvent 是后台批次结算后发布的回填载荷(design/05 §9;契约见
// Docs/api-client.md §3.10):SessionID 为父会话,其余字段即派发工具返回给模型的结果。
type SubagentResultEvent struct {
SessionID string `json:"sessionId,omitempty"`
*SpawnResult
}
// doneSpawnResult 把已结算的批次映射为工具结果。
func doneSpawnResult(res *MergedResult) *SpawnResult {
out := &SpawnResult{Status: "done", Agents: make([]SpawnAgentResult, 0)}
if res == nil {
return out
}
out.Summary = res.Summary
for _, a := range res.Agents {
out.Agents = append(out.Agents, SpawnAgentResult{
ID: a.ID,
Goal: a.Goal,
Role: a.RoleID,
Mode: a.Mode,
Status: string(a.Status),
Summary: a.Summary,
Text: compactText(a.Text, maxSpawnTextRunes),
Steps: a.Steps,
})
}
return out
}
// runningSpawnResult 确认一个仍在后台运行的批次。
func runningSpawnResult(ids []string, specs []AgentSpec) *SpawnResult {
out := &SpawnResult{
Status: "running",
Summary: fmt.Sprintf("已派发 %d 个子代理,正在后台运行;完成后结果回填", len(ids)),
Agents: make([]SpawnAgentResult, 0, len(ids)),
}
for i, id := range ids {
spec := specs[i]
out.Agents = append(out.Agents, SpawnAgentResult{
ID: id,
Goal: spec.Goal,
Role: spec.RoleID,
Mode: spec.Mode,
Status: string(StatusRunning),
})
}
return out
}
// spawnSpecs 把工具参数解码成每个子代理一个 AgentSpec。非空的 tasks 数组是并行批次,
// 优先于单个 goal 简写。dftTimeout 是派发层默认值(WithSpawnTimeout);非正值则让
// 调度器的按代理时限说了算。
func spawnSpecs(m map[string]any, parentSession string, dftTimeout time.Duration) ([]AgentSpec, error) {
tasks, err := taskList(m["tasks"])
if err != nil {
return nil, err
}
if len(tasks) > maxSpawnBatch {
return nil, fmt.Errorf("too many tasks: %d (max %d)", len(tasks), maxSpawnBatch)
}
if len(tasks) == 0 {
spec, serr := specFromMap(m, parentSession)
if serr != nil {
return nil, serr
}
// 派发层默认时限只补位:按调用未给出 timeout_seconds 时才生效。
if spec.Timeout == 0 {
spec.Timeout = dftTimeout
}
return []AgentSpec{spec}, nil
}
// 批次级的白名单是未自带名单任务的缺省值(缺省全开:未列出即继承全量)。
batchWhitelist := stringListArg(m, "tool_whitelist")
batchTimeout := timeoutArg(m, "timeout_seconds", dftTimeout)
specs := make([]AgentSpec, 0, len(tasks))
for i, task := range tasks {
spec, serr := specFromMap(task, parentSession)
if serr != nil {
return nil, fmt.Errorf("tasks[%d]: %w", i, serr)
}
if len(spec.ToolWhitelist) == 0 {
spec.ToolWhitelist = cloneStrings(batchWhitelist)
}
if spec.Timeout == 0 {
spec.Timeout = batchTimeout
}
specs = append(specs, spec)
}
return specs, nil
}
// specFromMap 从已解码的参数对象(顶层对象或 tasks 中的一个条目)读取一个 AgentSpec。
// goal 为空时包装 ErrMissingGoal。
func specFromMap(m map[string]any, parentSession string) (AgentSpec, error) {
goal := strings.TrimSpace(stringArg(m, "goal"))
if goal == "" {
return AgentSpec{}, ErrMissingGoal
}
personaID := strings.TrimSpace(stringArg(m, "persona"))
roleID := strings.TrimSpace(stringArg(m, "role"))
// 被指名的 persona 就是子代理实际充当的角色,因此它也填充绑定元数据:让 role 与
// persona 互相矛盾会导致工具结果、进度事件与 ws subagent_event 帧点名一个子代理
// 并未以之作答的角色。省略 persona 则逐字节保持既有的纯 role 路径
// (design/22 §8-2 向后兼容)。
if personaID != "" {
roleID = personaID
}
return AgentSpec{
Goal: goal,
RoleID: roleID,
PersonaID: personaID,
Mode: strings.TrimSpace(stringArg(m, "mode")),
AttachInfo: strings.TrimSpace(stringArg(m, "attach_info")),
ParentSession: parentSession,
ToolWhitelist: stringListArg(m, "tool_whitelist"),
Timeout: timeoutArg(m, "timeout_seconds", 0),
}, nil
}
// taskList 读取由对象组成的 tasks 数组。缺失或空数组产出 nil;非数组或条目不是对象则
// 被拒绝,使畸形批次绝不静默退化成单次派发。
func taskList(v any) ([]map[string]any, error) {
if v == nil {
return nil, nil
}
raw, ok := v.([]any)
if !ok {
return nil, fmt.Errorf("tasks must be an array")
}
out := make([]map[string]any, 0, len(raw))
for i, item := range raw {
m, ok := item.(map[string]any)
if !ok {
return nil, fmt.Errorf("tasks[%d] must be an object", i)
}
out = append(out, m)
}
return out, nil
}
// spawnStringProp 构建一个字符串属性 schema。
func spawnStringProp(desc string) map[string]any {
return map[string]any{"type": "string", "description": desc}
}
// spawnTasksProp 构建并行批次的 tasks 数组属性 schema。
func spawnTasksProp() map[string]any {
return map[string]any{
"type": "array",
"description": "Sub-agents to dispatch in parallel. When given, it overrides goal/persona/role/mode/attach_info.",
"maxItems": maxSpawnBatch,
"items": map[string]any{
"type": "object",
"properties": map[string]any{
"goal": spawnStringProp("Objective this sub-agent works toward alone."),
"persona": spawnPersonaProp(),
"role": spawnStringProp("Role/persona id to bind the sub-agent to (optional). Superseded by persona."),
"mode": spawnStringProp("Behavior mode for the role (optional)."),
"attach_info": spawnStringProp("Extra context or data for this sub-agent (optional)."),
"tool_whitelist": spawnWhitelistProp("Only this sub-agent; empty means every registered tool."),
"timeout_seconds": spawnTimeoutProp(0),
},
"required": []any{"goal"},
},
}
}
// spawnWhitelistProp 构建 tool_whitelist 属性 schema(design/05 §7
// 裁决 02.4.1):缺省全开,显式列出则只放行所列。
func spawnWhitelistProp(desc string) map[string]any {
return map[string]any{
"type": "array",
"items": map[string]any{
"type": "string",
},
"description": "Tool names the sub-agent may invoke. Omitted or empty means every registered tool " +
"(the default, the main agent's full set); listing names restricts the sub-agent to exactly those " +
"tools. " + desc,
}
}
// spawnTimeoutProp 构建 timeout_seconds 属性 schema(design/05 §7
// 裁决 02.4.4 的按调用覆盖)。dft 非正时描述为「沿用派发默认」。
func spawnTimeoutProp(dft time.Duration) map[string]any {
desc := "Per-call timeout override in seconds for the sub-agent run (optional; omitted keeps the " +
"configured sub-agent default)."
if dft > 0 {
desc = fmt.Sprintf("Per-call timeout override in seconds for the sub-agent run (optional, default %s).", dft)
}
return map[string]any{
"type": "integer",
"minimum": minSpawnTimeoutSeconds,
"maximum": maxSpawnTimeoutSeconds,
"description": desc,
}
}
// spawnPersonaProp 构建 persona 属性 schema。它是 design/22 §8-2 的派发开关:指名一个
// 角色会让子代理替班该角色(用该角色的指令与记忆作用域),而不是以派发会话的角色工作。
func spawnPersonaProp() map[string]any {
return spawnStringProp("Persona/role id the sub-agent runs AS: it answers with that role's " +
"instructions and reads/writes that role's memory scope (optional, default: the current " +
"role). A role id this runtime does not know is rejected. Supersedes role.")
}
// stringArg 读取一个字符串参数,容忍缺失或非字符串值。
func stringArg(m map[string]any, key string) string {
s, _ := m[key].(string)
return s
}
// boolArg 读取一个布尔参数,容忍缺失或非布尔值。
func boolArg(m map[string]any, key string) bool {
b, _ := m[key].(bool)
return b
}
// spawnLog 返回标注了来源 "orchestration/spawn" 的 l,并把 nil logger 换成丢弃式
// logger,使该工具总能记录日志。
func spawnLog(l *slog.Logger) *slog.Logger {
if l == nil {
l = slog.New(slog.NewTextHandler(io.Discard, nil))
}
return logger.WithSource(l, "orchestration/spawn")
}