Skip to content

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 the ctx.goals service 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

PackageRolectx key
workflowService Definition: script/run/result/error/event contractsctx.workflowEngine
workflow-worker-threadConcrete engine: one Node worker thread per runregisters on ctx.workflowEngine
tool-workflowModel-facing workflow toolregisters on ctx.tools
tool-ralphModel-facing Ralph fresh-agent toolregisters on ctx.tools
goal-round-driverSame-session continuation driver over ctx.goalsregisters 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:

ts
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):

HookBehavior
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/end pair the run.
  • workflow/phase / workflow/log expose script narration.
  • workflow/agent-start / workflow/agent-end pair each child call by seq.

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.

ConfigDefaultMeaning
providerspawnHost-side subagent provider used by agent().
maxConcurrentAgents0Concurrent agent() ceiling; 0 resolves from CPU parallelism.
maxTotalAgents1000Total agent() calls in one run.
maxItemsPerCall4096Items accepted by one parallel()/pipeline().
syncTimeoutMs5000VM timeout for the script's initial synchronous slice.
disposeGraceMs5000Bound 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.

ConfigDefaultMeaning
toolNameworkflowModel-facing tool name.
maxResultChars50000Rendered-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.

ConfigDefaultMeaning
subagentProviderspawnFresh structured-output provider for every round.
maxRounds256Default and deployment ceiling for one run.
maxHandoffChars16384Max serialized characters in one round report.
maxResultChars16384Max 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 roundRalph
Agentsame-session, reusedfresh child each round
Conversation seednothing copied, history retainednone (only shared workspace)
Memory across iterationsthe session logshared workspace + bounded handoff
CapmaxGoalRoundsmaxRounds
Evaluationmodel-driven via goal policyworker self-declared
Contained inctx.goals + goal-round-driverctx.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.subagents seam that every agent() call and every Ralph round uses.
  • Session query & log export — retrieving the durable session log that goal rounds treat as authoritative.
  • Glossarygoal, 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.md and 2026-07-19-fresh-agent-ralph-workflow-tool.md.
  • The same-session driver: packages/goal/goal-round-driver/README.md and src/prompt.ts.