TUCAN Dispatcher — Persisted-State Agent Routing
Audiences: developer, internal
The dispatcher is a deterministic state machine, not an LLM choice. Agent type is read from
session.agentType; tool catalog is derived from the YAML template at agent construction; every transition between stages is a typedTurnTransition(ok / suspend / replan / escalate / fail) validated by Zod v4.
Migration status (2026-07): this is the LEGACY TUCAN dispatcher. The second-generation engine Condor (
ddx-api/src/ai/condor/**, see condor-engine) runs side-by-side and progressively ports individual flows off this path via itsagentic-flow-router.service.ts. TUCAN'ssession.agentTypepersisted-dispatch invariant carries over verbatim to Condor — dispatch is never an LLM choice in either engine.Structural note: the former
dispatch/engine.ts+dispatch/dispatch-engine.module.tswere consolidated — the turn engine now lives indispatch/stages/dispatcher.ts(staged pipeline) fronted byagent-dispatcher.service.ts;dispatch/index.tsis the barrel.
Business Purpose
TUCAN's reliability lives in the dispatcher. The product promise — "the AI does real clinical work" — collapses if any of the following is true on a given turn:
- The wrong agent type runs (Document agent answering a clinical question, voice operator running on a chat session).
- The user's
Stopclick is silently dropped because the abort arrived before the dispatcher reached its firstawait. - A suspended workflow leaks across users because the next POST matched the wrong pending entry.
- A tool error becomes a 500 instead of a typed
escalate/failtransition the UI can render.
The dispatcher is the boundary that owns these guarantees. It:
- Reads agent identity from persisted session state, not from the LLM. (Project memory invariant project_tucan_no_guided_consumers.)
- Registers an
AbortSignalsynchronously before the first await, soStopworks across the gap between HTTP 201 response and the first SSE event. - Tracks suspended workflows by
session.metadata.suspendedWorkflows[taskId]— keyed entries, first-entry-wins, never matched across sessions. - Reduces every per-stage outcome into a typed
TurnTransition(ok | suspend | replan | escalate | fail) via thedispatch/transitions.tsfactory functions — the only way the engine moves forward.
That's why "TUCAN dispatcher" is its own KB page: the reliability contract is architecturally distinct from the LLM agent invocation and from the per-turn UI rendering.
Audiences
- Investor: TUCAN's defensibility is that it does not depend on the LLM being correct about routing. State-machine routing reduces the surface where an LLM hallucination can do harm to "what the tool I'm allowed to call does," not "which agent or workflow to run."
- Developer / partner: A new stage is a class with a single
run()method that returns aTurnTransition. TheAgentDispatcherService.processMessagepipeline composes them serially with abort-checks between every stage. No magic. - Internal (ops / support): When a turn looks wrong in production
("the AI answered scheduling questions with a clinical agent"),
the answer is in
session.agentTypeandsession.metadata.mode, not the LLM. SQL is your friend.
Architecture
AgentDispatcherService.processMessage(dto) — every stage returns a TurnTransition
(typed discriminant).
| # | Step | Phase |
|---|---|---|
| 1 | resolveTurn(dto) → { agentType, pendingSuspendedWorkflows } | setup |
| 2 | AbortSignal registration (sync, before any await) | setup |
| 3 | ContextBuilderService.buildContext(...) | setup |
| 4 | messageService.createUserMessage(...) | setup |
| 5 | agentPublisher.publishAgentStart(...) | setup |
| 6 | TrivialBypassStage.tryBypass(pre-rewriter) | stage chain |
| 7 | RewriterStage.run(...) | stage chain |
| 8 | IntentClassifierStage.publish(...) | stage chain |
| 9 | SuspenderStage (if pendingSuspendedWorkflows) | stage chain |
| 10 | DispatcherStage.run(...) ← actual Mastra Agent | stage chain |
| 11 | ReporterStage.run(...) | stage chain |
| 12 | fire-and-forget QuickActionsService.compute(...) | stage chain |
DispatchEngine.run(input) → AsyncGenerator<TurnTransition>
| Property |
|---|
| framework-light (no Mastra import, no SSE injection) |
classifyIntent / executeStep / report |
maps ToolResult → ok | suspend | escalate | fail |
| used as the typed engine for the migration target |
Consumer side-effects (the dispatcher service, NOT the engine)
| Side effect |
|---|
| SSE publish via event-bus |
Prisma writes (ChatMessage, TucanUsageRecord) |
session.metadata.updateSessionData (suspendedWorkflows) |
The staged dispatcher pipeline (current architecture)
The earlier "target architecture" — a framework-light DispatchEngine
async-generator in dispatch/engine.ts — was realized and then folded in.
The turn pipeline now lives in dispatch/stages/ (dispatcher.ts,
intent-classifier.ts, rewriter.ts, trivial-bypass.ts, suspender.ts,
reporter.ts, turn-prologue.ts), coordinated by
agent-dispatcher.service.ts (AgentDispatcherService). The standalone
dispatch/engine.ts + dispatch/dispatch-engine.module.ts no longer exist;
the retired DispatcherStage class note is in stages/dispatcher.ts itself.
Each stage still reduces to a typed TurnTransition — that invariant survived
the consolidation and carries forward into condor-engine.
The engine returns the generator after emitting any terminal
transition (fail, escalate, terminal ok with the report
envelope, or suspend). After return, the consumer must not pull
further values.
Per-stage classes
All dispatch/stages/ classes share the contract async run(input)
returning a transition or a stage-typed envelope. Today's set:
| Stage class | File | Purpose |
|---|---|---|
TrivialBypassStage | stages/trivial-bypass.ts | Skip LLM for greetings + one-token inputs (pre-rewriter and post-rewriter phases) |
RewriterStage | stages/rewriter.ts | Mastra agent that turns raw prompt into structured rewrite |
IntentClassifierStage | stages/intent-classifier.ts | Map rewrite → intent label via intent-descriptor.registry |
SuspenderStage | stages/suspender.ts | Resume Mastra workflow from suspendedWorkflows[taskId] |
DispatcherStage | stages/dispatcher.ts | The Mastra Agent.generate(...) invocation |
ReporterStage | stages/reporter.ts | Narrate dispatcher output to HTML / Markdown for the UI |
ReporterAggregators | stages/reporter-aggregators.ts | Helpers for reporter |
TurnPrologue | stages/turn-prologue.ts | Standard prologue per turn |
types | stages/types.ts | Shared per-stage types |
The dispatch/trivial-input.classifier.ts module backs TrivialBypassStage
with the actual classification logic.
Tech Stack & Choices
| Component | Choice | Why |
|---|---|---|
| Composition | Serial stages in AgentDispatcherService.processMessage | One ordered pipeline, easy to reason about and trace. |
| Transition type | Zod v4 discriminated union TurnTransition (ok / suspend / replan / escalate / fail) | Exhaustive switch is enforced at compile time; runtime parse on network boundaries (assertTurnTransition). |
| Transition construction | Typed factories in transitions.ts (ok, suspend, replan, escalate, fail) | Inline literals banned — centralizes telemetry and future schema migration. |
| Abort surface | AbortSignal + AbortController registered in activeStreams: Map<string, ActiveStreamEntry> | Stop button works across the response-vs-await race. |
| Suspended workflows | session.metadata.suspendedWorkflows[taskId] = { runId, stepId, workflowId, toolId, ... } | Per-task entries, first-entry-wins on resume, validated by SuspendedWorkflowEntrySchema. |
| Agent dispatch source | session.agentType (set by the caller — embeddable assistant resolver, voice operator, messenger consumer) | Deterministic routing; LLM cannot pick the agent. |
| Resume bridge | ChatOrchestratorService.resume (orchestrator/chat-orchestrator.service.ts:152) | Reads metadata, calls Mastra workflow.createRun({ runId }), run.resume({ step, resumeData }). |
| Error type | Typed TucanError discriminant (tool_unknown, permission_denied, provider_error, …) | Engine maps permission_denied → escalate('human'); others → fail. UI gets typed error semantics, not strings. |
| Engine isolation | DispatchEngine (Phase 6) imports no Mastra, no SSE | Consumer-side side effects; engine is pure transition reduction. Equivalence-tested against legacy in dispatch/__tests__/transition-equivalence.spec.ts. |
Alternatives rejected:
- LLM-chosen agent type (the "guided" pattern) — explicit project
invariant (project_tucan_no_guided_consumers). Adding it
silently breaks the SSE contract assumption that
agent:startreports a known, persisted agent ID. - Hard-coded order in one giant method — what we had before stage classes. Hard to test individual transitions; impossible to selectively bypass a stage for a given input.
- State machine via XState — overkill for a 7-stage serial pipeline; would have hidden the typed-transition contract behind an additional library.
Data Flow
Business outcome: A doctor clicks Stop on a turn that is running a slow tool. The turn aborts, the SSE stream closes cleanly, and the UI flips back to "ready" within one frame.
Technical mechanism:
- Controller receives stop POST and calls
abort()on theAbortControllerregistered forrequestId.- The next
checkAborted()inside the dispatcher throwsDispatchAbortedError(ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:156).- The outer try/catch publishes
agent:complete { aborted: true }and clearsstreamingCompleted: true.- The SSE channel sees
agent:complete; the consumer hook resets the UI.
Business outcome: A patient-search workflow asks the doctor "did you mean John Garcia or Juan García?" The dispatcher pauses, the doctor picks the correct candidate, the same workflow resumes with that decision.
Technical mechanism:
- Tool execution returns a
DispatchStep.suspendenvelope withformId: 'clarify-candidate'and a payload containing the candidate list.DispatchEngine.executeStep(engine.ts:204) sees thestep.suspendshape and emitssuspend(formId, payload)transition.- The dispatcher service persists
session.metadata.suspendedWorkflows[taskId]and publishesworkflow:suspendedSSE.- Doctor picks "John Garcia" → POST
/sessions/:id/messageswith aResumeDecisionbody.ChatOrchestratorService.resume(ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152) validates againstResumeDecisionSchema, looks up the entry, calls Mastraworkflow.createRun({ runId })andrun.resume({ step: entry.stepId, resumeData: decision }).- The Mastra workflow continues from the suspended step with the doctor's decision as
resumeData; the dispatcher emits the finalok(report) transition.
Implicated Code
ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:88— classAgentDispatcherService.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:110—processMessage(dto)— 10-stage pipeline.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:113—resolveTurn(dto, …)returns{ agentType, pendingSuspendedWorkflows }— agent identity comes from persisted state, NOT the LLM.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:126— Abort-signal handshake (registered synchronously before first await).ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:155—turnSignalset +checkAbortedclosure.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:181—TrivialBypassStage.tryBypass(pre-rewriter phase).ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:192—RewriterStage.run.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:200—IntentClassifierStage.publish.ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:228—DispatcherStage.run(MastraAgent.generatesite).ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:276—ReporterStage.run.ddx-api/src/ai/tucan/dispatch/stages/dispatcher.ts— the staged turn coordinator (the formerdispatch/engine.tsDispatchEnginegenerator was folded here;DispatcherStageis retired per the file's own header note).ddx-api/src/ai/tucan/dispatch/stages/intent-classifier.ts— intent classification stage (wasengine.classifyIntent).ddx-api/src/ai/tucan/dispatch/stages/suspender.ts— suspend handling (wasengine.executeStep→step.suspendbranch).ddx-api/src/ai/tucan/dispatch/stages/reporter.ts— terminal report envelope (wasengine.report).ddx-api/src/ai/tucan/dispatch/index.ts— barrel for the dispatch surface.ddx-api/src/ai/tucan/dispatch/transitions.ts:52—ok(output).ddx-api/src/ai/tucan/dispatch/transitions.ts:61—suspend(formId, payload).ddx-api/src/ai/tucan/dispatch/transitions.ts:73—replan(reason, attemptsRemaining).ddx-api/src/ai/tucan/dispatch/transitions.ts:83—escalate(reason, level).ddx-api/src/ai/tucan/dispatch/transitions.ts:91—fail(error).ddx-api/src/ai/tucan/dispatch/transitions.ts:114—assertTurnTransition(value)runtime guard.ddx-api/src/ai/tucan/dispatch/__tests__/transition-equivalence.spec.ts— parity test between legacy graph andDispatchEngine.ddx-api/src/ai/tucan/common/types/turn-transition.types.ts— Zod v4 discriminated union definition.ddx-api/src/ai/tucan/common/types/tucan-error.types.ts— typedTucanErrordiscriminant (code:permission_denied | tool_unknown | provider_error | …).ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152— resume bridge.
Operational Notes
- TUCAN does NOT have guided consumers today. (project_tucan_no_guided_consumers) Agent dispatch is persisted state, NOT LLM-chosen. There are no enum/regex tools wired to a guided pattern; the plumbing exists in the registry but ships dormant. Do not document the aspirational guided pattern as live.
- Agent type is set by the caller, not chosen by the LLM. The
EmbeddableAssistant resolver hardcodes
'clinical-assistant'; the voice operator sets'tucan'; the messenger consumer sets the channel-appropriate type (ai-chat-sessions §Architecture).useSessionResolverALWAYS creates'clinical-assistant'— does NOT forward the selected type (see MEMORY_TUCAN_AI; defect to file). get_clinical_notesis a hallucination. Real tool:list-clinical-notes. If the dispatcher logstool_unknownfor it, the clinical agent's prompt has drifted — re-eval.- Abort registration must be synchronous. The controller MUST
register the signal in
activeStreamssynchronously before the fire-and-forget Promise starts. Any new HTTP path that callsprocessMessagewithout doing this breaks Stop. - First-entry-wins on suspended workflow lookup. The
ChatOrchestratorService.resumereadsObject.entries(suspendedWorkflows)[0](chat-orchestrator.service.ts:197). Multi-suspend is not supported today. agent:resolvedSSE event is emitted by the specialist factory when the dispatch picks a specific specialist (agent-templates §Specialist factory). UI relies on it for the "answered by X" label.- Replan budget defaults to 1. Engine emits
replan(reason, Math.max(0, remaining)). After replan exhaustion the engine emitsfail({code:'budget_exhausted', kind:'replan'}). Tune the budget per-intent in the descriptor registry, not globally. - SSE channel naming MUST use
SSEChannels.session(id). Raw template literals like`agent:session-${id}`are banned by the SSE contract channel rules (SSEChannels.session(id), never raw literals).
Related Topics
- tucan-assistant — orchestration entry point; the service-level overview.
- tucan-orchestrator — layered execution L1-L5; consumer of some of the same primitives.
- tucan-intent-filtering — the intent classifier stage in detail.
- agent-templates — provides the resolved agent that
DispatcherStageactually invokes. - ai-medical-tools — produces the
ToolResult<O>envelopes the engine reduces. - ai-chat-sessions — owns
session.metadata.suspendedWorkflows. - tucan-context-builder — supplies the
contextthe dispatcher passes to the agent. - sse-event-engine (W1) — receives
agent:start/agent:resolved/agent:complete/workflow:suspendedevents the dispatcher emits. packages/ddx-sse-contract/(@ddx/sse-contract, 4.1.0) — canonical SSE contract (link, do not duplicate).MEMORY_TUCAN_AIshard — dispatch pipeline historical notes.