断点(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(契约归一建议报塔)。
502 lines
20 KiB
Go
502 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 上。
|
||
//
|
||
// 🔴 **每一条出口都要留下痕迹**(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")
|
||
}
|