06-ai-tucanwave: W3filled22 citations

TUCAN Dispatcher — Persisted-State Agent Routing

Audiences: developer, internal

TUCAN Dispatcher — Persisted-State Agent Routing

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 typed TurnTransition (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 its agentic-flow-router.service.ts. TUCAN's session.agentType persisted-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.ts were consolidated — the turn engine now lives in dispatch/stages/dispatcher.ts (staged pipeline) fronted by agent-dispatcher.service.ts; dispatch/index.ts is 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 Stop click is silently dropped because the abort arrived before the dispatcher reached its first await.
  • 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 / fail transition the UI can render.

The dispatcher is the boundary that owns these guarantees. It:

  1. Reads agent identity from persisted session state, not from the LLM. (Project memory invariant [[project_tucan_no_guided_consumers]].)
  2. Registers an AbortSignal synchronously before the first await, so Stop works across the gap between HTTP 201 response and the first SSE event.
  3. Tracks suspended workflows by session.metadata.suspendedWorkflows[taskId] — keyed entries, first-entry-wins, never matched across sessions.
  4. Reduces every per-stage outcome into a typed TurnTransition (ok | suspend | replan | escalate | fail) via the dispatch/transitions.ts factory 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 a TurnTransition. The AgentDispatcherService.processMessage pipeline 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.agentType and session.metadata.mode, not the LLM. SQL is your friend.

Architecture

diagram
┌────────────────────────────────────────────────────────────────┐
│  AgentDispatcherService.processMessage(dto)                    │
│   1. resolveTurn(dto) → { agentType, pendingSuspendedWorkflows }│
│   2. AbortSignal registration (sync, before any await)         │
│   3. ContextBuilderService.buildContext(...)                    │
│   4. messageService.createUserMessage(...)                      │
│   5. agentPublisher.publishAgentStart(...)                      │
│                                                                 │
│   ── Stage chain ────────────────────────────────────────       │
│   6. TrivialBypassStage.tryBypass(pre-rewriter)                 │
│   7. RewriterStage.run(...)                                     │
│   8. IntentClassifierStage.publish(...)                         │
│   9. SuspenderStage (if pendingSuspendedWorkflows)              │
│  10. DispatcherStage.run(...)         ← actual Mastra Agent     │
│  11. ReporterStage.run(...)                                     │
│  12. fire-and-forget QuickActionsService.compute(...)           │
│                                                                 │
│   Every stage returns a TurnTransition (typed discriminant).    │
└────────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌────────────────────────────────────────────────────────────────┐
│  DispatchEngine.run(input) → AsyncGenerator<TurnTransition>     │
│   · 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) │
│   · 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 classFilePurpose
TrivialBypassStagestages/trivial-bypass.tsSkip LLM for greetings + one-token inputs (pre-rewriter and post-rewriter phases)
RewriterStagestages/rewriter.tsMastra agent that turns raw prompt into structured rewrite
IntentClassifierStagestages/intent-classifier.tsMap rewrite → intent label via intent-descriptor.registry
SuspenderStagestages/suspender.tsResume Mastra workflow from suspendedWorkflows[taskId]
DispatcherStagestages/dispatcher.tsThe Mastra Agent.generate(...) invocation
ReporterStagestages/reporter.tsNarrate dispatcher output to HTML / Markdown for the UI
ReporterAggregatorsstages/reporter-aggregators.tsHelpers for reporter
TurnProloguestages/turn-prologue.tsStandard prologue per turn
typesstages/types.tsShared per-stage types

The dispatch/trivial-input.classifier.ts module backs TrivialBypassStage with the actual classification logic.

Tech Stack & Choices

ComponentChoiceWhy
CompositionSerial stages in AgentDispatcherService.processMessageOne ordered pipeline, easy to reason about and trace.
Transition typeZod v4 discriminated union TurnTransition (ok / suspend / replan / escalate / fail)Exhaustive switch is enforced at compile time; runtime parse on network boundaries (assertTurnTransition).
Transition constructionTyped factories in transitions.ts (ok, suspend, replan, escalate, fail)Inline literals banned — centralizes telemetry and future schema migration.
Abort surfaceAbortSignal + AbortController registered in activeStreams: Map<string, ActiveStreamEntry>Stop button works across the response-vs-await race.
Suspended workflowssession.metadata.suspendedWorkflows[taskId] = { runId, stepId, workflowId, toolId, ... }Per-task entries, first-entry-wins on resume, validated by SuspendedWorkflowEntrySchema.
Agent dispatch sourcesession.agentType (set by the caller — embeddable assistant resolver, voice operator, messenger consumer)Deterministic routing; LLM cannot pick the agent.
Resume bridgeChatOrchestratorService.resume (orchestrator/chat-orchestrator.service.ts:152)Reads metadata, calls Mastra workflow.createRun({ runId }), run.resume({ step, resumeData }).
Error typeTyped TucanError discriminant (tool_unknown, permission_denied, provider_error, …)Engine maps permission_deniedescalate('human'); others → fail. UI gets typed error semantics, not strings.
Engine isolationDispatchEngine (Phase 6) imports no Mastra, no SSEConsumer-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:start reports 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:

  1. Controller receives stop POST and calls abort() on the AbortController registered for requestId.
  2. The next checkAborted() inside the dispatcher throws DispatchAbortedError (ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:156).
  3. The outer try/catch publishes agent:complete { aborted: true } and clears streamingCompleted: true.
  4. 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:

  1. Tool execution returns a DispatchStep.suspend envelope with formId: 'clarify-candidate' and a payload containing the candidate list.
  2. DispatchEngine.executeStep (engine.ts:204) sees the step.suspend shape and emits suspend(formId, payload) transition.
  3. The dispatcher service persists session.metadata.suspendedWorkflows[taskId] and publishes workflow:suspended SSE.
  4. Doctor picks "John Garcia" → POST /sessions/:id/messages with a ResumeDecision body.
  5. ChatOrchestratorService.resume (ddx-api/src/ai/tucan/orchestrator/chat-orchestrator.service.ts:152) validates against ResumeDecisionSchema, looks up the entry, calls Mastra workflow.createRun({ runId }) and run.resume({ step: entry.stepId, resumeData: decision }).
  6. The Mastra workflow continues from the suspended step with the doctor's decision as resumeData; the dispatcher emits the final ok (report) transition.

Implicated Code

  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:88 — class AgentDispatcherService.
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:110processMessage(dto) — 10-stage pipeline.
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:113resolveTurn(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:155turnSignal set + checkAborted closure.
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:181TrivialBypassStage.tryBypass(pre-rewriter phase).
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:192RewriterStage.run.
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:200IntentClassifierStage.publish.
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:228DispatcherStage.run (Mastra Agent.generate site).
  • ddx-api/src/ai/tucan/dispatch/agent-dispatcher.service.ts:276ReporterStage.run.
  • ddx-api/src/ai/tucan/dispatch/stages/dispatcher.ts — the staged turn coordinator (the former dispatch/engine.ts DispatchEngine generator was folded here; DispatcherStage is retired per the file's own header note).
  • ddx-api/src/ai/tucan/dispatch/stages/intent-classifier.ts — intent classification stage (was engine.classifyIntent).
  • ddx-api/src/ai/tucan/dispatch/stages/suspender.ts — suspend handling (was engine.executeStepstep.suspend branch).
  • ddx-api/src/ai/tucan/dispatch/stages/reporter.ts — terminal report envelope (was engine.report).
  • ddx-api/src/ai/tucan/dispatch/index.ts — barrel for the dispatch surface.
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:52ok(output).
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:61suspend(formId, payload).
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:73replan(reason, attemptsRemaining).
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:83escalate(reason, level).
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:91fail(error).
  • ddx-api/src/ai/tucan/dispatch/transitions.ts:114assertTurnTransition(value) runtime guard.
  • ddx-api/src/ai/tucan/dispatch/__tests__/transition-equivalence.spec.ts — parity test between legacy graph and DispatchEngine.
  • 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 — typed TucanError discriminant (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). useSessionResolver ALWAYS creates 'clinical-assistant' — does NOT forward the selected type (see MEMORY_TUCAN_AI; defect to file).
  • get_clinical_notes is a hallucination. Real tool: list-clinical-notes. If the dispatcher logs tool_unknown for it, the clinical agent's prompt has drifted — re-eval.
  • Abort registration must be synchronous. The controller MUST register the signal in activeStreams synchronously before the fire-and-forget Promise starts. Any new HTTP path that calls processMessage without doing this breaks Stop.
  • First-entry-wins on suspended workflow lookup. The ChatOrchestratorService.resume reads Object.entries(suspendedWorkflows)[0] (chat-orchestrator.service.ts:197). Multi-suspend is not supported today.
  • agent:resolved SSE 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 emits fail({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).
  • [[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 DispatcherStage actually invokes.
  • [[ai-medical-tools]] — produces the ToolResult<O> envelopes the engine reduces.
  • [[ai-chat-sessions]] — owns session.metadata.suspendedWorkflows.
  • [[tucan-context-builder]] — supplies the context the dispatcher passes to the agent.
  • [[sse-event-engine]] (W1) — receives agent:start / agent:resolved / agent:complete / workflow:suspended events the dispatcher emits.
  • packages/ddx-sse-contract/ (@ddx/sse-contract, 4.1.0) — canonical SSE contract (link, do not duplicate).
  • MEMORY_TUCAN_AI shard — dispatch pipeline historical notes.
    TUCAN Dispatcher — Persisted-State Agent Routing — Dudoxx Docs | Dudoxx