Two kinds of continuation
The orchestrator distinguishes two ways to keep making progress toward an objective:
- Same-session goal rounds — one agent keeps working in the same session, admitted one continuation at a time by the goal domain (
packages/goal/goal-round-driver). This is what thectx.goalsservice and the same-session driver implement. - Fresh-agent Ralph — a model-facing tool (
packages/workflow/tool-ralph) that runs a fixed workflow giving one immutable objective to a sequence of fresh child agents, each with no conversation seed.
Both sit on top of two engine seams: ctx.workflowEngine for script execution and ctx.subagents for child delegation. Ralph is not a same-session goal, an agent-loop mode, a scheduler, or a generic workflow feature — the glossary calls it a "model-facing tool policy composed from workflow and subagent primitives".
The package family
| Package | Role | ctx key |
|---|---|---|
workflow | Service Definition: script/run/result/error/event contracts | ctx.workflowEngine |
workflow-worker-thread | Concrete engine: one Node worker thread per run | registers on ctx.workflowEngine |
tool-workflow | Model-facing workflow tool | registers on ctx.tools |
tool-ralph | Model-facing Ralph fresh-agent tool | registers on ctx.tools |
goal-round-driver | Same-session continuation driver over ctx.goals | registers as a plugin |
The workflow model (ctx.workflowEngine)
WorkflowEngine.start(request): WorkflowRun validates enough synchronously to reject a malformed meta, unparseable script, unavailable provider route, or unsupported per-run limit before a run exists. The request shape:
WorkflowStartRequest = {
meta: { name, description, ... } // plain identity data, not script
script: string // plain JS body (no `export const meta`)
args?: unknown // JSON object exposed as `args` global
subagentProvider?: string // routes every child, invisible to the script
maxTotalAgents?: number // per-run child cap
parent: Agent // attributes every child agent to the caller
signal?: AbortSignal
}WorkflowRun exposes { id, meta, result, cancel(reason?), dispose() }, and WorkflowResult = { value, stopReason, error?, agentsStarted } — value is plain JSON, stopReason is completed / error / cancelled. result never rejects: execution failures resolve with stopReason: 'error', cancellation with cancelled. Runs are holder-owned; dispose() is required on every path.
The script hooks
Inside the worker the script receives args and these hooks (from packages/workflow/workflow-worker-thread):
| Hook | Behavior |
|---|---|
agent(prompt, { label, phase, schema?, provider?, model? }) | Start one host-side subagent. With schema it returns the validated structured object; otherwise final text. An ordinary failed child yields null. |
parallel(thunks) | Run thunks under the configured concurrency limit; a throwing thunk resolves to null. |
pipeline(items, ...stages) | Pass each item through stages (prev, item, index) with no cross-stage barrier. |
phase(title) | Emit workflow/phase observer narration. |
log(message) | Emit workflow/log narration. |
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps, provider-start failures, infrastructure result failures) throw fatal workflow errors that always escape parallel()/pipeline() instead of becoming an ordinary per-item null.
Failure discipline
WorkflowError carries a code and a fatal flag. Fatal codes include SCRIPT_PARSE, META_INVALID, INVALID_ARGUMENT, UNSUPPORTED_OPTION, UNSUPPORTED_SCHEMA, AGENT_CAP, ITEM_CAP, AGENT_START, AGENT_RESULT, RESULT_UNSERIALIZABLE, and CANCELLED. A child that resolves normally with a non-completed stop reason is not an infrastructure exception — agent() returns null so the script can handle it.
Events
Workflow events are observe-only and carry WorkflowRunInfo (id + meta), not the live run, so listeners cannot acquire cancellation or disposal authority:
workflow/start/workflow/endpair the run.workflow/phase/workflow/logexpose script narration.workflow/agent-start/workflow/agent-endpair each child call byseq.
Worker-thread execution
workflow-worker-thread implements the engine with one Node worker thread per run. A ready/go handshake prevents start-signal cancellation racing worker boot from executing the script's initial synchronous slice. For each agent() call the worker sends child-start over a typed host/worker protocol; the host calls SubagentRuntime.start (using the run's provider override or the configured provider), and on fulfillment records the run, observes result, then sends child-started. Provider starts are tracked separately from published children, so cancellation or worker death while a start is pending aborts it against the shared per-run signal.
The worker is an isolation, not a security boundary: node:vm inside a worker is an API-shaping mechanism, and an escaped script recovers Node capabilities with the host's privileges. The worker still keeps script CPU work off the host event loop, lets worker.terminate() be a real final stop, starts with an empty environment (credentials do not cross through process.env), and uses structured-clone data with plain-JSON validation at the script boundary.
| Config | Default | Meaning |
|---|---|---|
provider | spawn | Host-side subagent provider used by agent(). |
maxConcurrentAgents | 0 | Concurrent agent() ceiling; 0 resolves from CPU parallelism. |
maxTotalAgents | 1000 | Total agent() calls in one run. |
maxItemsPerCall | 4096 | Items accepted by one parallel()/pipeline(). |
syncTimeoutMs | 5000 | VM timeout for the script's initial synchronous slice. |
disposeGraceMs | 5000 | Bound before force-settlement/termination. |
The workflow tool (dsh-tool-workflow)
The model-facing workflow tool takes meta (name/description), script (plain JS), and optional args. A tool:<toolName> system-prompt section carries the usage policy: use it only on an explicit ask for a workflow / large multi-agent orchestration; prefer plain subagent calls for one or two delegations. Collection is synchronous: execute awaits run.result and always disposes the run. Success returns canonical { runId, agentsStarted, result } rendered as workflow "<name>" completed (<count> agent(s)), followed by Return value: and the pretty-printed JSON. A non-completed stop reason maps to an errored result, never partial output as success.
| Config | Default | Meaning |
|---|---|---|
toolName | workflow | Model-facing tool name. |
maxResultChars | 50000 | Rendered-result ceiling; longer JSON is truncated with a notice. |
Ralph (dsh-tool-ralph)
ralph({ objective, maxRounds? }) runs a fixed foreground workflow that gives one immutable objective to a sequence of fresh child agents. The deployment config's maxRounds is both the default and a ceiling on a call override. Every round starts one child through subagentProvider; that provider must exist, support structured output, and report inheritsParentContext: false. The configured provider is carried as WorkflowStartRequest.subagentProvider (the fixed script cannot inspect or change routing), and the resolved round cap as maxTotalAgents (coordinating the loop with the engine's total-child backstop).
Each child receives only the immutable objective, its current round and cap, a shared-workspace-as-authority instruction, and the previous structured Ralph handoff. The shared workspace is long-term memory; parent conversation and prior child sessions are not seeded. The handoff report has status: continue | complete | blocked, a non-empty summary, evidence, next steps, and blocker text.
| Config | Default | Meaning |
|---|---|---|
subagentProvider | spawn | Fresh structured-output provider for every round. |
maxRounds | 256 | Default and deployment ceiling for one run. |
maxHandoffChars | 16384 | Max serialized characters in one round report. |
maxResultChars | 16384 | Max characters in the complete successful parent result. |
The successful terminal result is complete, blocked, or budget-limited with the last bounded report and round count. Completion and blocker labels explicitly say a worker reported the outcome, not independent certification — Ralph completion is worker self-declaration. An ordinary child failure produces an error naming the failed round and retaining the last successful handoff; the loop does not retry that round.
Same-session goal rounds (dsh-goal-round-driver)
The goal-round-driver turns an active, armed goal into sequential goal rounds through the public Agent and session services. A goal round is one continuation cycle admitted for the current goal, materialized as one goal-sourced turn. When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves roundsStarted + 1 for the current { goalId, revision }, and queues one <goal_round> prompt with GoalMessageSource.
The retained prompt names the JSON-quoted objective and round/maxGoalRounds, treats the current workspace/tool results/durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains:
<goal_round>
Objective: "<...>"
Round: <round>/<maxGoalRounds>
Continue working toward the objective in this same session. ...
</goal_round>Key properties: human messages do not consume the goal cap; only an entered user/message increments roundsStarted; a reservation rejected as stale does not consume the round number. Activation (armed/disarmed) is process-local and deliberately absent from durable replay, so resume and fork require a later human-authorized resume through /goal or the model tool. maxGoalRounds belongs to the goal definition; the model-facing blocked threshold belongs to dsh-tool-goal — the driver duplicates neither.
| Goal round | Ralph | |
|---|---|---|
| Agent | same-session, reused | fresh child each round |
| Conversation seed | nothing copied, history retained | none (only shared workspace) |
| Memory across iterations | the session log | shared workspace + bounded handoff |
| Cap | maxGoalRounds | maxRounds |
| Evaluation | model-driven via goal policy | worker self-declared |
| Contained in | ctx.goals + goal-round-driver | ctx.workflowEngine + ctx.subagents |
Known limitations
- One worker thread per run — no pool, warm runtime, or cross-run script cache.
- No journaling/resume — a process restart cannot continue a run.
- Foreground collection only — no background start/poll, spill handles, or detached collection.
- No saved or nested workflows — a script receives no
workflow()hook and cannot orchestrate recursively.
Further reading
- Subagents — the
ctx.subagentsseam that everyagent()call and every Ralph round uses. - Session query & log export — retrieving the durable session log that goal rounds treat as authoritative.
- Glossary —
goal,goal round,goal activation,round,Ralph loop,Ralph handoff. - The subsystem references in the repo:
docs/subsystems/workflow.md. - The worker engine README:
packages/workflow/workflow-worker-thread/README.md, and the Ralph tool README:packages/workflow/tool-ralph/README.md. - Agent Notes:
.agents/notes/implemented/feature/2026-07-05-dynamic-workflows.mdand2026-07-19-fresh-agent-ralph-workflow-tool.md. - The same-session driver:
packages/goal/goal-round-driver/README.mdandsrc/prompt.ts.