Workflow 运行时

把多智能体编排写成跑在 QuickJS 里的脚本:宿主注入的原语、子智能体派发、并发与重试上限、日志与续跑、事件和界面。

作者 David更新于 第 22 篇(共 33 篇)

让主智能体一轮轮地派子智能体,编排逻辑就散在对话里:没法复现,进程一崩也没法从中间接着跑。MiMo 的 Workflow 把编排写成一段 JavaScript:分几个阶段、哪里并行、失败重试几次、什么时候停,由脚本决定;每一处真正“干活”的调用交给子智能体。脚本跑在 QuickJS 沙箱里,只能用宿主注入的少数几个函数。

这套运行时整个在 packages/opencode/src/workflow/ 下,上游 OpenCode 没有对应物。沙箱本身(QuickJS 的装配、期限与内存)放在 exec 与 QuickJS 沙箱,四个内置脚本放在下一篇。

怎么用

  • 默认关闭:设 MIMOCODE_EXPERIMENTAL_WORKFLOW_TOOL=true(或总开关 MIMOCODE_EXPERIMENTAL)后,才会注册 workflow 工具、TUI 的 /workflows 命令和 /deep-research 命令(packages/opencode/src/flag/flag.ts:361、packages/opencode/src/tool/registry.ts:312、packages/opencode/src/cli/cmd/tui/app.tsx:597、packages/opencode/src/command/index.ts:193)。
  • 工具参数:operation 有五种。run 要么给 name 跑内置 workflow,要么给 script 跑内联脚本,不能同时给;可选 args、workspace、async。另外四种是 status、wait、cancel、resume(packages/opencode/src/tool/workflow.ts:36)。工具说明末尾会附上内置 workflow 目录(registry.ts:490)。
  • 配置:workflow 段有 maxConcurrentAgents、maxDepth、maxLifecycleAgents、scriptDeadlineMs 四项(packages/opencode/src/config/config.ts:466),默认值见后文的表。
  • 界面:运行中的 workflow 在对话里是一个面板,实时刷新阶段、日志和计数;/workflows 列出当前会话的所有运行,选中后进入全屏页面。
  • 自定义脚本:放在 .mimocode/workflows/<name>.js 或 .claude/workflows/<name>.js,但只能被另一个脚本里的 workflow("<name>") 调用,见“子 workflow”一节。

整体数据流:

图表加载中…

脚本的形状

  • 开头必须是 export const meta = { ... },name 与 description 必填,另有可选的 whenToUse、phases、model、permissions(packages/opencode/src/workflow/meta.ts:20)。其中 model 在代码里找不到读取它的地方。
  • meta 不执行:parseMeta 用一个递归下降读取器把字面量当数据解析,只认对象、数组、字符串、数字、布尔与 null,函数调用、运算符、模板串一律报错(meta.ts:41)。文件头注释解释了为什么不用 new Function:那会在宿主进程里、沙箱之外执行 meta,一个藏着 child_process 调用的 meta 就能跑任意命令。
  • 解析完把 meta 语句替换成等长的空白,正文行号不变,报错堆栈仍然对得上(meta.ts:81)。
  • 正文被包进 (async () => { ... })() 执行(packages/opencode/src/workflow/sandbox.ts:205),所以顶层可以 await,也可以 return,返回值就是这次运行的结果。

原语

宿主注入的函数在 launch 里组装(packages/opencode/src/workflow/runtime.ts:1373):

const hooks: Record<string, HostFn> = {
  agent,
  phase,
  log: logHook,
  workflow: workflowHook,
  readFile: fileHooks.readFile,
  writeFile: fileHooks.writeFile,
  glob: fileHooks.glob,
  exists: fileHooks.exists,
}

parallel 和 pipeline 不是宿主函数,而是沙箱启动时注入的一段纯 guest 代码(sandbox.ts:70):

globalThis.parallel = (thunks) =>
  Promise.all(thunks.map((t) => Promise.resolve().then(t)));
globalThis.pipeline = (items, ...stages) =>
  Promise.all(items.map((item, index) =>
    stages.reduce((acc, stage) => acc.then((prev) => stage(prev, item, index)), Promise.resolve(item))));
原语行为
agent(prompt, opts?)派一个子智能体。给了 schema 返回校验过的对象,否则返回最终文本;失败不抛异常,返回空值
parallel(thunks)就是 Promise.all,不限流;任一 thunk 抛错,整批失败
pipeline(items, ...stages)每个 item 依次过所有 stage,不同 item 之间互不等待
workflow(nameOrScript, args?, opts?)把另一个 workflow 当独立子运行执行并等待结果
phase(title)、log(message)记录进度:写 transcript、写日志文件、发事件
readFile、writeFile、glob、exists文件读写,限定在 workspace 根目录内
args启动参数,按值注入的全局变量

几点要对照着看:

  • 官方博客只提到 agent()、parallel()、pipeline(),代码里还有上表其余几个。
  • 工具说明写着 parallel 里抛错的 thunk“resolves to null”(packages/opencode/src/tool/workflow.txt:11),预置代码并不捕获;源码注释说明这是有意的,脚本逻辑错误应当大声失败(sandbox.ts:63)。以代码为准。
  • 宿主返回的 null 进沙箱会变成 undefined(sandbox.ts:334),所以脚本里判断 agent 失败要写 !r 或 r == null,写 r === null 会漏判。
  • 沙箱删掉了 Date、WeakRef、FinalizationRegistry,Math.random 换成以 runID 的 SHA-1 为种子的伪随机数,保证续跑时重放出同样的序列(sandbox.ts:170、runtime.ts:1400)。需要日期就由调用方经 args 传入。
  • 文件原语用 resolveInWorkspace 拒绝 .. 与越界的绝对路径,glob 的结果排序后再滤掉根目录之外的匹配。检查按路径字面进行、不解析符号链接,注释自己说明它是作用域限制而非安全边界(packages/opencode/src/workflow/workspace.ts:11)。

agent():派子智能体

agent 的选项有 agentType(默认 general)、tools、model、schema、isolation、label、phase、timeoutMs、retry(runtime.ts:224)。model 可以是 provider/model,也可以是 lite 这样的模型组名,解析失败回落到运行默认值并只告警一次(runtime.ts:333)。共享工作区的派发调用的是 Actor 服务(runtime.ts:816):

const spawned = yield* actor.spawn({
  mode: "subagent",
  sessionID: input.sessionID,
  agentType: o.agentType ?? "general",
  description: spawnDescription(o),
  task: prompt,
  context: "none",
  tools: o.tools ? [...o.tools] : "INHERIT",
  background: true,
  parentActorID: input.parentActorID,
  model: resolvedModel,
  // Register the child in the reclaim set the instant the actor
  // exists — synchronously inside the spawn Effect, BEFORE its work
  // fiber detaches. A cancel racing this spawn would otherwise miss
  // it (the child runs detached in the actor scope, so interrupting
  // the workflow fiber can't stop it) and leak an orphan. MR104 #2.
  onActorID: (id) => {
    entry.childActorIDs.add(id)
    onActorID?.(id)
  },
  ...(o.schema ? { format: { type: "json_schema" as const, schema: o.schema, retryCount: 2 } } : {}),
})

子智能体在 workflow 所在的会话里以后台 subagent 身份运行,context: "none" 表示看不到父对话,工具默认继承。给了 schema 就走结构化输出,校验失败最多再修两次;返回值在要求 schema 时只取结构化结果,否则优先结构化结果、再取最终文本(runtime.ts:854)。Actor 的生成与回收见 Actor。

失败被归成五类:over-cap、spawn-reject、timeout、actor-error、no-deliverable(runtime.ts:680)。脚本只看到空值,原因通过 workflow.agent_failed 事件发出。

isolation: "worktree" 另走一条路:先建一个新 worktree,在 Instance.provide 绑定的该目录里派发,子智能体的读写与 Shell 都落在 worktree 里。结束后只有“成功且有改动”才保留 worktree,并在返回值上附加 _worktree(分支、目录、changed),其余情况一律删除(runtime.ts:1029)。隔离调用不进日志,续跑时总会重新执行。

权限:后台子智能体没法回答授权询问。询问路由对普通后台子智能体采取“继承”:父会话已经允许的就放行,否则立即以 DeniedError 失败、绝不挂起(packages/opencode/src/agent/config.ts:57)。meta 的 permissions 清单就是为这个准备的:launch 在启动时把每项权限问一次,用户选“总是允许”后规则进入会话,子智能体随后继承。只有顶层、前台发起的运行才会真的弹窗,嵌套运行和后台发起的运行一律非交互(runtime.ts:737)。

可见性:session/visibility.ts 决定哪些会话禁止渲染,拦的是“为运行时派生的智能体而建”的宿主会话。workflow 的子智能体不在其列,因为它们根本不建子会话:该文件的注释专门澄清,agent() 只在 workflow 自己的会话里登记 actor,不是第四类子会话(packages/opencode/src/session/visibility.ts:31);真正被拦的只有检查点写手、dream、distill 三类智能体的宿主会话(packages/opencode/src/agent/config.ts:7)。所以 TUI 可以从结构树直接打开每个子智能体的对话。

上限与期限

项默认出处
全局并发min(16, 2 × CPU 核数),配置 maxConcurrentAgents 覆盖runtime.ts:387
单次运行并发等于全局上限,只能往小调runtime.ts:615
单次运行累计派发1000(maxLifecycleAgents),超出后 agent() 直接返回空值runtime.ts:620
嵌套深度8(maxDepth)runtime.ts:611
整个脚本12 小时(scriptDeadlineMs)runtime.ts:30
单个 agent 超时默认不限,timeoutMs 开启runtime.ts:649
重试默认 1 次;retry.attempts 是总次数,退避 baseMs 400、maxMs 4000,全抖动runtime.ts:1102

全局信号量由所有运行(含子运行)共享,首次启动时按配置建好,之后改配置不会重建(runtime.ts:394)。只有 spawn-reject、timeout、actor-error 会重试,额度耗尽和“跑完了却没产出”不重试(runtime.ts:684)。超时的子智能体会被优雅取消,结果按空值处理。

子 workflow

workflow() 的第一个参数含有 export const meta = 就当内联脚本,否则当名字(packages/opencode/src/workflow/resolve.ts:7)。名字只允许字母、数字和 ._-,从 workspace 根向上走到 worktree 根,每层依次找 .mimocode/workflows/<name>.js、.claude/workflows/<name>.js,就近优先(resolve.ts:20)。

这里和 README 有出入。README 说可以“用同名文件覆盖内置 Workflow(如 .mimocode/workflows/compose.js)”,但 workflow 工具的 run 按 name 只查内置注册表(tool/workflow.ts:206),而脚本里的 workflow() 只查上述文件、不查内置注册表。两条查找路径互不相交:工具以 name: "compose" 调用的永远是内置版本,放在 .mimocode/workflows/ 下的文件只对脚本内的 workflow("compose") 生效。想从顶层跑自定义脚本,要么把文件内容作为 script 传入,要么传一段内联脚本,在里面调用 workflow("<name>")。

子运行的 runID 由父 runID 与调用键哈希而来(runtime.ts:1259),续跑时找得回来;它有自己的派发额度与超时默认值,workspace 只能缩小不能扩大(runtime.ts:1314)。成环、超深、名字找不到属于接线错误,带 WorkflowStructuralError 标记一路传到根运行、让整棵树失败(runtime.ts:1281、runtime.ts:1339);子运行自身失败则和 agent() 一样返回空值,另发 workflow.child_failed 事件。

持久化与续跑

运行状态存三处:

  • SQLite 表 workflow_run:会话 ID(级联删除)、名字、状态、运行中与成败计数、当前阶段、父 actor、参数、脚本 SHA-256、单 agent 超时、错误(packages/opencode/src/workflow/workflow.sql.ts:6)。计数写库做了防抖,每次运行每 250 毫秒最多一次(runtime.ts:418)。
  • 数据目录下 workflow/<runID>.js:启动时保存的脚本全文(packages/opencode/src/workflow/persistence.ts:100)。
  • 同目录的 <runID>.jsonl:结果日志。

每次成功的非隔离 agent() 调用,结果都会同步追加进日志(runtime.ts:1140)。键是 prompt、agentType、model、schema、phase 的 SHA-256 再加出现序号(persistence.ts:25),label、tools 不参与,改它们不会让缓存失效。命中时直接返回(runtime.ts:1074):

if (o.isolation !== "worktree") {
  const base = journalKeyBase(promptStr, {
    agentType: o.agentType,
    model: o.model,
    schema: o.schema,
    phase: o.phase,
  })
  const n = occ.get(base) ?? 0
  occ.set(base, n + 1)
  const key = base + ":" + n
  if (journal.results.has(key)) {
    // Cache hit: no spawn, no agentCount increment (would hit the 1000
    // cap on replays alone). Outcome counter DOES climb so the live view
    // reflects reality as replay proceeds.
    entry.succeeded++
    markAgentNode("succeeded", journal.results.get(key))
    scheduleFlush(entry)
    return Promise.resolve(journal.results.get(key))
  }

resume 用原 runID 重新执行整份脚本:同一 runID 的续跑用进程内锁串行化,仍在本进程里运行的拒绝续跑(runtime.ts:1537、runtime.ts:1545);脚本的 SHA-256 与库里记录的不同,说明脚本改过,就清空日志从头跑(runtime.ts:1562)。于是已经成功的调用秒回,失败的调用重跑,脚本越“收敛”,第二次做的事越少。模型、并发、期限不入库,续跑时用默认值(runtime.ts:1514);单 agent 超时入库,续跑时沿用(runtime.ts:1577)。

取消时先中断脚本 fiber,最多等 5 秒(runtime.ts:499)。取消和失败都会回收:优雅取消在途子智能体(每个最多等 5 秒)、删掉仍归本运行所有的 worktree(每个最多 10 秒),并递归取消仍在跑的子运行(runtime.ts:445)。成功路径不回收,保留下来的 worktree 就是交付物。

事件、结果与界面

Bus 事件共六个:workflow.started、workflow.phase、workflow.log、workflow.finished、workflow.agent_failed、workflow.child_failed(packages/opencode/src/workflow/events.ts:5)。

run 默认同步:工具一直阻塞到运行结束,其间每 250 毫秒把 transcript 与计数推进工具部件的 metadata,TUI 据此刷新面板(tool/workflow.ts:287)。transcript 只保留头 40 行、尾 160 行(tool/workflow.ts:109),最终输出里的结果 JSON 截到 4000 字符(tool/workflow.ts:331)。async: true 则立刻返回 runID,结束时给父智能体的收件箱投一条通知,内容同样截到 4000 字符(runtime.ts:1418,收件箱见收件箱、团队与进度检查)。

脚本调用时还会记一棵只用于观察的结构树:阶段、agent、子 workflow 三种节点,agent 节点带 prompt、模型、工具、是否隔离、耗时和最多 600 字符的结果摘要(runtime.ts:66、runtime.ts:110)。它不参与日志键和续跑。

服务端在 /workflows 下挂了四个路由(packages/opencode/src/server/routes/instance/workflows.ts:19):

路由作用
GET /workflows?sessionID=列出会话的运行,sessionID 必填
POST /workflows/:runID/resume续跑,只接受顶层运行的 ID
GET /workflows/:runID/transcript完整 transcript,不截断
GET /workflows/:runID/structure结构树

取消没有 HTTP 路由,只能通过工具的 cancel。TUI 的 /workflows 对话框每秒轮询列表(packages/opencode/src/cli/cmd/tui/component/dialog-workflows.tsx:20),全屏页面用 WorkflowTree 画卡片:agent 卡片显示标签、模型、工具数、schema、隔离与耗时,点“open ↗”打开该子智能体的对话;子 workflow 卡片点进去是子运行的页面(packages/opencode/src/cli/cmd/tui/component/workflow-tree.tsx:34)。状态为运行中、失败或已取消时页面上有“↻ resume”,确认框提醒会重新执行并可能产生费用(packages/opencode/src/cli/cmd/tui/routes/session/index.tsx:2929)。

下一篇:四个内置 Workflow——逐个拆读 compose、deep-research、fact-check、research-experiment 的阶段、并行点与失败处理。

本页目录