Condor Workflow Compiler — Plan to Compiled Graph to Execution
Audiences: developer, internal
A
WorkflowPlanis a declaration: a list of steps withdependsOnedges, authored by an LLM and schema-validated. A MastraWorkflowis an execution artifact: an ordered chain of.then()/.parallel()calls over concreteStepinstances that each know their tool, their agent, their model and their prompt. The compiler is the one function that turns the first into the second. It is pure — same plan plus same context yields the same workflow shape, never cached across turns — and that purity is not decoration: it is what lets the pump call it up to three times per turn when a plan needs repairing.
Purpose
This topic covers the middle of the Condor control plane. Upstream, the
planner has produced and normalised a WorkflowPlan
(condor-intent-and-planner). Downstream, the
run spine owns the run FSM and the pump drains the stream
(condor-session-run-spine,
condor-sse-pump-agui). Between them sits one
entrypoint:
compile(plan: WorkflowPlan, ctx: CompileCtx): Workflow — compiler.ts:438
Four concerns live under that entrypoint, and this topic takes them in order:
| Concern | File | What it decides |
|---|---|---|
| Graph lowering | compiler/graph-lowering.ts:244 | which steps can run in the same wave |
| Leaf building | compiler/leaf-builders/*.ts | what one step actually does at runtime |
| Agent + model binding | agent-factory.ts:78, model-by-input-tokens.ts:65 | which agent and which model back a step |
| Compiled-graph ledger | compiled-graph-ledger.ts:215 | which plan steps were emitted vs collapsed |
The pipeline
The dashed edge back into runCompile is the reason compile() must stay pure.
plan-phase.ts invokes it at line 604, and again at 638 and 700 after a repair
round. A compiler that mutated its input, cached, or held per-run state could not
be re-entered safely on the same turn.
Step 1 — Boot assertions inside compile()
Four things happen before a single step is built, in this literal order
(compiler.ts:438 onward):
- Fail-fast on the formatter.
ctx.roleAgents.formatterMUST exist, because every terminalfinal/ render step delegates to it. A missing formatter used to be caught lazily insidebuildFinalStep— which meant a plan whose terminal step was elided could compile and run to completion with no formatter at all, silently degrading the rendered answer. The assertion moved to compile-entry so a mis-assembledroleAgentsmap throws immediately rather than deep inside a request. validatePlanStructure(plan)— a second, log-only pass. The normalise pipeline already ran this as a gate before compile. This call re-runs it purely for structured observability, emitting[plan.validate.structure]warnings. Enforcement of off-list tools stays withrequireTooldeeper in the loop. This is deliberate, not a duplicated gate.- Clinical-mode repair at leaf scope.
deriveMedicalModere-runs here overcollectPlanToolIds(plan). If it repairs, every leaf compiled below seesmedicalMode: truethrough a clonedeffectiveCtx. This is the leaf-scope twin of the planner-scope repair; the derivation itself has exactly two write sites across the whole engine. lowerPlan(plan)runs again here — not reused from the normalise pass. It throwsOrchestratorCompileErroronunknown-step-kind/unknown-dep, and logs itsinputRefRepairsas[plan.normalize.input-ref-dependency].
Step 2 — Graph lowering: what "lowering" produces
lowerPlan (compiler/graph-lowering.ts:244) takes the plan's dependsOn edges —
a DAG — and flattens them into LoweredPlan.levels, an ordered array of
ExecutionLevel (graph-lowering.ts:45). The term is borrowed from compiler
theory: lowering means translating a higher-level representation into the
primitives the target runtime actually offers. Mastra offers .then() and
.parallel([...]); it does not offer "run this DAG". Lowering is what closes
that gap.
A level is the set of steps whose dependencies are all satisfied by earlier
levels — i.e. steps that may run concurrently. The emission rule that follows is
mechanical: a level of width 1 becomes .then(step), a level of width n
becomes .parallel([...n siblings]).
Flattening loses one thing: fan-in. If a downstream step depends on more than
one sibling from the level above, .then() alone cannot deliver both outputs. The
compiler auto-inserts a synthesizer step (leaf-builders/synthesizer.ts:34)
immediately before it, keyed by step id, whose only job is to merge sibling
outputs. Synthesizers are compiler-internal scaffolding — no plan may author one,
and no step kind names them.
Two other synthetic insertions live at this layer:
- Cost gate. When
plan.costCeilingCentsis set, a gate step is inserted between levels (never before the first) that sums priorcostCentsand throwsCostCeilingExceededErroron breach, surfaced to the client asdata-error-frame{code:'cost-ceiling-exceeded'}. - Legacy group steps.
parallelandsequentialplan steps are dissolved into the level graph bylowerPlanitself. They never reach a leaf builder.
Step 3 — What a "leaf" IS
A leaf is a compiled Mastra Step instance, produced via createStep, by
exactly one builder in compiler/leaf-builders/, from exactly one
WorkflowPlanStep. One plan step in, one leaf out — with the synthesizer and
cost-gate insertions above as the only exceptions to that arithmetic.
Reading the builders in order of how much they carry:
tool-callis the heaviest. Beyond wrappingctx.tools[toolId].execute, it resolves eachinputRefsentry against the upstreamUpstreamStepDescriptor(prompt-format.ts:52) — this is what stops placeholder strings like"patient_id_from_s1"leaking into a real tool argument. It also owns the tool-error envelope, the clarification path, and the HITL approval gate; the policy consult attool-call.ts:52is the single join into condor-hitl-approval.agent-callcalls.stream(), never.generate(), so token and tool events reach the UI live rather than arriving as one block at the end. It runsrunModelOutputFirewallandselectBudgetedToolsbefore invoking.branchandloopshare one predicate engine (below).approvalis the plan-authored suspend leaf. Note that atool-callleaf can also suspend, via tool policy — two distinct code paths through the same Mastrasuspend()mechanism.finaldelegates to the formatter role agent asserted at compile-entry.
leaf-builders/shared.ts holds the helpers every builder needs — toRequestContext,
requireRoleAgent, liftToolsets, buildPriorOutputsBlock — and is one of the two
applyMedicalModeToContext write sites in the engine.
Per-step prompt assembly
Each leaf gets its prompt composed at step time, from that step's own
matcher-bound MatchedTool — not from a monolithic system prompt shared by the
whole run. The implementation is composeStepPrompt and buildStepContractBlock
(compiler/prompt-format.ts:773 and :751) plus assembleStepInstructions
(compiler/leaf-builders/index.ts:120). The contract is pinned by
dynamic-step-prompt.spec.ts, which despite its filename tests those two files —
there is no dynamic-step-prompt.ts implementation module.
The expression DSL
branch.condition and loop.until both consume one closed grammar: PlanExpr,
whose operator set is EXPR_OPS (expression/dsl.ts:36), evaluated by the sole
evaluator evaluateExpr (expression/evaluator.ts:103).
This unification fixed a real and quiet bug. The plan schema previously
documented JSONPath, while branch.ts and loop.ts actually evaluated a bare
truthy key lookup. The model, reading the schema, emitted JSONPath-shaped strings
such as "$.steps.foo.output.score > 0.8" — which the key lookup resolved falsy
every time. The visible symptom: every branch silently took ifFalse, and every
loop only ever exited via its maxIterations backstop. Schema-advertised grammar
and executed grammar are now the same object, so that class of divergence is
structurally unrepresentable.
Step 4 — Agent and model binding
bindRoleAgents({roles, env, catalog}) (agent-factory.ts:78) is the single
seam that constructs a Mastra Agent per canonical RoleTemplateId — intake,
triage, clinician, coder, researcher, summarizer, planner, reviewer, formatter,
tool-runner, referrer, documenter, correspondent. It is one switch in one file
precisely so role wiring cannot drift between the dispatcher, the orchestrator and
the Studio entrypoints. A role that is not yet implemented throws
RoleNotImplementedError (agent-factory.ts:128), surfaced as
data-error-frame{code:'agent-execution-failed'} — never silently dropped.
bindTools (agent-factory.ts:202) is the sibling that shapes a tool-search-hit
subset into the Mastra tools map.
Model choice is a tier layered underneath that, not a replacement for it.
pickLightChatModelId(catalog, heavyModelId) (model-by-input-tokens.ts:65)
inspects the turn's input size and substitutes a lighter catalog model when the
input is at or under SMALL_TURN_CHAR_THRESHOLD (model-by-input-tokens.ts:31 —
4,000 chars, roughly 1,000 tokens, measured by estimateInputChars at :124).
Its preference order is: a self-hosted gemma-family model, then an explicitly
mini / nano / small / lite-named id, then the cheapest-per-token chat
model, and otherwise the heavy model unchanged. Everything routes through
dudoxxMastraModel — the Dudoxx factory law holds here as everywhere.
The consequence to internalise: bindRoleAgents still builds the role's default
agent regardless. The router only swaps the catalog model id, only for small
turns, and only when a genuinely lighter model exists. It is a cost and latency
optimisation and never a correctness requirement — a catalog with no light model
degrades to a no-op.
Step 5 — The compiled-graph ledger (and what DEF-147 was)
compiled-graph-ledger.ts sits beside the emission loop rather than inside it.
buildCompiledGraphLedger(plan) (:215) records, per compile, which plan steps
were emitted and which were collapsed — where collapsed is a first-class
LedgerStepStatus (:65), not an inference.
DEF-147 was a lockstep-drift defect. Before the ledger, three separate
functions — chooseTerminal, collapsedTerminalStepIds and inspectShape —
each independently re-derived the same question: is this terminal step elided?
They were kept consistent only by a comment in compiler.ts labelled
"LOCKSTEP CONTRACT". When they drifted, the failure was this: a final step that
the compiler intentionally elides via collapse-to-source is, correctly, absent from
wfResult.steps. The reconcile loop walked the raw plan rather than the
compiled graph, saw the step missing, and labelled it failed — reporting a
deliberate optimisation as a runtime error.
The fix is structural rather than corrective. The collapse derivation now runs
exactly once, in this module; collapsedStepIds (:323) is the read side,
and compiler.ts's collapsedTerminalStepIds() became a thin read over the
ledger instead of a hand-maintained replay of compile(). There is no longer a
mirror function that can drift.
Two scope boundaries worth knowing before citing this module:
- The ledger is in-memory only. A
ledgerJSON column oncondor_workflow_plansis deferred, not shipped — despite the module's own header describing the artifact as "persisted-per-compile". - Synthesizer, cost-gate and level-marker steps are not yet enumerated as
emitted rows. The
EmittedStepOriginunion (:54) reserves those slots, but today the ledger records plan steps plus collapse elisions.
Step 6 — The handoff to the run spine
compile() returns a Mastra Workflow object and stops. It does not run it,
does not open a run record, and emits no frames.
The driver is workflow-runner.pump.ts, which imports CompileCtx and
StepTimingRecorder from the compiler and takes ownership from there. Everything
past that line — draining the stream, emitting AG-UI frames, opening and
transitioning the run FSM, suspending and resuming across a HITL approval,
persisting the run record for reconnect replay — belongs to the pump and the run
spine, and is documented in
condor-session-run-spine and
condor-sse-pump-agui.
The boundary is worth stating sharply, because the two halves fail differently. A
compiler fault is a shape fault: it throws OrchestratorCompileError before
any step runs, is deterministic for a given plan, and is repairable by recompiling
a corrected plan on the same turn. A run-spine fault is a lifecycle fault: it
happens mid-flight, has already emitted frames the client has seen, and is
recovered through the FSM's terminal states rather than by retrying compilation.
Gotchas
- The compiler's own header comment is stale. It claims "NO
suspend()anywhere (AC-7, v0 has no HITL gates)". That described the v0 compiler.leaf-builders/approval.tsandleaf-builders/suspend-sentinel.tsboth exist and do build suspend semantics. Do not quote the header as current behaviour. validatePlanStructurerunning twice is intended. The second call, insidecompile(), is a log-only observability pass — not a duplicated gate and not a bug to remove.- There is no
dynamic-step-prompt.ts. Onlydynamic-step-prompt.spec.tsexists. The behaviour it pins lives incompiler/prompt-format.tsandcompiler/leaf-builders/index.ts; cite those. compile()is called up to three times per turn.pump/phases/plan-phase.tsinvokesrunCompileat lines 604, 638 and 700 across repair rounds. Any state introduced into the compiler must therefore be per-call, never per-run.
Related Topics
- Condor Intent and Planner — upstream: how the
WorkflowPlanthis topic consumes is authored, normalised and validated. - Condor Session, Run Spine and Telemetry — downstream: the run FSM that drives the compiled workflow.
- Condor Mutation and Observers — mid-run plan-graph recovery, applied within the already-compiled graph without recompiling.
- Condor Engine — the surrounding runtime, transport and flow router.
- Condor HITL Approval — the suspend/approve
mechanism both the
approvalleaf and the tool-policy path route through.