dshkit

DeepSeek Harness 的 workflow 引擎:契约、错误码与 Ralph 配置

ctx.workflowEngine 执行模型编写的编排脚本、扇出子 agent。start 请求、run 与 result 契约、只读事件、九个 WorkflowError 错误码,以及 ralph 工具的全部可调项。

更新于 2026-08-143 min
一句话结论

ctx.workflowEngine.start(request) 返回的 WorkflowRun,它的 result 永远不 reject——执行失败以 stopReason 'error' resolve,取消以 'cancelled' resolve。当前引擎在 worker 线程里跑脚本,它隔离宿主事件循环,但明确不是安全边界。Ralph 只是建在同一个 seam 上的普通插件,maxRounds 默认 256。

workflow 工具到处都被描述成「编排多 agent 工作流」。这话没错,也没用。这一页讲的是真实契约—— start() 校验什么、什么会/不会 reject、哪些失败是致命的。

这个家族

角色
dsh-workflow定义执行与生命周期事件ctx.workflowEngine
dsh-workflow-worker-thread在 worker 线程里跑脚本注册到 ctx.workflowEngine
dsh-tool-workflow把通用 workflow 执行暴露给模型注册到 ctx.tools
dsh-tool-ralph暴露固定的 fresh-agent Ralph 工作流注册到 ctx.tools

seam 定义脚本、run、result、error 和事件契约;由引擎决定怎么隔离和执行脚本。worker 线程引擎是当前 这个,未来的进程或沙箱引擎可以替换它而不用改工具。

start 请求

WorkflowStartRequest = { meta, script, args?, subagentProvider?, maxTotalAgents?, parent, signal? }

start() 同步校验——meta 块畸形、脚本解析不了、provider 路由不可用、或者单次运行的限制不被支持, 都会在 run 存在之前 被拒绝。这是刻意的切分:配置错误在调用处失败,而不是编排跑到一半才炸。

三个字段值得注意:

parent 把每个子 agent 归属到调用方 agent,保留 cwd 和血缘。

subagentProvider 可选地为该次运行的所有子 agent 指定路由——但不把 provider 选择权暴露给脚本。 Ralph 工具就是靠这个锁死自己的 provider,而模型编写的普通 workflow 工具因此不会多出一个 provider 选择器。

maxTotalAgents 可选地为单次运行调低引擎的部署天花板,同样对脚本不可见。

metaargs纯数据,不是脚本片段

run 与 result

WorkflowRun    = { id, meta, result, cancel(reason?), dispose() }
WorkflowResult = { value, stopReason, error?, agentsStarted }

需要刻进脑子里的性质:start() 返回之后,result 永远不 reject。 执行失败以 stopReason: 'error' resolve;取消在引擎的有界宽限内以 cancelled resolve。你靠读 stopReason 处理结果,而不是靠 catch。

value 是纯 JSON 数据或 null

run 是持有者所有的:引擎插件卸载会阻止新的 start,但不会撤销已接受的 run,而持有者必须在每条路径上 调用 dispose()。dispose 会取消剩余工作,并在文档规定的界限内达到或放弃静默。

事件是只读的

workflow/startworkflow/end 配对整次运行。workflow/phaseworkflow/log 暴露脚本的叙述。 workflow/agent-startworkflow/agent-endseq 配对每次子调用——而一个 provider 异步 start 就 reject 掉的子 agent,两个事件都不会发

值得偷师的设计细节:事件携带的是 WorkflowRunInfoidmeta)而不是活的 run, 所以监听者拿不到取消或销毁的权限。观察不等于控制。

同进程的事件负载是借用的不可变值,每个监听者独立隔离——同步抛异常或返回被 reject 的 promise 只会被记 日志,不会饿死同伴,也不改变执行。

九个错误码

WorkflowError 带一个 code 和一个 fatal 标记。fatal 的错误一定穿透 parallel()pipeline(), 不会退化成某一项普通的 null

错误码含义
SCRIPT_PARSE脚本解析失败
META_INVALIDmeta 块无效
INVALID_ARGUMENT某次 hook 调用违反引擎契约
UNSUPPORTED_OPTION引擎不支持的选项
UNSUPPORTED_SCHEMA引擎不支持的 schema
AGENT_CAP超出配置的 agent 数量上限
ITEM_CAP超出配置的条目上限
AGENT_STARTprovider 的异步 start 被 reject
AGENT_RESULT已发布子 agent 的 result 因基础设施故障 reject
RESULT_UNSERIALIZABLE脚本或 worker 的返回值不是纯 JSON 数据
CANCELLED取消接管了这次运行,待执行和后续的 hook 全部 reject

还有一个决定你怎么写脚本的区分:

这也是为什么扇出之后的惯用写法是 .filter(Boolean)——null 的意思是「那个子 agent 没跑完」, 不是「编排坏了」。

Ralph:一个普通插件

tool-ralph 很值得当设计范例读:它把一套专门的编排策略实现成了建在 ctx.workflowEnginectx.subagents 上的普通插件agent-loop 里没有加什么 Ralph 模式,同会话的 goal 域也保持独立。

ralph({ objective, maxRounds? }) 会等整次运行结束。

配置默认含义
subagentProviderspawn每一轮使用的全新结构化输出 provider
maxRounds256单次 Ralph 运行的默认值部署上限
maxHandoffChars16384单轮报告序列化后的最大字符数
maxResultChars16384成功时返回给父级的结果最大字符数

provider 必须存在、支持结构化输出、并且报告 inheritsParentContext: false——「子 agent 是全新的」 是硬性要求,不是偏好。

每个子 agent 只收到:不可变的目标、当前轮次与上限、一条**「共享工作区即权威」**的指令,以及上一轮的 结构化 handoff。工作区是长期记忆;父对话和之前的子会话都不会被塞进去。

报告带 status: continue | complete | blocked、非空摘要、证据、下一步和阻塞说明。无效、缺失或超长的 报告会让整个 workflow 失败,而不是被截断、也不会被误当成「轮次耗尽」——这是刻意不让一份畸形的 handoff 看起来像一次跑完的循环。

终态结果是 completeblockedbudget-limited。Ralph 不重试失败的那一轮;错误会点名是哪一轮, 并保留最后一次成功的 handoff。

一个需要绕开的限制

只支持前台收集。 调用方持有一个活的 run 并 await 它;后台启动/轮询、spill 句柄、分离式收集都还没做。 如果你本来打算「发起一个长 workflow、之后再回来轮询」——目前不行。

常见问题

workflow 的 worker 线程算安全边界吗?

不算。仓库里明确写着:worker 线程把 workflow 执行与宿主事件循环隔离开,但不是安全边界。别把它当成跑不可信脚本的沙箱。

WorkflowRun.result 会 reject 吗?

不会。start() 返回之后,result 永远不 reject。执行失败以 stopReason 'error' resolve,取消在引擎的有界宽限内以 'cancelled' resolve。校验失败发生在 start() 内部、同步完成,那时 run 还不存在。

子 agent 失败和 fatal 错误有什么区别?

子 agent 正常 resolve 但 stop reason 不是完成,这不是异常——agent() 返回 null,让脚本自己处理。而 fatal 的 WorkflowError 一定会穿透 parallel() 和 pipeline(),不会退化成某一项的 null。

一个 Ralph 循环能跑多少轮?

maxRounds 默认 256。部署配置里的值既是默认值、也是单次调用覆盖的上限;而且引擎会在发布 run 之前拒绝高于自身部署天花板的上限。

接着看