【取证(症状成立,且比登记描述更糟)】两路真并行时两路最终落盘**同一个 turn_id**,
其中一路的图彻底消失,且 A 路的工具被画在 B 路的图上。逐层定位:发布器 graphs 只按
sessionID 建键(后起的路整张顶掉前者)· 帧载荷基线的键集被既有测试精确断言为
{id,nodes,edges,sessionId,startedAt,endedAt}(无任何区分路的键)· 前端每会话单桶 ·
落盘 (session_id,turn_id) 唯一键两路相撞 · 冷启动 LastGraph 只有一张。
该 bug 早已公开登记(design/15 §2.5.10-1「本轮未解决」、契约 §6-19「仍**未修**」)。
【修法】
- 归属桶 {会话}[#{路}](loop.FlowOwnerKey,与 checkpoint 键同字符口径);路只来自
ParallelLegFrom(ctx) ⇒ 单路逐字不变(桶 = 会话 id),既有调用点语义零改动
- 图 id 的第二分量改用**建图序号**(每次 +1、永不复用):同一轮里可以有并行路 +
队首续跑的串行路,用轮次号会让两条串行路撞同一个 web-1
- 轮次号只在上一轮的路全部收尾后推进,并丢掉更早轮次的桶(否则内存随路数无界增长)
- 新增只读出口 LatestLanes ⇒ GET /admin/flow/graph 的 lanes[](最近一轮每一路),
graph 仍是「最新一路」= 向后兼容
- 同源洞一并修:子代理归属原取 AgentSpec.ParentSession(会话、不含路)⇒ 并行路里派的
子代理 span 被静默丢弃;新增 AgentSpec.FlowOwner 承载「会话#路」,ParentSession 继续
只承载会话(总线载荷 subagent_result.sessionId 不受影响)
- 前端:store 增 flowLanes(按 turnId 分格、按轮淘汰、硬上限 4)+ LaneTracks 泳道卡;
单路/未给 lanes 时渲染路径与改前逐字相同
【无迁移】turn_id 是自由文本、每路 id 唯一 ⇒ 0022 的唯一索引天然分两行(0023 仍空着)
【我的验收】① 隔离 worktree(30 文件)build/vet/linux/darwin 全过、69 包全绿 0 FAIL;
② 我亲手打红变异:让 FlowOwnerKey 忽略路 ⇒ TestFlowPublisher_ParallelLegsDoNotOverwriteEachOther
FAIL;③ 前端独立复跑 npx tsc --noEmit + npm run build 通过(4.12s)
⚠️ 契约 v0.77 的登记在紧随其后的 docs(contract) 提交里(同一个工作时段内)。
486 lines
20 KiB
Go
486 lines
20 KiB
Go
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 上;总线为 nil 或 id 未知时安静结束。
|
||
// parentSession 是该批次所属的父会话,随载荷一起广播,使中继能把回填结果
|
||
// 归属到父会话的实时流,而不是发一张无归属的帧。
|
||
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 {
|
||
return
|
||
}
|
||
}
|
||
b := t.sched.Bus()
|
||
if b == nil {
|
||
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)
|
||
}
|
||
}
|
||
|
||
// 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")
|
||
}
|