跳到主要内容

子代理与动态工作流:JS 编排多 Agent

30 秒导读: 主代理只有一个上下文、一条回合循环(见 01)。要"同时研究五件事""让三个评审员投票""对一批 URL 跑流水线",就得开更多个 agent。Whale 提供两条路:一是 spawn_subagent 工具——模型自己在回合里派生一个受限子代理;二是动态工作流——你写一段 JavaScript,用 agent() / parallel() / pipeline() 这些宿主函数,让脚本去编排几十个子代理。本章讲这两套机制怎么落地。


1. 这是什么(零基础也能懂)

一句话定义

  • 子代理(subagent) = 一个由父代理临时派生、能力被收窄、跑完就回收的完整 agent。它有自己的会话、自己的回合循环、自己的工具集,做完一件事把一段**报告(report)**交回父代理。
  • 动态工作流(dynamic workflow) = 一段跑在沙箱 JavaScript 引擎里的脚本,脚本通过宿主注入的 agent() 等函数批量、并行、有依赖地调度子代理,把多 agent 编排写成普通代码(循环、maptry/catch)。

解决什么问题 / 给谁用

假设你在终端里让 Whale "深度调研一个问题"。一个 agent 顺着做,会把五个搜索角度、十五个网页、二十五条待核查断言全塞进同一个上下文——又慢、又贵、又容易在长上下文里"忘事"。

更好的做法是分而治之:

  • 五个角度 → 开五个并行的搜索子代理,各查各的;
  • 每个网页 → 一个抽取子代理,只读那一页、吐结构化断言;
  • 每条断言 → 三个对抗性评审子代理投票,2/3 判否就杀掉。

spawn_subagent 让模型能在一个回合里手动开一个这样的子代理;工作流则让你把上面整套"扇出→流水线→投票→汇总"写成一个脚本,一次跑完。

它能做什么(功能)

能力入口谁发起
派生单个受限子代理spawn_subagent 工具模型在回合里
廉价的多路"纯模型"推理parallel_reason 工具模型在回合里
后台子代理 + 轮询/取消subagent_status / cancel_subagent模型在回合里
JS 脚本编排多 agentworkflow 工具 → 脚本里的 agent() / parallel() / pipeline() / workflow()用户显式启动的脚本

用起来什么样

一段真实的工作流脚本(内置 deep-research,internal/workflow/testdata/claude_code_deep_research.js)长这样——注意它就是普通 JS,agent() 返回可 await 的值:

// 示意,取自内置 deep-research 脚本
phase("Scope")
const scope = await agent(
"把这个研究问题拆成 5 个互补的搜索角度……只返回结构化输出。",
{ label: "scope", schema: SCOPE_SCHEMA } // 要求子代理产出符合 schema 的 JSON
)

// 扇出:每个角度一个搜索子代理,并行跑
const searchResults = await pipeline(
scope.angles,
angle => agent(SEARCH_PROMPT(angle), { phase: "Search", schema: SEARCH_SCHEMA })
.then(r => ({ angle: angle.label, results: r.results })),
searchResult => parallel( // 每个搜到的源再扇出一个抽取子代理
novel.map(source => () => agent(FETCH_PROMPT(source), { schema: EXTRACT_SCHEMA }))
)
)

一句话直觉

  • 子代理 ≈ 雇一个只带了指定工具的临时工,交代一件事,他做完写份报告走人,期间他看不到你办公室的全部东西(能力被收窄)。
  • 工作流 ≈ 一张写死的排班表 + 调度脚本,决定这些临时工谁先谁后、谁和谁并行、谁的产出喂给谁。

边界:单个 agent 从输入到工具执行的主回合循环属于 01 回合循环;provider(DeepSeek)细节属于 06 Provider 与扩展面。本章只讲"如何开出并编排多个 agent"。


2. 顶层全景(它大概怎么转)

两个子系统,一个共同的底座

两条路最终都汇到同一个动作:Runner.SpawnSubagent —— 造一个 agent.Agent、跑一遍它的回合循环、把最终消息当报告收回。

┌───────────────────────── 父代理回合循环 (01 章) ─────────────────────────┐
│ │
模型发起 │ spawn_subagent 工具 parallel_reason 工具 workflow 工具 │
(一个回合里) │ │ │ │ │
└────────┼───────────────────────────────┼─────────────────────────┼───────┘
│ │ │
▼ ▼ ▼
internal/tasks.Runner 纯模型并行 worker internal/workflow.ScriptRunner
.SpawnSubagent(...) (无工具、无循环) (在沙箱 QuickJS 里跑脚本)
│ │
│ 脚本调用 agent()/parallel()/pipeline()
│ │
│ ▼
│ TaskScheduler.SpawnAgent
│ (记事件 + 调 Runner)
│ │
└──────────────────────┬─────────────────────────────────┘

┌──────────────────────────────────────────────────┐
│ 造子代理:收窄工具集 + 收窄权限 + 隔离工作区 │
│ agent.NewAgentWithRegistry(...) → 跑子回合循环 │
│ 收集事件 → 截出 report/summary → 回收 │
└──────────────────────────────────────────────────┘

怎么读这张图: 上半是"谁发起",中间是"三条不同的路",下半是"它们共用的造子代理底座"。parallel_reason 是特例——它不造完整 agent,只并行跑几个"无工具的纯推理 worker"。

部件一句话职责

部件干什么在哪个文件
Runner子代理工厂:持有 provider 工厂、父工具、父策略、预算计数器internal/tasks/runner.go:76
Runner.SpawnSubagentWithProgress派生子代理的主流程(收窄→造 agent→跑→收报告)internal/tasks/subagent.go:195
能力注册表构建从父工具集里按能力/权限筛出子代理能用的工具internal/tasks/registry.go:112 CapabilityToolsForPermission
childToolPolicy / childBasePolicy收窄子代理的权限策略(写类工具改为需审批)internal/tasks/subagent.go:156
structuredOutputTool给子代理挂一个 structured_output 工具,强制产出符合 schema 的 JSONinternal/tasks/structured_output_tool.go:57
ScriptRunner工作流引擎:解析脚本、起 JS 运行时、注入宿主函数、记事件internal/workflow/script_runner.go:74
workflowJSRuntime基于 fastschema/qjs 的 QuickJS 沙箱 + 看门狗internal/workflow/js_runtime.go:31
TaskScheduler.SpawnAgent工作流里每次 agent() 的落地:记 task_started/task_completed 事件并调 Runnerinternal/workflow/scheduler.go:75
workflowBudget / workflowResumeState完成 token 预算;基于 call-key + spec-hash 的可恢复缓存internal/workflow/budget.go:11internal/workflow/resume.go:15

主线走一遍(高层)

路 A(模型手动开一个): 模型在回合里发 spawn_subagent(task=…, role="explore")spawnSubagentTool.Run(internal/tasks/tools.go:163)解码请求、清掉模型乱塞的预算 → Runner.SpawnSubagentWithProgress 收窄工具/权限、造子 agent、跑子循环、把子代理最终消息截成 report 回传。

路 B(脚本编排一批): 用户确认启动某个命名工作流 → ScriptRunner.StartWorkflow(internal/workflow/script_runner.go:112)解析脚本、装预算、异步 go runScript → 沙箱里执行脚本 → 脚本每调一次 agent() 就走 TaskScheduler.SpawnAgent → 同样落到 Runner.SpawnSubagentparallel()/pipeline() 只是"先收集这批调用、再用 Go 侧并发池一起跑"。


3. 核心原理:派生一个子代理

先把"路 A"这条最基础的主线讲透——工作流不过是"批量的路 A"。

3.1 请求与响应的契约

一次派生的输入是 SpawnSubagentRequest,输出是 SpawnSubagentResponse(internal/tasks/subagent.go:22:39)。有两个设计要点值得记:

报告(Report)是一等字段,不是 summary。 源码注释把话说死了:Report 是父代理必须读的主载荷(子代理的完整最终消息),Summary 只是给 UI/进度用的一行预览,绝不能当成父代理拿到的唯一东西。

// internal/tasks/subagent.go:45 (节选注释)
// Report is the subagent's full final assistant message — the primary
// payload the parent agent must read. ...
// Summary is a one-line preview of Report ... It is NOT a substitute for Report

执行预算不让模型猜。 SpawnSubagentRequest 里有 MaxToolIters/MaxToolCalls,但模型走工具时这两个字段被强制清零(internal/tasks/tools.go:182):req.MaxToolCalls = 0; req.MaxToolIters = 0。上限只来自角色定义或 Runner 默认(DefaultMaxToolIters = 200,internal/tasks/runner.go:24)。理由在注释里:父模型一猜预算,往往把一个本该翻五十个文件的彻底调研给"饿死"。工作流脚本走的是另一条路(scheduler 直接构造请求),不受此限。

3.2 派生七步走

SpawnSubagentWithProgress 是全章最重要的函数,主体在 internal/tasks/subagent.go:195。它做的事按顺序是:

① 解析运行时配置 ResolveAgentRuntimeConfigWithLibrary → 定角色/模型/工具选择器/权限档
② 解析工作区 resolveSubagentWorkspace → 普通共享根,或隔离 worktree
③ 收窄工具集 BuildAgentRegistryForMCPServers → 从父工具里筛出子代理能用的
④ (可选)挂 schema 给注册表追加 structured_output 工具
⑤ 落会话元数据 saveSubagentMeta(status=running, 记 parent/role/model/workspace…)
⑥ 组装系统块 agent 定义块 + 工作流上下文块 + schema 块 + skills 块 + memory 块
⑦ 造子 agent 并跑 newChild() → RunStream… → drainEvents 收事件 → 截 report/summary

第 ⑦ 步里 newChild(internal/tasks/subagent.go:297)是关键:它用 agent.NewAgentWithRegistry 造一个和父代理同款agent.Agent,但注入了收窄后的注册表、收窄后的策略、子会话模式,以及一串额外系统块。注意 WithApprovalFunc 里的注释——子注册表虽然能力受限,审批决策仍走父代理的审批通道,这样工作区/用户的权限规则在子代理里照样生效。

3.3 收事件:为什么要防"关不掉"

子代理跑起来后,父侧用 drainEvents(internal/tasks/subagent.go:354)把子代理的事件流抽干:工具调用→发进度、工具结果→发进度、Done→截出报告、Usage→累加用量。

这里有个易读但重要的容错:抽事件时同时 selectrunCtx.Done()。注释解释得很直白——如果子代理的 provider 卡住、既不返数据也不理会 ctx,父回合不能跟着死锁在这里(用户视角就是"关不掉"),所以 ctx 一取消就返回 "cancelled",把卡住的子 goroutine丢弃、让它自己将来解封时退出。

// internal/tasks/subagent.go:364 (节选)
case <-runCtx.Done():
// The child's stream hung without closing ... Stop waiting so the
// parent turn can unwind instead of deadlocking here ("关不掉");
return "cancelled", runCtx.Err()

4. 核心原理:能力与权限如何被收窄

这是子代理"安全"的核心——子代理默认只读,且只能看到父工具的一个子集。分三层。

4.1 第一层:哪些工具根本不给

excludedChildTools(internal/tasks/registry.go:35)是一份硬黑名单:spawn_subagentparallel_reasonrequest_user_inputupdate_plan 和一整套 todo_* 工具。含义是:子代理不能再派生孙代理、不能向用户提问、不能碰计划/待办。这从结构上封死了"递归开 agent"和"子代理抢用户交互"。

4.2 第二层:按能力选择器筛选 + 只读守卫

CapabilityToolsForPermission(internal/tasks/registry.go:112)是筛选主函数。它遍历父工具,用能力选择器(如 workspace.readshell.run,常量见 internal/tasks/registry.go:14)决定放行谁,再看权限档决定"放行的能否写"。

关键在只读守卫:当权限档是 read_only(默认),放行的工具会被包一层 guardedReadOnlyTool(internal/tasks/registry.go:299)。这层壳在每次调用前跑 core.IsReadOnlyToolCall,运行时判断这次调用是不是只读;不是就直接返回 read_only_required 错误、根本不碰真工具:

// internal/tasks/registry.go:331 guardReadOnly (节选)
if core.IsReadOnlyToolCall(t.spec, call) {
return core.ToolResult{}, false // 放行
}
return core.ToolResult{ ... Code: "read_only_required" }, true // 拦截

妙在:一个 shell_run 工具,可以只按 shell.read 能力放进来、但被守卫挡住所有写操作——同一个工具,细到"单次调用"的粒度收窄。

4.3 第三层:写类工具改判"需审批"

如果权限档是 ask(而非 read_only),子策略换成 childToolApprovalPolicy(internal/tasks/child_policy.go:11)。它先让基础策略裁决,若基础放行、又不是只读、且这次调用命中了"写类能力"(workspace.write / shell.run / terminal.write / mutates_state),就强制改成需要用户审批(internal/tasks/child_policy.go:16 Decide)。一句话:子代理可以有写能力,但写之前得过审批门。

关于父代理这套"规则策略 + LLM 自动审查"的完整权限模型,见 04 安全与权限;这里只讲子代理如何在其之上再收窄

4.4 内置角色:三种只读身份

不给 role 时默认 explore。三个内置角色都在 builtinAgentDefinition(internal/tasks/agent_definition.go:276),全是 read_only,只是工具与预算不同:

角色工具能力默认工具调用上限用途
exploreworkspace.read + shell.read150只读探索代码库/资料
researchworkspace.read + web.search + web.fetch150有源可查的研究
reviewworkspace.read + shell.read100对已知改动集的评审

自定义角色可以放 .whale/agents/<name>.md,由 AgentDefinitionLibrary(internal/tasks/agent_library.go:19)从项目根和用户主目录加载并按 rank 合并。


5. 核心原理:预算、隔离与结构化输出

三个"锦上添花但很实用"的机制。

5.1 子代理预算:软提示,不是硬闸

每派生一次,recordSubagentBudget(internal/tasks/subagent.go:575)累加 SpawnCountTotalTokens,并按阈值生成一句 Hint 塞回响应里(subagentBudgetHint,internal/tasks/subagent.go:592):

  • >4 次或 ≥50000 token → 提示"预算偏高,优先用当前上下文工具或合并剩余子任务";
  • >1 次 → 提示"正在升温,小跟进优先直接用工具"。

注意这是给模型看的软信号,不阻断——每个新子代理都可能吃一次 prefix-cache miss + 一整轮子循环(呼应 02 提示词缓存 的成本模型)。真正的硬闸在工作流那侧(见 §7.4)。

5.2 隔离 worktree:让子代理在副本里改

若角色的 Isolationworktree,resolveSubagentWorkspace(internal/tasks/subagent.go:653)会用 worktree.Start 开一个独立 git worktree,子代理的所有改动落在副本、不污染主工作区。worktree 命名由 isolatedWorktreeName(internal/tasks/subagent.go:711)生成:agent-<role>-<sessionID 的 sha1 前 10 位>,并裁到 64 字符内(git 分支名安全)。

5.3 结构化输出:强制吐 JSON,还带一次"修复"

当请求带 OutputSchema,派生流程给子注册表追加一个 structuredOutputTool(internal/tasks/structured_output_tool.go:57)。它的 Runjson.Unmarshal + jsonschema.ValidateValue,只有校验通过才 capture.set(value)。系统块(internal/tasks/prompts.go:86)明确告诉子代理:最终结果必须structured_output 恰好一次,不许用散文/Markdown 顶替。

真正巧的是一次修复重试(internal/tasks/subagent.go:477):子代理跑完若没成功产出结构化结果,父侧不是直接失败,而是用 structuredOutputRepairPrompt(internal/tasks/prompts.go:223)再起一个只挂 structured_output 工具、只准跑 1 轮的修复子代理,叫它"别再研究了,用已有信息现在就调一次 structured_output"。仍失败才报 structured_output_missing/_invalid


6. 核心原理:回合里的并行(两个廉价特例)

在进入完整工作流之前,先看回合循环自己就能做的两种"轻量并行"。

6.1 parallel_reason:纯模型 fan-out

ParallelReason(internal/tasks/parallel_reason.go:33)接收 1–8 条 prompt,每条起一个 goroutine,用一个"廉价纯推理 worker"系统提示(You are a cheap parallel reasoning worker...,:139)跑,无工具、无循环。适合比较/分类/头脑风暴。

它的并发收集也带"防关不掉"设计:worker 通过一个带缓冲(每 prompt 一格)的 channel 回报,而不是直接写共享 slice——缓冲保证再慢的 worker 也能把结果送出去不阻塞,所有对 results[] 的写入都收敛到主 goroutine,天然无数据竞争(internal/tasks/parallel_reason.go:64 起的长注释)。ctx 一停,已产出的照收、剩下的逐个标"canceled",顶层不报错——取消是按结果报告的。

6.2 回合循环:把连续的 spawn_subagent 一起跑

如果模型在一个回合里连着发多个 spawn_subagent 调用,回合循环会把它们并行 dispatch。识别逻辑在 eligibleReadyParallelSubagentGroups(internal/agent/parallel_subagent.go:30):只把连续、原始 tool-call 槽相邻的 spawn_subagent 归为一组,且至少 2 个才成组。

真正并行执行在 flushPendingParallelSubagents(internal/agent/stream.go:110)→ dispatchParallelToolCallsWithRecovery(:173):用固定数量的 worker goroutine 从 workCh 取活,结果按原始索引 sort.SliceStable 排回原序。并行度默认 CPU×2(下限 2、有上限,defaultMaxParallelSubagents,internal/agent/agent.go:337)。一个细节(internal/agent/stream.go:245 注释):并行 spawn 批次的 post-hook 在整批返回后、按原始 tool-call 顺序统一跑,即便底层子代理乱序完成,存储的结果与事件仍是确定序。


7. 核心原理:动态工作流(JS 编排多 Agent)

现在是本章的主菜:用 JavaScript 把上面这些拼成任意拓扑。

7.1 为什么用一段真 JS,而不是配置

fan-out、流水线、投票、条件重试……这些用声明式配置表达很别扭,用代码却很自然(mapfiltertry/catch、累加状态)。Whale 的选择是:让你写真 JavaScript,把"调度一个子代理"做成一个可 await 的宿主函数 agent(),其余交给语言本身。脚本头部 export const meta = {...} 声明名字/描述/阶段(见 internal/workflow/testdata/claude_code_deep_research.js:1),被 parseWorkflowScript 抽出来做展示与预算默认。

7.2 沙箱:QuickJS + 看门狗

脚本不是在 Node 里跑,而是在一个隔离的 QuickJS 实例里(fastschema/qjs)。newWorkflowJSRuntime(internal/workflow/js_runtime.go:31)造运行时时设了硬边界:

限制常量
内存上限64 MBdefaultWorkflowJSMemoryLimit
栈上限2 MBdefaultWorkflowJSMaxStack
无 yield 超时10sdefaultWorkflowJSTimeout
stdout/stderr丢弃io.Discard
CWD私有临时目录(0700)os.MkdirTemp

看门狗(watchdog) 防死循环(workflowJSWatchdog,internal/workflow/js_runtime.go:153):它按 timeout/4 的间隔巡检,如果脚本超过 10s 没有把控制权交还给宿主 API,就取消 ctx、报"execution exceeded ... without yielding"。

关键设计:等待子代理是合法的慢,不该被判死。所以每次调用宿主函数(agent()/parallel()/…)都用 workflowJSHostWait(internal/workflow/js_runtime.go:271)包起来——它 enterHostWait 让看门狗暂停计时,函数返回再 leaveHostWait。于是"跑一个纯 JS 死循环"会被杀,"等十个子代理各跑两分钟"不会。

// internal/workflow/js_runtime.go:246 check (节选)
if w.cause != nil || w.hostDepth > 0 { // 正在等宿主 → 不算超时
return
}
if time.Since(w.lastBeat) <= w.timeout {
return
}
w.cause = fmt.Errorf("workflow JS execution exceeded %s without yielding ...", w.timeout)

运行时还掐掉了两个非确定性 API:Date.now()Math.random() 都被替换成抛错的函数(internal/workflow/script_runner_execute.go:589),因为它们会破坏"可恢复"(见 §7.5)。

7.3 宿主函数:agent / parallel / pipeline / workflow

这些函数在 executeScript(internal/workflow/script_runner_execute.go:15)里用 jsctx.SetFunc 注入。它们的精髓是一套**"收集—执行"两态机**:

  • 直接调用(不在 parallel/pipeline 内):agent()(:334)立即 spawnCall 跑,返回结果。
  • 收集态(在 parallel/pipeline 的 thunk 里):agent() 不真跑,而是把调用追加到当前收集帧并返回一个"标记对象",这个对象带 .then()/.catch() 用来登记后处理/兜底函数。

parallel(thunks)(internal/workflow/script_runner_execute.go:392)因此这样工作:开一个收集帧 → 逐个 InvokeJS 每个 thunk(每个 thunk 最多调一次 agent()/workflow(),否则报错)→ 收集到一批调用 → 用 runParallelCalls(internal/workflow/script_runner.go:927)以 Go 侧 worker 池并发执行 → 按登记的 .then/.catch 处理每个结果。这就是"每个 thunk 写成 () => agent(...)"的原因:thunk 是"待执行的意图",不是立即执行。

pipeline(items, ...stages)(:509)更进一步:对一批 items,每个 item 顺序流过多个 stage,stage 之间的产出喂给下一个;不同 item 之间的 stage 又能并行推进(实现见 runPipelineItems,internal/workflow/script_runner_pipeline.go:9,它维护每个 item 的 stage 指针、用一个 activeAgents < maxConcurrency 的调度环把待执行的 agent 调用喂出去)。deep-research 就是用它做"搜索→URL 去重→抓取抽取"这条无栅栏流水线。

workflow(nameOrRef, args)(:368)允许一个工作流调另一个工作流,但只允许嵌套一层(nestingDepth >= 1 直接报错,internal/workflow/script_runner.go:613)。

并发上限:工作流内 DefaultMaxConcurrency = 3(internal/workflow/manager.go:14),外加 agentGate 信号量总闸;整个运行的 agent() 次数硬上限 MaxAgentCalls = 1000(internal/workflow/script_runner.go:106)。

7.4 调度与记账:每个 agent() 都留痕

脚本里一次 agent() 落到 Go 侧是 TaskScheduler.SpawnAgent(internal/workflow/scheduler.go:75)。它做三件事:task_started 事件 → 调 Spawner.SpawnSubagentWithProgress(就是 §3 的 Runner)→ 按成败task_completed/task_failed 事件。事件写进 RunEventStore,/workflows 面板就是读它。

预算是硬闸:每个子代理开跑前 budget.checkCanStart()(internal/workflow/budget.go:54),已花的完成 token >= 总额就直接失败;跑完 budget.addUsage 累加并发 budget_updated 事件。总额可由脚本 meta.defaultBudgetTokens 或启动参数给。脚本里还能读到一个冻结的 budget 全局(total/spent()/remaining(),internal/workflow/script_runner.go:396 注入)。

7.5 可恢复:call-key + spec-hash 缓存

工作流可以从一次已结束的运行恢复(resumeFromRunId)。机制在 resume.go:恢复时把源运行里每个 task_completed 事件按 call_key 建索引(newWorkflowResumeState,internal/workflow/resume.go:36)。重跑时,每个 agent() 调用先算一个确定性的 call-key(基于它在脚本结构里的位置,如 root/agent-0001.../parallel-0001/item-2/...)和一个 spec-hash(把 prompt/角色/模型/schema 等规范化后 sha256,workflowSpecHash,internal/workflow/resume.go:89)。

lookup(callKey, specHash)(internal/workflow/resume.go:72)命中且 spec-hash 一致 → 直接复用旧结果,不重跑子代理;一旦某个 key 的 spec-hash 对不上(脚本改了),就把整个恢复状态 invalidated,后面全部重跑。这也解释了为什么 §7.2 要禁掉 Date.now()/Math.random():call-key 依赖脚本执行路径可确定重放


8. 深入实现:读一遍 deep-research

把前面的机制串成一条真实拓扑(internal/workflow/testdata/claude_code_deep_research.js)。它是本仓库唯一的内置工作流(BuiltinDeepResearchName,internal/workflow/builtin.go:5,脚本用 //go:embed 嵌入)。

args = 研究问题

▼ phase("Scope")
agent(拆角度, schema=SCOPE_SCHEMA) ──► scope.angles (3–6 个搜索角度)

▼ phase("Search") ← pipeline(scope.angles, 搜索stage, 去重+抓取stage)
┌───────────────── 每个 angle 一条流水线,item 间并行 ─────────────────┐
│ stage1: agent(SEARCH_PROMPT, schema=SEARCH_SCHEMA) → 4–6 个源 │
│ stage2: JS 侧 URL 去重 + 预算配额,再 parallel(每个新源一个抓取agent)│
│ agent(FETCH_PROMPT, schema=EXTRACT_SCHEMA).then(...).catch(...)│
└──────────────────────────────────────────────────────────────────────┘

▼ phase("Verify") ← 每条断言 3 票对抗投票
parallel( 每条断言 → parallel(3 个 VERIFY agent).then(2/3 refute 就杀) )

▼ phase("Synthesize")
agent(把存活断言按置信度排序、引用来源、汇总) → 最终报告

几个值得学的写法:

  • schema 贯穿始终:每个 agent() 都带 schema,所以脚本拿到的是结构化对象(scope.anglesr.resultsext.claims),能直接 .map/.filter,而不是解析散文。
  • 去重是普通 JS 状态:seen(Map)、fetchSlots(配额计数)就是脚本里的闭包变量,在 pipeline 的 stage 之间累积——编排逻辑和业务逻辑写在一起,不需要框架 DSL。
  • 容错分层:.then(ext => ext ? {...} : null) 处理"用户跳过";.catch(e => {...sourceQuality:"unreliable"...}) 处理抓取失败并降级而不是让整条流水线崩。
  • 投票是嵌套 parallel:外层 parallel 遍历断言,内层 parallel 跑三个投票 agent,.then(verdicts => 数 refute) 做裁决。

9. 巧妙之处(可借鉴的技术)

  • "报告 vs 摘要"分成两个字段并写死语义。 用注释强制"父代理必须读 Report、Summary 只是预览",避免了"上层只拿到一行字导致信息丢失"这种隐性 bug(internal/tasks/subagent.go:45)。
  • 运行时只读守卫,而非"只给只读工具"。 同一个 shell_run 工具能按 shell.read 放进来、再逐次调用拦写操作,收窄粒度细到单次调用(internal/tasks/registry.go:299/331)。
  • 看门狗区分"死循环"和"等子代理"。 hostDepth 让"等宿主 API"暂停计时,于是能杀掉纯 JS 死循环、又不误杀慢工作流(internal/workflow/js_runtime.go:221/246)。
  • 禁掉 Date.now/Math.random 换来可恢复。 用"剥夺非确定性"换"call-key 可重放",是很干净的取舍(internal/workflow/script_runner_execute.go:589 + resume.go)。
  • "收集—执行"两态机让 parallel/pipeline 用普通 JS thunk 表达。 agent() 在收集态只登记不执行,返回带 .then/.catch 的标记对象,把"意图"和"执行"解耦(internal/workflow/script_runner_execute.go:334/392)。
  • 一次结构化输出修复重试。 子代理没吐合规 JSON 时,不直接失败,而是起一个"只准调 structured_output、只跑 1 轮"的修复子代理(internal/tasks/subagent.go:477)。
  • 到处防"关不掉"。 drainEventsparallel_reason、并行 dispatch 都用带缓冲 channel + ctx.Done() 逃逸,保证一个卡住的子任务不拖垮父回合(internal/tasks/subagent.go:364internal/tasks/parallel_reason.go:64)。

10. 边界与局限

  • 工作流默认关闭。 WorkflowsEnabled 默认 false(internal/app/app_config.go:35,配置键 workflows.enabled)。禁用时 workflow 工具返回 workflow_disabled 并要求用户去 /config 打开(internal/workflow/tool.go:50)。
  • 子代理不能递归开子代理。 spawn_subagent/parallel_reason/request_user_input/update_plan/todo_* 都在子代理黑名单里(internal/tasks/registry.go:35)。
  • 工作流嵌套只允许一层。 workflow() 在子工作流里再调会报错(internal/workflow/script_runner.go:613)。
  • 子代理默认只读。 要写必须显式给写能力(且在 ask 档下过审批)。
  • 执行预算不由模型控制。 工具侧强制清零 MaxToolIters/MaxToolCalls(internal/tasks/tools.go:182);上限来自角色/默认。
  • 卡住的子代理是"丢弃"而非"杀死"。 ctx 取消后父侧不再等,但真正停下取决于子 provider 是否理会 ctx(internal/tasks/subagent.go:364 注释)。
  • 沙箱是隔离的 QuickJS,不是 Node。 没有 fs/net/require;连 URL 都是脚本运行时临时 polyfill 的(internal/workflow/script_runner_execute.go:25)。脚本对外界的唯一影响就是通过 agent() 派生子代理。

11. 横向对比

维度本章(Whale 子代理/工作流)同 shelf 兄弟的取舍
编排语言沙箱 QuickJS 里的真 JavaScript许多框架用声明式图/YAML 或 Python 装饰器
子代理默认权限只读,运行时守卫逐次拦写常见做法是按工具白名单静态放行
可恢复call-key + spec-hash 缓存,禁非确定性 API多数靠持久化每步输出 + 重放

兄弟章:01 回合循环(子代理复用同一套 agent.Agent 循环)、02 提示词缓存(每个新子代理为何可能吃 cache miss)、04 安全与权限(子代理在其上再收窄)、06 Provider 与扩展面(子代理与 worker 用的 provider)。整体地图见 index


12. 代码地图(导航索引)

主题文件路径符号名
派生子代理主流程internal/tasks/subagent.goRunner.SpawnSubagentWithProgress
请求/响应契约internal/tasks/subagent.goSpawnSubagentRequest / SpawnSubagentResponse / SubagentBudget
收事件 + 防关不掉internal/tasks/subagent.godrainEvents(在 runChild 内)
子会话/工作区/命名internal/tasks/subagent.gochildSessionID / resolveSubagentWorkspace / isolatedWorktreeName
权限收窄(基础)internal/tasks/subagent.gochildToolPolicy / childBasePolicy
写类工具需审批internal/tasks/child_policy.gochildToolApprovalPolicy.Decide
工具能力筛选 + 只读守卫internal/tasks/registry.goCapabilityToolsForPermission / guardedReadOnlyTool / excludedChildTools
子代理工厂/默认值internal/tasks/runner.goRunner / NewRunner / DefaultMaxToolIters
内置角色定义internal/tasks/agent_definition.gobuiltinAgentDefinition / AgentDefinition
角色定义库(.whale/agents)internal/tasks/agent_library.goAgentDefinitionLibrary
结构化输出工具 + 修复internal/tasks/structured_output_tool.go / internal/tasks/subagent.gostructuredOutputTool / structuredOutputRepairPrompt
子代理系统块internal/tasks/prompts.goagentDefinitionSystemBlock / outputSchemaSystemBlock
纯模型并行internal/tasks/parallel_reason.goRunner.ParallelReason
模型侧工具入口internal/tasks/tools.gospawnSubagentTool / parallelReasonTool
回合里并行 spawninternal/agent/parallel_subagent.go / internal/agent/stream.goeligibleReadyParallelSubagentGroups / dispatchParallelToolCallsWithRecovery
工作流引擎internal/workflow/script_runner.goScriptRunner.StartWorkflow / runParallelCalls
宿主函数注入internal/workflow/script_runner_execute.goexecuteScript(agent/parallel/pipeline/workflow/phase/log)
流水线调度internal/workflow/script_runner_pipeline.gorunPipelineItems
JS 沙箱 + 看门狗internal/workflow/js_runtime.gonewWorkflowJSRuntime / workflowJSWatchdog / workflowJSHostWait
调度与事件记账internal/workflow/scheduler.goTaskScheduler.SpawnAgent
预算internal/workflow/budget.goworkflowBudget.checkCanStart
可恢复缓存internal/workflow/resume.goworkflowResumeState.lookup / workflowSpecHash
工作流作为工具暴露 + 开关internal/workflow/tool.goTool.Run / workflowDisabledUserMessage
内置 deep-researchinternal/workflow/builtin.go / internal/workflow/testdata/claude_code_deep_research.jsBuiltinDeepResearchName