动态工作流(二):引擎、沙箱与日志回放

引擎怎样在不做 I/O、不读时钟的前提下调度多个 actor:序号、日志回放与 hold 规则、repair 与 nudge、用量记账;沙箱怎样用子进程加 vm 上下文隔离脚本、禁用 Date.now 与 Math.random;NDJSON 线协议,以及各类故障落到哪种终态。

作者 David更新于 32 篇(共 47 篇)

上一篇的终点是一段降级后的 JavaScript:唯一的自由标识符是 __host,门面调用都改写成了 __host.*,有站点的还带上了站点 ID。这一篇讲它怎样被执行,以及中断之后怎样从原处接着跑。

执行分三层。同一个纯函数包里的引擎核心(apps/zcode-cli/packages/dynamic-workflow/src/engine)负责调度和日志决策;兄弟包 @zcode/dynamic-workflow-runtimeapps/zcode-cli/packages/dynamic-workflow-runtime)把脚本放进子进程里的 vm 上下文,用 NDJSON 把 __host.* 调用桥回引擎;bootstrap 里的生产 driver 把每个 actor 落成一个真实的子会话,把日志写进 SQLite。前两层在本篇,第三层在下一篇

三层与三条边界

引擎的类型文件开头列出了它的契约面(apps/zcode-cli/packages/dynamic-workflow/src/engine/types.ts:1):Boundary A 是沙箱脚本调用的宿主 API WorkflowHostApitypes.ts:360);Boundary B 是引擎向下驱动的 WorkflowDrivertypes.ts:416),外加 driver 向上回报进度的 WorkflowReportSinktypes.ts:505);Boundary C 是一组运行事件 RunEventtypes.ts:603);另有一个日志端口 JournalStorePorttypes.ts:970)。WorkflowEngine 同时实现 A 与回报面(apps/zcode-cli/packages/dynamic-workflow/src/engine/engine.ts:136)。

位置负责边界
引擎核心dynamic-workflow/src/engine序号、日志查询与写入决策、actor 队列、并发上限、submit 裁决、用量累计、终态不做 I/O、不读时钟、不用随机数
沙箱 harnessdynamic-workflow-runtime/src写入口文件、起子进程、NDJSON 桥接、超时与取消、把宿主故障归一成引擎终态只依赖纯包与 node 内建
生产 driverbootstrap/src/app/workflow-driver.tsactor 子会话、模型调用、世界读取、产物落盘、SQLite 日志下一篇
试验台 driverbootstrap/src/app/dynamic-workflow-snippet-service.tsEvalWorkflowSnippet:内存日志,没有 ask同一套沙箱与世界读取

纯度是确定性回放的前提。引擎文件头写得很直白(engine.ts:4):

核心不做任何 I/O、不读时钟、不用随机数:随时间/调度变化的决策要么被 journal 记录、要么被"每站点序号 + 每 actor FIFO 的 actorSeq"这套确定性规则固定,从而首次执行与 replay 逐字一致。

runtime 包的 README 把依赖边界写成“整条 sandbox↔engine 管线 app-free 可跑”的证明:它绝不 import core、contracts、bootstrap、adapters(apps/zcode-cli/packages/dynamic-workflow-runtime/README.md:6)。

身份:站点乘序号

一次 ask 的身份是“站点 ID × 该站点第几次执行”,第 n 次执行 ask#3 就是实例 ask#3@ntypes.ts:24);actor 同形,是“创建站点 × 序号”。序号只有一个铸造点 nextOrdinal,createActor、ask、世界读取、report、产物全都经它,它也顺手记下实例出生时所在的阶段(engine.ts:614)。阶段名另用一张计数表,一个恰好叫 report#1 的阶段不能挪动那个站点的序号(engine.ts:147)。

同一 actor 上的 ask 还有一个准入序号 actorSeq,按记录类型的定义,ask 行在“run、actor 站点、actor 序号、actorSeq”四列上唯一(types.ts:881);SQLite 表上没有这条约束,由引擎自己守(apps/zcode-cli/packages/adapters/src/storage/session-store/migrations.ts:818)。每次宿主调用的输入另算一个防御性哈希:对规范化 JSON 做 FNV-1a 32 位,不用 node:crypto,因为它只做回放时的一致性校验,确定性和可移植才是重点(apps/zcode-cli/packages/dynamic-workflow/src/engine/hash.ts:1)。ask 哈希的是指令文本(apps/zcode-cli/packages/dynamic-workflow/src/engine/scheduler.ts:107),世界读取哈希 {op, args}apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-world.ts:37),report 哈希条目本身(apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-report.ts:49)。

AskScheduler:一次 ask 的一生

ask 的生命周期全在 AskScheduler 里(scheduler.ts:38)。引擎的 ask 先挡两种接线错误:句柄不认识是 UnknownActor,站点没有规格是 MissingAskSpec,两者都让整个 run 失败;后者不肯兜底成无类型 ask,因为那会让 typed ask 悄悄失去 schema 校验(engine.ts:417)。之后交给调度器:

图表加载中…

日志命中的那一段(scheduler.ts:110):

    const recorded = this.journal.getNode(this.host.runId, siteId, ordinal);
    if (recorded !== undefined) {
      // 命中即防御性校验 inputHash——不一致说明纯度契约被破坏,run 大声失败。
      if (recorded.inputHash !== hash) {
        const err = hashMismatch(instance, recorded.inputHash, hash);
        this.host.failRun(err);
        return Promise.reject(err);
      }
      const seq = recorded.actorSeq ?? 0;
      if (recorded.status === "running") {
        // 崩溃于执行中:按记录的 actorSeq 位置重新 live 派发(hold 规则保证其准入次序)。
        actor.pendingRecorded.set(seq, () => {
          actor.imported?.reconcileRecorded(seq, recorded.inputHash, this.host.wasLiveBeforeResume(instance));
          this.admitLive(instance, actor, seq, instructions, hash, spec, deferred);
        });
      } else {
        // completed / failed:短路结算,无 driver 调用。
        actor.pendingRecorded.set(seq, () => {
          actor.imported?.reconcileRecorded(seq, recorded.inputHash, this.host.wasLiveBeforeResume(instance));
          this.releaseCachedAsk(instance, recorded, deferred);
        });
      }
      this.drainAdmission(actor);
      return deferred.promise;
    }

几条要点:

  • hold 规则。记录行不是一到就放行,而是按 actorSeq 挂在 pendingRecorded 里,严格按序释放;新来的 ask 要等这个 actor 在日志里的所有记录行都准入完,才能排进去(scheduler.ts:294)。记录行的条数在 actor 注册时从日志里数出来(scheduler.ts:75)。从代码看,这是为了让新 ask 分到的 actorSeq 排在所有记录之后:一个 actor 是一段累积上下文的对话,回放时它收到任务的顺序必须和第一次一样。
  • 失败也要回放。已记录的失败按原样再拒绝一次,因为脚本可能 try/catch 过它并据此分支(scheduler.ts:320);同理,结算为失败时必须落日志、覆盖准入时的 running(scheduler.ts:418)。一次 ask 落两次库:准入写 running,结算写 completed 或 failed;report 是唯一的例外,一次写成 completed(types.ts:870)。
  • 派发。每个 actor 同时只跑一个 ask(actor.current),全 run 在飞的 ask 数不超过 caps.maxConcurrencyscheduler.ts:330)。会话在第一次派发时才建,每个 actor 一次(scheduler.ts:374)。按模型请求计的进程级闸门不在这里,在 driver 之下(scheduler.ts:361),见本篇最后一节。

笔者把两个包复制出来编译,用内存日志和一个假 driver 跑了下面这段脚本两次,第一次在 ask#3 刚派发时取消,第二次用同一个 runId、同一份日志再跑:

interface Verdict { ok: boolean; reason: string }
const a = agent("甲");
const b = agent("乙");
const [x, y] = await Promise.all([a.ask<Verdict>("审一"), b.ask<Verdict>("审二")]);
const z = await a.ask("总结 " + x.reason + y.reason);
return { x, y, z };

假 driver 让“乙”第一次交一个坏结果。两次的事件序列(整理过格式):

第一次
run-started
node-queued ask#1@1 / node-queued ask#2@1
node-dispatched ask#1@1 / node-dispatched ask#2@1
node-settled ask#1@1 ok
  reject [{"expected":"present","got":"missing","path":"$.reason"},{"expected":"boolean","got":"string \"yes\"","path":"$.ok"}]
node-repairing ask#2@1
node-settled ask#2@1 ok
node-queued ask#3@1 / node-dispatched ask#3@1
node-settled ask#3@1 cancelled
run-settled stopped user

第二次
run-started
node-settled ask#1@1 cached ok
node-settled ask#2@1 cached ok
node-queued ask#3@1 / node-dispatched ask#3@1
node-settled ask#3@1 ok
run-settled completed

两个 actor 的 ask 并行派发;ask#3 要等脚本里的 Promise.all 兑现才发出。第二次运行里,已完结的两步直接按记录结算,没有 queued、dispatched 事件,也不经过 driver;被取消的 ask#3 在日志里仍是 running(中止在飞 ask 时只发事件、不改日志行),于是按原位置重新派发。

repair 与 nudge

typed ask 的结果经 submit_result 工具交回。driver 上报 askSubmitAttempted,调度器用注入的校验器检查:通过就 accept 并结算;不通过且还有预算就回 reject 与违规列表,发一条 node-repairing;预算用完则取消这次 ask,以 ValidationFailed 结算(apps/zcode-cli/packages/dynamic-workflow/src/engine/scheduler-submit.ts:23)。一轮对话结束而没有提交时,无类型 ask 直接拿最终文本结算;typed ask 先 nudge 一次,再没有提交就以 ResultNotSubmitted 结算,错误里带着最终文本(scheduler-submit.ts:94)。两个预算是常量:repair 3 次,nudge 1 次(types.ts:1039types.ts:1042)。

校验前还有一步宽松归一:原值不过、而原值是字符串时,先 JSON.parse 一次再校验。注释记着来由:GLM-5.3 经 Anthropic 兼容端点时,常把 result 参数序列化成 JSON 字符串,校验器如实报“expected object, got string”,repair 三次后 run 失败(scheduler-submit.ts:57)。解析后仍不过时,报的是解析后对象上的违规,模型才有得改(scheduler-submit.ts:82)。reject 在 driver 那边怎样变成同一轮里的错误结果、nudge 怎样起一个新轮次,在下一篇。

用量记账

driver 每完成一次 ask 上报 AskStats:token 数、工具调用数、轮数,以及其中碰过外部世界的调用数(apps/zcode-cli/packages/dynamic-workflow/src/engine/ask-observation-types.ts:10)。引擎把 token 累加进 spentTokens,先写库再发 usage-updated,事件载荷与列值因此永远相等;run 结算之后迟到的统计只记账、不再发事件,因为 run-settled 必须是事件流的最后一条(engine.ts:542)。用量只是观察面,只记账、只广播,永远不会让 run 失败(engine.ts:200)。修订出来的 run 从前驱的累计值起账,脏值在写库前归一(engine.ts:270)。

日志:四类记录与回放

日志端口是同步的仓储式接口,正好贴合 node:sqliteDatabaseSync,也让核心保持确定性(types.ts:9)。四类记录对应四张表:RunRecordtypes.ts:799)、ActorRecordtypes.ts:846)、NodeRecordtypes.ts:886)、按单调序号追加的 StoredEventtypes.ts:927)。节点种类有 ask、world-read、world-run、report、artifact 五种(types.ts:560);world.run 在站点层面是一次世界读取,落库时单列 world-run,理由是“效应不该伪装成读”(types.ts:554engine-world.ts:67)。世界读取的结果进日志,所以回放对 run 与 resume 之间的磁盘变化免疫(engine-world.ts:49);落库的实参序列化后超过 4096 字节就逐项截成预览(types.ts:575)。

引擎每记一条事件,都是先写日志、再把同一个对象交给 driver 扇出(engine.ts:640)。logphase-entered 只有事件、没有节点行,没法去重,resume 重跑会把它们再发一遍,消费者按单调归约免疫(types.ts:707)。

resume 就是用既有的 runId 再构造一次引擎:日志里已有这一行,构造函数走恢复分支,report 计数、产物版本、已花 token 都从日志行恢复(engine.ts:315)。前提是脚本逐字节相同:两侧都有哈希且不同,就在构造时同步抛出 ScriptHashMismatch,拒绝的是这次 resume,而不是把一个还能用正确脚本恢复的 run 标成失败(engine.ts:298)。内存版日志 InMemoryJournalStore 进出都做 structuredClone,模拟存储边界,试验台就用它(apps/zcode-cli/packages/dynamic-workflow/src/engine/journal-memory.ts:1)。SQLite 版在下一篇

沙箱:子进程加一个 vm 上下文

harness 的入口是 runWorkflowScriptapps/zcode-cli/packages/dynamic-workflow-runtime/src/harness.ts:172):先建一个转发用的回报面、用它造 driver,再以这个 driver 构造引擎(harness.ts:176);然后渲染一份自包含的 ESM 入口文件,写到 <cwd>/.zcode/workflow-runs/<runId>.mjs。payload 不走命令行,因为 Windows 的命令行上限是 32,767 字符,整份脚本放上去一过约 18 KB 就 spawn ENAMETOOLONGharness.ts:227)。入口文件留作每次执行体的存档,目录里写一份内容为 *.gitignore;项目目录写不进去就退到系统临时目录并报一条警告(apps/zcode-cli/packages/dynamic-workflow-runtime/src/child-entry-file.ts:8)。

子进程默认以 process.execPath --max-old-space-size=256 <entry> 启动(harness.ts:162harness.ts:238),环境里显式带上 ELECTRON_RUN_AS_NODE=1:桌面端的 Agent 跑在 Electron Helper 里,不带它子进程会按完整的 Chromium 应用启动,卡在 GPU 初始化(harness.ts:246)。CLI 打成 SEA 单文件时不认 Node 的命令行旗标,于是改为自己 re-exec 自己,带上隐藏子命令 __zcode-dwf-childapps/zcode-cli/packages/contracts/src/plugins/index.ts:20apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-launch.ts:352)。run.ts 在严格的参数解析之前就把它分派出去(apps/zcode-cli/packages/cli/src/run.ts:300),子命令 import() 入口文件并调用它导出的 start,注入 CLI 进程的 vm、readline 与 stdio(apps/zcode-cli/packages/cli/src/dwf-child-command.ts:44);堆上限在这条路上只能由入口文件 best-effort 地 v8.setFlagsFromStringapps/zcode-cli/packages/dynamic-workflow-runtime/src/child-source.ts:353)。

子进程里,脚本跑在 vm.createContext 建的独立 realm 里,只有 ES 内建对象加一个注入的 __host,天然没有 processrequireBufferfetchchild-source.ts:15)。两个 realm 之间只有两样东西跨界:一个外层的 __send(string) 函数和入站的行字符串,所以脚本看到的 Array.isArrayinstanceof、原型链全是 context 自己的(child-source.ts:19);运行期的 import() 会因为没提供回调而抛错(child-source.ts:24)。args 在 context 里用 JSON.parse 构造再浅冻结,注释承认这不是安全边界,只防手滑重新赋值(child-source.ts:156)。最后是三条运行期禁令(child-source.ts:227):

// —— 运行期禁令(belt;编译诊断是 suspenders):Date.now / argless new Date() / Math.random ——
var __NativeDate = Date;
class __WorkflowDate extends __NativeDate {
  constructor() {
    if (arguments.length === 0) throw new Error("argless new Date() is disabled in workflows");
    super(...arguments);
  }
  static now() {
    throw new Error("Date.now() is disabled in workflows");
  }
  static parse(value) {
    return __NativeDate.parse(value);
  }
  static UTC() {
    return __NativeDate.UTC.apply(__NativeDate, arguments);
  }
}
globalThis.Date = __WorkflowDate;
Math.random = function () {
  throw new Error("Math.random() is disabled in workflows");
};

理由是回放。日志只记宿主调用,脚本自己的逻辑在 resume 时会从头再算一遍;如果逻辑依赖当前时间或随机数,第二遍就可能拼出不同的指令,撞上 InputHashMismatch,那条错误信息说的正是“the script is not deterministic, so the journal cannot be replayed”(scheduler.ts:459)。new Date(毫秒数)Date.parseDate.UTC 这些确定性的用法照常可用。从门面的设计看,脚本真要当前时间,可以用 world.run 去取:它的结果进日志,resume 时读记录而不重新执行(apps/zcode-cli/packages/dynamic-workflow/src/facade/dts.ts:384)。

注释里说的编译期那一层(“suspenders”)目前并不存在:分析器里没有检查 Date.nowMath.random 的诊断,笔者试过,含这两个调用的脚本零诊断通过编译,只会在运行时抛错。

NDJSON 线协议

线协议的唯一真源是 protocol.ts,子进程那一侧是手写的镜像,因为它以内嵌字符串的形式运行,没法 import(apps/zcode-cli/packages/dynamic-workflow-runtime/src/protocol.ts:4)。

方向kind内容说明
子到父create-actorlocalIdsiteIdnamepersona即发即忘(protocol.ts:45
子到父requestidtypeaskworld-readpublish-artifact需要应答(protocol.ts:60
子到父eventtypelogreportdeclare-artifactphase-entered不需要应答,但到达顺序承重(protocol.ts:95
子到父completeok,加 valueerror脚本返回或抛错(protocol.ts:150
父到子responseidok,加 valueerror应答一次 requestprotocol.ts:165

create-actor 不走请求应答,是因为 Boundary A 要求 createActor 同步返回句柄:子进程没法为一次同步返回等一个来回,于是自己造一个本地句柄 local#N 发出去;父进程处理 create-actor 是纯同步的,stdio 又是先进先出,所以任何引用这个句柄的 ask 到达之前,映射一定已经建好(protocol.ts:14)。事件通道的顺序同样承重:一条带标签的 report 必须晚于它的看板声明到达,否则引擎以 ArtifactUndeclared 让整个 run 失败(protocol.ts:90)。错误以 WireError 过界,带着 codeviolationsfinalText,脚本里的 try/catch 能按结构处理;模型侧的错误不再过界,要么在运行时里重试,要么让整个 run 停下(protocol.ts:25)。

runtime 的 README 只列了 request 里的 ask、world-read 与 event 里的 log(apps/zcode-cli/packages/dynamic-workflow-runtime/README.md:43),落后于 protocol.ts。笔者把上一篇的编译器和这个包复制出来,喂给子进程一段小脚本,在父进程一侧手工应答,抓到的原始行如下:

child->parent {"kind":"event","type":"phase-entered","name":"规划"}
child->parent {"kind":"create-actor","localId":"local#1","siteId":"actor#1","name":"规划员"}
child->parent {"kind":"request","id":"r1","type":"ask","siteId":"ask#1","actor":"local#1","instructions":"列出步骤"}
parent->child {"kind":"response","id":"r1","ok":true,"value":{"steps":["a","b"]}}
child->parent {"kind":"event","type":"log","message":"steps=2"}
child->parent {"kind":"request","id":"r2","type":"world-read","siteId":"world-read#1","op":"glob","args":["*.ts"]}
parent->child {"kind":"response","id":"r2","ok":true,"value":["x.ts","y.ts"]}
child->parent {"kind":"event","type":"report","siteId":"report#1","item":{"count":2}}
child->parent {"kind":"complete","ok":true,"value":{"plan":{"steps":["a","b"]},"banned":"Error: Date.now() is disabled in workflows"}}

脚本最后在 try 里调了一次 Date.now(),返回值里的 banned 就是那条运行期禁令的错误。把 harness 与引擎连起来,一次正常的 run 是这样的:

图表加载中…

失败裁决

run 的裁决归引擎所有,harness 自己的 finalize 只做清理:清定时器、摘掉取消监听、关掉读取子进程输出的 readline,子进程还活着就 kill(harness.ts:314)。故障按“是谁的错”分成两路(harness.ts:324):

    // 终结失败(脚本抛错 / 子进程崩溃 / 超时 / 协议损坏)都是 run 失败:交给引擎的公有 fail(),
    // 由它 first-wins 结算、driver 侧取消在飞 ask、journal 记 failed + failure_json——run 的裁决
    // 归引擎所有,journal 与调用方看到的结果不分叉。子进程清理由 finalize(经 engine.settled)负责。
    const failRun = (error: WorkflowError): void => {
      if (finalized) return;
      engine.fail(error);
    };
    // 宿主侧故障(沙箱崩溃 / 超时 / 协议损坏):stopped(interrupted),可 resume。
    const interruptRun = (message: string, cause?: unknown): void => {
      if (finalized) return;
      engine.stop(
        "interrupted",
        new WorkflowError("Interrupted", message, cause === undefined ? undefined : { cause }),
      );
    };

第一段注释是旧说法,下面的 interruptRun 才是现状:

情形路径终态能否 resume
脚本 returncomplete okengine.completecompleted不需要
脚本抛错,包括降级后的函数体编译失败complete ok:falseengine.fail(DriverError)harness.ts:474errored否,只能修订
引擎契约违反:InputHashMismatchUnknownActorMissingAskSpecDuplicateActorNameReportCapExceeded引擎内部 failRunerrored
子进程起不来、崩溃或提前退出、墙钟超时、吐出非法 NDJSON 行interruptRunharness.ts:254harness.ts:379harness.ts:395harness.ts:411stopped(interrupted)
取消信号onAbort"model""interrupted"{ superseded } 各归其类,其余算 userharness.ts:340stopped(...)superseded 以外都能
确定性的模型侧错误driver 的 stopRunengine.stop("provider")engine.ts:565stopped(provider)能,先解决原因

几个细节:

  • 所有结算路径的第一行都是“已结算就忽略”,先到者胜(apps/zcode-cli/packages/dynamic-workflow/src/engine/engine-settlement.ts:7)。脚本返回时仍可能有在飞的 ask,比如 Promise.race 的输家或没被 await 的 ask,它们和取消一样被中止并补发 node-settled(cancelled),否则会一直烧 token(engine-settlement.ts:15)。
  • 崩溃时以子进程 stderr 归因,stderr 保留末尾 64KB(harness.ts:163harness.ts:415)。
  • 生产路径调用 runWorkflowScript 时不传 timeoutMs,正式的 run 没有墙钟超时(dynamic-workflow-run-launch.ts:295);试验台传入调用方给的超时,默认 60 秒、最多 600 秒(apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-snippet-service.ts:147apps/zcode-cli/packages/contracts/src/tools/eval-workflow-snippet.ts:14)。
  • 与文档不一致:runtime 的 README 说四类终结失败都调 engine.fail,取消调 engine.cancel(),结算状态是 failedcancelledapps/zcode-cli/packages/dynamic-workflow-runtime/README.md:54:57:26)。代码里宿主侧故障走 stop("interrupted"),引擎上没有 cancel 方法,逻辑状态是 completederroredstopped 三个终态(types.ts:589types.ts:597)。harness 文件头的注释(harness.ts:23)与入口文件模块“回落也失败就归一成 failed”的说法(child-entry-file.ts:14)同样过时,函数自己的文档注释才对(harness.ts:166)。

并发上限

run 级的容量上限只有一个 caps.maxConcurrency,提交时定下并存进日志(types.ts:110)。它限制的是在飞的 ask 数,世界读取不受 actor 队列与并发上限约束(engine-world.ts:92)。生产环境里上限的天花板是 max(1, min(16, availableParallelism() − 2)),给主 Agent 与宿主进程留两个核(apps/zcode-cli/packages/bootstrap/src/app/workflow-concurrency-ceiling.ts:4);请求的值被钳进 [1, 天花板] 并向下取整,过大不报错(apps/zcode-cli/packages/bootstrap/src/app/dynamic-workflow-run-service.ts:135)。

模型请求另有一道进程级闸门:纯包里的 ConcurrencyController 是一台按 provider key 分桶的 AIMD 状态机,不读时钟,now 由调用方传入(apps/zcode-cli/packages/dynamic-workflow/src/engine/concurrency.ts:1)。限流时 cap 乘以 0.75,连续 4 次成功加 1,地板是 1,一个 key 空闲 300000 毫秒后回到天花板(concurrency.ts:25concurrency.ts:33concurrency.ts:35concurrency.ts:37)。bootstrap 的治理器让一个 CLI 进程里的所有 run 和主 Agent 共用这套桶,闸门按每一次模型请求准入,退避期间不占槽(apps/zcode-cli/packages/bootstrap/src/app/workflow-concurrency-governor.ts:4);主 Agent 的请求立即放行但计入在飞数,它的回合永远不会被工作流流量阻塞(workflow-concurrency-governor.ts:54)。所以工具说明告诉模型:并发是运行时的事,只有用户要求时才设 max_concurrency,遇到限流更不要去调它(apps/zcode-cli/packages/core/src/tool/handlers/create-workflow-description.ts:71)。

下一篇:动态工作流(三):从工具调用到落库——模型用哪些工具创建、修订、试跑、保存与恢复工作流,每个 actor 怎样成为一个子会话,日志又写进了 SQLite 的哪几张表。

本页目录