dshkit

The DeepSeek Harness workflow engine: contract, error codes and Ralph config

ctx.workflowEngine runs model-authored orchestration scripts over sub-agents. The start request, the run and result contracts, observe-only events, the nine WorkflowError codes, and every tunable on the ralph tool.

Updated 2026-08-145 min
Short answer

ctx.workflowEngine.start(request) returns a WorkflowRun whose result never rejects — failures resolve with stopReason 'error' and cancellation with 'cancelled'. The current engine runs scripts in worker threads, which isolate the host event loop but are explicitly not a security boundary. Ralph is an ordinary plugin over the same seam, with maxRounds defaulting to 256.

The workflow tool is described everywhere as "orchestrates multi-agent workflows". That is true and useless. This page is the actual contract — what start() validates, what can and cannot reject, and which failures are fatal.

The family

PackageRoleKey
dsh-workflowDefines execution and lifecycle eventsctx.workflowEngine
dsh-workflow-worker-threadRuns scripts in worker threadsregisters on ctx.workflowEngine
dsh-tool-workflowExposes general workflow execution to the modelregisters on ctx.tools
dsh-tool-ralphExposes the fixed fresh-agent Ralph workflowregisters on ctx.tools

The seam defines the script, run, result, error and event contracts; an engine decides how to isolate and execute the script. The worker-thread engine is the current one, and a future process or sandbox engine can replace it without changing the tool.

The start request

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

start() validates synchronously — a malformed meta block, an unparseable script, an unavailable provider route or an unsupported per-run limit is rejected before a run exists. That is a deliberate split: configuration errors fail at the call, not halfway through an orchestration.

Three fields deserve attention:

parent attributes every child agent to the invoking agent, preserving cwd and lineage.

subagentProvider optionally routes every child in that run — without exposing provider choice to the script. This is how the Ralph tool pins its provider while the ordinary model-written workflow tool gains no provider selector.

maxTotalAgents optionally lowers the engine's deployment ceiling for one run, and is likewise invisible to the script.

meta and args are plain data, not script fragments.

The run and result

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

The property to internalise: once start() returns, result never rejects. Execution failures resolve with stopReason: 'error'; cancellation resolves with cancelled within the engine's bounded grace. You handle outcomes by reading stopReason, not by catching.

value is plain JSON data or null.

A run is holder-owned: engine-plugin unload prevents new starts but does not revoke accepted runs, and the holder must call dispose() on every path. Disposal cancels remaining work and reaches or abandons quiescence within a documented bound.

Events are observe-only

workflow/start and workflow/end pair the run. workflow/phase and workflow/log expose script narration. workflow/agent-start and workflow/agent-end pair each child call by seq — and a child whose async provider start rejects emits neither.

The design detail worth stealing: events carry WorkflowRunInfo (id plus meta) rather than the live run, so listeners cannot acquire cancellation or disposal authority. Observation does not grant control.

Same-process payloads are borrowed immutable values, and every listener is independently contained — a synchronous throw or rejected promise is logged without starving peers or changing execution.

The nine error codes

WorkflowError carries a code and a fatal flag. Fatal errors always escape parallel() and pipeline() instead of becoming an ordinary per-item null.

CodeMeaning
SCRIPT_PARSEThe script could not be parsed
META_INVALIDThe meta block is invalid
INVALID_ARGUMENTA hook call violates the engine contract
UNSUPPORTED_OPTIONAn option the engine does not support
UNSUPPORTED_SCHEMAA schema the engine does not support
AGENT_CAPThe configured agent limit was exceeded
ITEM_CAPThe configured item limit was exceeded
AGENT_STARTThe provider's async start rejected
AGENT_RESULTA published child's result rejected with an infrastructure fault
RESULT_UNSERIALIZABLEA script or worker value is not plain JSON data
CANCELLEDCancellation owns the run; pending and future hooks reject

And the distinction that decides how you write scripts:

That is why the idiomatic pattern is .filter(Boolean) after a fan-out — a null means "that child did not complete", not "the orchestration broke".

Ralph, as an ordinary plugin

tool-ralph is worth reading as a design exhibit: it implements a specialised orchestration policy as an ordinary plugin over ctx.workflowEngine and ctx.subagents. No Ralph mode was added to agent-loop, and the same-session goal domain stays independent.

ralph({ objective, maxRounds? }) waits for the whole run.

ConfigDefaultMeaning
subagentProviderspawnFresh structured-output provider used for every round
maxRounds256Default and deployment ceiling for one Ralph run
maxHandoffChars16384Maximum serialized characters in one round report
maxResultChars16384Maximum characters in the successful parent result

The provider must exist, support structured output, and report inheritsParentContext: false — the fresh-child property is a requirement, not a preference.

Each child receives only the immutable objective, its current round and cap, a shared-workspace-as-authority instruction, and the previous structured handoff. The workspace is long-term memory; parent conversation and prior child sessions are not seeded.

Reports carry status: continue | complete | blocked, a non-empty summary, evidence, next steps and blocker text. Invalid, missing or oversized reports fail the workflow rather than being truncated or mistaken for cap exhaustion — a deliberate refusal to let a malformed handoff look like a finished loop.

The terminal result is complete, blocked or budget-limited. Ralph does not retry a failed round; the error names the round and retains the last successful handoff.

The one limitation to plan around

Foreground collection only. The caller owns one live run and awaits it; background start/poll, spill handles and detached collection are deferred. If you were planning to fire a long workflow and poll it later, that is not available yet.

Frequently asked

Are workflow worker threads a security boundary?

No. The repository states plainly that worker threads isolate workflow execution from the host event loop but are not a security boundary. Do not treat them as a sandbox for untrusted scripts.

Can WorkflowRun.result reject?

No. Once start() returns, result never rejects. Execution failures resolve with stopReason 'error' and cancellation resolves with 'cancelled' within the engine's bounded grace. Validation failures happen synchronously in start(), before a run exists.

What is the difference between a child failing and a fatal error?

A child that resolves normally with a non-completed stop reason is not an exception — agent() returns null so the script can handle it. Fatal WorkflowErrors always escape parallel() and pipeline() instead of becoming a per-item null.

How many rounds can a Ralph loop run?

maxRounds defaults to 256. The deployment config's value is both the default and a ceiling on a per-call override, and the engine rejects a cap above its own deployment ceiling before publishing a run.

Keep reading