13-ai-condorwave: W6filled18 citations

Condor Intent & Planner — User Turn to Plan Object

Audiences: developer, internal

Condor Intent & Planner — User Turn to Plan Object

This is the front half of the Condor control plane: everything between a user's chat turn arriving at the dispatcher and a validated, normalized WorkflowPlan object being handed to the compiler. The back half — compiling that plan into a Mastra workflow and executing it — is Condor Workflow Compiler. The runtime surrounding both (router, transport, session lifecycle) is Condor Engine.

⚠ Read this first — the intake stage is NOT the planner

The most misleading thing about this stage is its own directory name. Condor has a directory called intake/ containing an intent classifier and a request decomposer, both of which run on every turn. A reader naturally assumes they classify the request and hand a structured intent to a planner.

They do not. As of 8ee29c4a:

  • The flow selection is still the static flowKey resolved by agentic-flow-router.service.ts — a routing label carried on the dispatch (e.g. DIAGNOSIS_ENGINE_FLOW_KEY), not computed from the request text.
  • The plan DAG is still authored by ONE fused LLM call, generateAndParsePlan at workflow-runner.plan.ts:901.
  • intake/ is explicitly additive / observational — its own file headers say so (intake-stage.ts:6-9, intent-classifier.ts:12-18). Its output (IntentClass + Subtask[]) is threaded forward for logging and for future downstream consumption; it does not gate, replace, or feed the plan-authoring call.

So: generateAndParsePlan is the real planner. Any statement of the form "the intent classifier drives the plan" is wrong today. It is a staging ground for a future load-bearing intent layer, running in shadow so its accuracy can be measured against real traffic before anything depends on it.

Control flow — turn to normalized plan

Rendering diagram…

Two things worth naming in that graph:

  1. The dotted branch to intake/ is a dynamic import(), not a static one. That is deliberate: it keeps @mastra/core out of the router's static import graph, which is DI-cycle sensitive in Nest.
  2. Repair and first-pass generation converge on the same normalizePlan pipeline. A repaired plan is therefore indistinguishable from a first-pass-valid one at execution time — there is no "was repaired" branch downstream.

The real planner — generateAndParsePlan

A single-shot call against the Mastra orchestrator agent using structured output (output: workflowPlanSchema). What it assembles into the prompt:

InputWhy it is there
Context preamble (now / locale / localTime)Lets the planner resolve "latest" and relative time, and reply in the UI locale
buildSessionMemorySnapshotWorking-memory snapshot — memory-informed planning
buildPriorRunsBlockPrior-run history for the session
Attachment hints (real fileId + mime + ingestStatus)Lets the planner name a reader tool directly, skipping a sessionFilesList round-trip

If structured output drops, it falls back to text-parsing the reply.

Mastra trap: the orchestrator handle must not be destructured before calling .generate(...). Destructuring loses this, and Mastra's private-field access then throws at runtime. The inline comment above workflow-runner.plan.ts:901 guards this.

The plan object — WorkflowPlan

workflowPlanSchema (mastra/orchestrator/types.ts:472):

FieldShapeNote
idstring (min 1)
stepsWorkflowPlanStep[] (min 1)the discriminated union below
rationalestring?planner's own justification
roleRoleTemplateId?role-template binding
autonomy'autonomous' | 'supervised'feeds the HITL gate
intentPlanIntentKind?optional for back-compat; compiler defaults to 'execute'
userMessagestring?verbatim last user message
costCeilingCentsnumber?cumulative spend ceiling enforced by the pump

userMessage exists for a specific reason: the compiler threads it into every leaf's prompt. Without it, role agents re-asked for a brief the user had already given — the 2026-05-07 "phantom-execution" bug.

The 8-way step union

Rendering diagram…

approvalStepSchema is the plan-level HITL seat — the mutation/approval machinery that consumes it is in Condor Mutation & Observers.

Naming caveat (verified 2026-07-29): plan.schema.ts:70 exports a separate, narrower WORKFLOW_PLAN_STEP_KINDS const listing only five kinds (tool-call, agent-call, parallel, sequential, final). The authoritative 8-way Zod union is the one in types.ts:460. Do not read the plan.schema.ts const as the step taxonomy.

Two functions named "normalize" — do not conflate

This is the second trap in this stage, and it is purely a naming collision:

normalizePlanCandidatenormalizePlan
Fileworkflow-dispatcher.helpers.ts:278mastra/orchestrator/normalize-plan.ts:201
Stagebefore schema validationafter schema validation
Inputraw unknown (parsed JSON)a schema-valid WorkflowPlan
Jobcoerce an unknown shape into something the schema can accept (role-snapshot diffing, DEF-202)the 3-stage post-schema pipeline below

Getting these backwards misplaces where schema validation sits in the chain.

The normalizePlan pipeline (3 ordered stages)

  1. ensurePlanFinalStep (normalize-plan.ts:163) — guarantees a synthesis final leaf exists. It is a deliberate pure re-implementation of ensureFinalStep (workflow-runner.plan.ts:823) so this module stays Mastra-free and unit-testable without @mastra/core.
  2. lowerPlan (compiler/graph-lowering.ts) — dependency repair plus execution-graph lowering. An explicit dependsOn: [] means root; producers of an inputRef are folded ahead of their consumers. This is the join into the compiler topic.
  3. validatePlanStructure (plan.schema.ts:572) — validates against the active tool-catalog snapshot: off-list tool ids, phantom inputRefs, id/kind mismatches, and prose-claimed mutations (via plan-mutation-rewrite.ts).

shapeHash — a dedupe key, and nothing else

shapeHash (mastra/orchestrator/shape-hash.ts:97) is a deterministic sha256 over a canonicalized plan structure: object keys sorted, __synth_*-prefixed keys stripped, array order preserved.

Its one job is the orchestrator dedupe gate (AC-3): if an identical plan shape is generated twice inside a ~5s window, the second execution collapses into the first.

It is not a cache key for LLM responses, and not a replan-detection signal. Replan detection is a separate mechanism (plan-revision.ts, covered in the mutation/observer topic). Describing shapeHash as content-addressable caching overstates it considerably.

workflow-templates/ — pre-authored plan skeletons

workflow-template.registry.ts is a pure in-memory, DI-free registry of reusable plan skeletons. Each skeleton is a complete, schema-valid WorkflowPlan — not a partial or a fragment.

  • diagnosis-engine.workflow.ts is the one shipped skeleton: anamnesis → differential → drug-check → referral → documenter → final.
  • DETERMINISTIC_TEMPLATE_IDS (:171) marks skeletons whose execution must not vary; assertSkeletonsValid (:200) is a boot-time self-check.
  • When a turn's flowConfig.workflowTemplateIds names one, the pump — not the compiler — merges the skeleton with the live orchestrator-generated plan before compilePlan runs.

That last point is the design decision worth keeping: the registry only seeds. The compiler remains the sole execution path, so adding templates required no compiler changes at all.

medicalMode — the single canonical write-site

medical-mode-derivation.ts is not part of plan shaping, but it is derived at this stage and every downstream guardrail reads it. It exists to collapse what were two duplicate ctx.set('medicalMode', …) sites into one.

deriveMedicalMode (:115) ORs four sources:

  1. the flow-config default,
  2. a legacy flowKey check (this is the DIAGNOSIS_ENGINE_FLOW_KEY → medicalMode ON edge drawn in Condor Engine's router diagram),
  3. the persisted session flag,
  4. a clinical-candidate mismatch repair — if the surfaced tool-candidate set contains clinical-prefixed ids (CLINICAL_TOOL_ID_PREFIXES at :40patient., medication., condition., …) while medicalMode is OFF, it deterministically repairs to ON.

Source 4 is a fail-closed rule: the ambiguous case resolves toward stricter guardrails, never looser. Outside the module's own internal helpers there are exactly two call sites — condor.controller.ts:2345 (turn entry) and mastra/orchestrator/compiler.ts:473 (compile time) — which is what makes "single canonical write-site" true rather than aspirational. mastra/request-context.schema.ts:10 states the same rule as a contract comment.