13-ai-condorwave: W6filled22 citations

Condor Workflow Compiler — Plan to Compiled Graph to Execution

Audiences: developer, internal

Condor Workflow Compiler — Plan to Compiled Graph to Execution

A WorkflowPlan is a declaration: a list of steps with dependsOn edges, authored by an LLM and schema-validated. A Mastra Workflow is an execution artifact: an ordered chain of .then() / .parallel() calls over concrete Step instances 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:

ConcernFileWhat it decides
Graph loweringcompiler/graph-lowering.ts:244which steps can run in the same wave
Leaf buildingcompiler/leaf-builders/*.tswhat one step actually does at runtime
Agent + model bindingagent-factory.ts:78, model-by-input-tokens.ts:65which agent and which model back a step
Compiled-graph ledgercompiled-graph-ledger.ts:215which plan steps were emitted vs collapsed

The pipeline

Rendering diagram…

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

  1. Fail-fast on the formatter. ctx.roleAgents.formatter MUST exist, because every terminal final / render step delegates to it. A missing formatter used to be caught lazily inside buildFinalStep — 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-assembled roleAgents map throws immediately rather than deep inside a request.
  2. 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 with requireTool deeper in the loop. This is deliberate, not a duplicated gate.
  3. Clinical-mode repair at leaf scope. deriveMedicalMode re-runs here over collectPlanToolIds(plan). If it repairs, every leaf compiled below sees medicalMode: true through a cloned effectiveCtx. This is the leaf-scope twin of the planner-scope repair; the derivation itself has exactly two write sites across the whole engine.
  4. lowerPlan(plan) runs again here — not reused from the normalise pass. It throws OrchestratorCompileError on unknown-step-kind / unknown-dep, and logs its inputRefRepairs as [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.costCeilingCents is set, a gate step is inserted between levels (never before the first) that sums prior costCents and throws CostCeilingExceededError on breach, surfaced to the client as data-error-frame{code:'cost-ceiling-exceeded'}.
  • Legacy group steps. parallel and sequential plan steps are dissolved into the level graph by lowerPlan itself. 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.

Rendering diagram…

Reading the builders in order of how much they carry:

  • tool-call is the heaviest. Beyond wrapping ctx.tools[toolId].execute, it resolves each inputRefs entry against the upstream UpstreamStepDescriptor (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 at tool-call.ts:52 is the single join into condor-hitl-approval.
  • agent-call calls .stream(), never .generate(), so token and tool events reach the UI live rather than arriving as one block at the end. It runs runModelOutputFirewall and selectBudgetedTools before invoking.
  • branch and loop share one predicate engine (below).
  • approval is the plan-authored suspend leaf. Note that a tool-call leaf can also suspend, via tool policy — two distinct code paths through the same Mastra suspend() mechanism.
  • final delegates 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 ledger JSON column on condor_workflow_plans is 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 EmittedStepOrigin union (: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.ts and leaf-builders/suspend-sentinel.ts both exist and do build suspend semantics. Do not quote the header as current behaviour.
  • validatePlanStructure running twice is intended. The second call, inside compile(), is a log-only observability pass — not a duplicated gate and not a bug to remove.
  • There is no dynamic-step-prompt.ts. Only dynamic-step-prompt.spec.ts exists. The behaviour it pins lives in compiler/prompt-format.ts and compiler/leaf-builders/index.ts; cite those.
  • compile() is called up to three times per turn. pump/phases/plan-phase.ts invokes runCompile at lines 604, 638 and 700 across repair rounds. Any state introduced into the compiler must therefore be per-call, never per-run.