数据截至 (上游 commit 676a0a228882)
子代理与动态工作流:JS 编排多 Agent
30 秒导 读: 主代理只有一个上下文、一条回合循环(见 01)。要"同时研究五件事""让三个评审员投票""对一批 URL 跑流水线",就得开更多个 agent。Whale 提供两条路:一是
spawn_subagent工具——模型自己在回合里派生一个受限子代理;二是动态工作流——你写一段 JavaScript,用agent()/parallel()/pipeline()这些宿主函数,让脚本去编排几十个子代理。本章讲这两套机制怎么落地。
1. 这是什么(零基础也能懂)
一句话定义
- 子代理(subagent) = 一个由父代理临时派生、能力被收窄、跑完就回收的完整 agent。它有自己的会话、自己的回合循环、自己的工具集,做完一件事把一段**报告(report)**交回父代理。
- 动态工作流(dynamic workflow) = 一段跑在沙箱 JavaScript 引擎里的脚本,脚本通过宿主注入的
agent()等函数批量、并行、有依赖地调度子代理,把多 agent 编排写成普通代码(循环、map、try/catch)。
解决什么问题 / 给谁用
假设你在终端里让 Whale "深度调研一个问题"。一个 agent 顺着做,会把五个搜索角度、十五个网页、二十五条待核查断言全塞进同一个上下文——又慢、又贵、又容易在长上下文里"忘事"。
更好的做法是分而治之:
- 五个角度 → 开五个并行的搜索子代理,各查各的;
- 每个网页 → 一个抽取子代理,只读那一页、吐结构化断言;
- 每条断言 → 三个对抗性评审子代理投票,2/3 判否就杀掉。
spawn_subagent 让模型能在一个回合里手动开一个这样的子代理;工作流则让你把上面整套"扇出→流水线→投票→汇总"写成一个脚本,一次跑完。
它能做什么(功能)
| 能力 | 入口 | 谁发起 |
|---|---|---|
| 派生单个受限子代理 | spawn_subagent 工具 | 模型在回合里 |
| 廉价的多路"纯模型"推理 | parallel_reason 工具 | 模型在回合里 |
| 后台子代理 + 轮询/取消 | subagent_status / cancel_subagent | 模型在回合里 |
| JS 脚本编排多 agent | workflow 工具 → 脚本里的 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 的 JSON | internal/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 事件并调 Runner | internal/workflow/scheduler.go:75 |
workflowBudget / workflowResumeState | 完成 token 预算;基于 call-key + spec-hash 的可恢复缓存 | internal/workflow/budget.go:11、internal/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.SpawnSubagent。parallel()/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→累加用量。
这里有个易读但重要的容错:抽事件时同时 select 了 runCtx.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_subagent、parallel_reason、request_user_input、update_plan 和一整套 todo_* 工具。含义是:子代理不能再派生孙代理、不能向用户提问、不能碰计划/待办。这从结构上封死了"递归开 agent"和"子代理抢用户交互"。
4.2 第二层:按能力选择器筛选 + 只读守卫
CapabilityToolsForPermission(internal/tasks/registry.go:112)是筛选主函数。它遍历父工具,用能力选择器(如 workspace.read、shell.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,只是工具与预算不同:
| 角色 | 工具能力 | 默认工具调用上限 | 用途 |
|---|---|---|---|
explore | workspace.read + shell.read | 150 | 只读探索代码库/资料 |
research | workspace.read + web.search + web.fetch | 150 | 有源可查的研究 |
review | workspace.read + shell.read | 100 | 对已知改动集的评审 |
自定义角色可以放 .whale/agents/<name>.md,由 AgentDefinitionLibrary(internal/tasks/agent_library.go:19)从项目根和用户主目录加载并按 rank 合并。
5. 核心原理:预算、隔离与结构化输出
三个"锦上添花但很实用"的机制。