import { voice } from '@livekit/agents'; import type { JobContext, VAD } from '@livekit/agents'; import type { Agent as MastraAgent } from '@mastra/core/agent'; import type { Mastra } from '@mastra/core/mastra'; import type { MastraMemory } from '@mastra/core/memory'; import { RequestContext } from '@mastra/core/request-context'; import type { Workflow } from '@mastra/core/workflows'; import type { MastraVoiceAgent, MastraVoiceAgentMemory, VoiceReplyGenerator, VoiceToolCall, VoiceTurnCompleteHook, VoiceTurnContext } from './bridge.js'; import type { LiveKitSessionMetadata } from './metadata.js'; import type { VoiceSpeechCompleteHook, VoiceTurnMetricsHook } from './turn-metrics.js'; interface WorkerUserData { vad?: VAD; [key: string]: unknown; } type TurnDetectionSetting = Exclude; export interface ResolveMastraAgentArgs { metadata: LiveKitSessionMetadata; ctx: JobContext; } export interface SessionStartArgs { session: voice.AgentSession; ctx: JobContext; agent: MastraVoiceAgent; metadata: LiveKitSessionMetadata; } /** Context handed to {@link CreateLiveKitWorkerOptions.onCallEnd} when the call ends. */ export interface VoiceCallEndArgs { /** Resolved memory mapping for the call (thread/resource), or `false` if memory was disabled. */ memory: MastraVoiceAgentMemory | false; /** * The `Memory` instance backing the call, or `null`. On the agent path it's the agent's * (storage-equipped) memory; on the workflow/custom path it's the resolved `memoryInstance`. Use * it for end-of-call maintenance — e.g. flush observational memory off the audio path. */ memoryInstance: MastraMemory | null; /** Dispatch metadata for the call. */ metadata: LiveKitSessionMetadata; /** Request context for the call, if any. */ requestContext?: RequestContext; /** * The worker's `configuration` (greeting, consent requirements, …). Read it here to honor policy * at end-of-call — e.g. only flush a call summary to observational memory when * `configuration.consentPolicy.summaryStorage` isn't required, or the caller granted it. */ configuration?: LiveKitWorkerConfiguration; /** The LiveKit room name. */ roomName: string; /** The LiveKit job context, for advanced teardown needs. */ ctx: JobContext; } /** * Fired once when the call ends. Runs off the audio path inside LiveKit's shutdown grace window, * and — unlike the per-turn {@link MastraVoiceAgentOptions.onTurnComplete} — it is AWAITED, so the * work completes before the worker process exits. A thrown error is logged, not propagated. */ export type VoiceCallEndHook = (args: VoiceCallEndArgs) => void | Promise; export interface CreateLiveKitWorkerOptions { /** Generation and server-playout metrics; observer errors never interrupt the call. */ onTurnMetrics?: VoiceTurnMetricsHook; /** Playback completion, separate from onTurnComplete. Does not reconcile memory. */ onSpeechComplete?: VoiceSpeechCompleteHook; /** The Mastra instance whose agents handle voice sessions. */ mastra: Mastra; /** * Which Mastra agent answers each session: a fixed agent key/id, or a resolver called * per session with the dispatch metadata. Defaults to `metadata.agentId`. */ agent?: string | ((args: ResolveMastraAgentArgs) => string | MastraAgent | Promise); /** * Generate each turn's reply with a Mastra workflow instead of an agent: a `Workflow` * instance, a fixed workflow key/id, or a resolver that returns a workflow id per session. * The workflow runs once to completion per turn (LiveKit owns the turn boundary, so there is * no suspend/resume). Mutually exclusive with `agent`; requires * {@link CreateLiveKitWorkerOptions.workflowInput}. */ workflow?: string | Workflow | ((args: ResolveMastraAgentArgs) => string | Promise); /** * Maps a turn into the workflow's `inputData`. Required when `workflow` is set. A stateless * mapping that passes the full transcript each turn avoids carrying conversation state in the * workflow, e.g. `({ chatCtx }) => ({ history: chatContextToMessages(chatCtx) })`. */ workflowInput?: (args: VoiceTurnContext & { metadata: LiveKitSessionMetadata; }) => unknown | Promise; /** Only stream text from this workflow step id. Defaults to every step that writes to its `writer`. */ replyStep?: string; /** Fallback when the workflow streams no text via `writer`: derive the spoken reply from the final result. */ resultText?: (result: unknown) => string | undefined | void; /** Lowest-level escape hatch: supply any reply generator directly (a custom workflow, remote bridge, …). */ generate?: VoiceReplyGenerator; /** * Speech-to-text: a LiveKit plugin instance or an inference model string like 'deepgram/nova-3'. * For per-call / per-tenant selection, set the `configuration.stt` resolver — it takes * precedence, with this option as the fallback. */ stt?: voice.AgentSessionOptions['stt']; /** * Text-to-speech: a LiveKit plugin instance or an inference model string like 'cartesia/sonic-3'. * For per-call / per-tenant selection, set the `configuration.tts` resolver — it takes * precedence, with this option as the fallback. */ tts?: voice.AgentSessionOptions['tts']; /** * Voice activity detection. `'silero'` (default) loads the Silero VAD from * `@livekit/agents-plugin-silero`; pass an instance to bring your own, or `false` to disable. */ vad?: VAD | 'silero' | false; /** * End-of-turn detection. `'multilingual'` or `'english'` load LiveKit's semantic turn * detector model from `@livekit/agents-plugin-livekit`; other values are passed through * (e.g. `'vad'`, `'stt'`, `'manual'`). To construct a `TurnDetector` instance per call (they * need the LiveKit job context), use `configuration.turnDetection`; this option is its fallback. */ turnDetection?: 'multilingual' | 'english' | TurnDetectionSetting; /** Turn handling tuning (endpointing delays, interruption sensitivity, preemptive generation). */ turnHandling?: voice.AgentSessionOptions['turnHandling']; /** Extra `AgentSession` options merged over what this helper builds. */ sessionOptions?: Partial; /** * Memory mapping. Defaults to `{ thread: metadata.threadId ?? room name, resource: * metadata.resourceId ?? thread }` when the resolved agent has memory configured. * Pass `false` to disable, or a function to customize. */ memory?: false | ((args: ResolveMastraAgentArgs & { roomName: string; }) => MastraVoiceAgentMemory | false); /** * The `Memory` instance used to bootstrap the call thread and persist the greeting on the * workflow / custom-generator path, where there is no agent to source it from. Ignored on the * agent path (the resolved agent's own memory is used). Pass the same `Memory` your workflow * steps read and write through, so the saved thread is one faithful transcript (the up-front * thread + the persisted greeting + every turn the steps write). If the `Memory` has no * `storage` of its own, this worker's Mastra storage is injected into it (same as * `agent.getMemory()` does), so a `Memory` that relies on the Mastra instance for storage works. */ memoryInstance?: MastraMemory | ((args: ResolveMastraAgentArgs) => MastraMemory | undefined | Promise); /** * Spoken while a Mastra tool call runs. Works on both the agent and workflow paths; on the * workflow path it fires only for tool calls the reply step surfaces to its `writer` (use * `pipeAgentReplyToWriter`). See {@link MastraVoiceAgentOptions.toolFeedback}. */ toolFeedback?: (toolCall: VoiceToolCall) => string | undefined | void; /** * Fired once per turn after the reply has streamed to text-to-speech, off the audio path and * fire-and-forget. Works on both the agent and workflow paths. Use it to fully background * post-turn memory maintenance — e.g. a non-blocking `memory.updateWorkingMemory(...)` — so it * never adds to the caller's latency. See {@link MastraVoiceAgentOptions.onTurnComplete}. */ onTurnComplete?: VoiceTurnCompleteHook; /** * Fired once when the call ends (participant disconnects / job shuts down), via LiveKit's * shutdown callback — entirely off the audio path, when latency no longer matters. Unlike * `onTurnComplete`, it is AWAITED within LiveKit's shutdown grace window, so end-of-call work * completes before the process exits. The ideal place to flush observational memory once for the * whole call (instead of paying for it inline per turn). Keep it to a few seconds so it fits the * grace window; a thrown error is logged, not propagated. See {@link VoiceCallEndArgs}. */ onCallEnd?: VoiceCallEndHook; /** * Grouped conversation & compliance configuration. Related policy knobs live here — rather than * as many top-level worker options — so this call stays flat as more are added. Today it carries * the greeting / AI-disclosure; it's the intended home for further compliance controls (recording * notice, periodic re-disclosure, data retention, human handoff, …). See * {@link LiveKitWorkerConfiguration}. */ configuration?: LiveKitWorkerConfiguration; /** * Static greeting spoken when the session starts. * * @deprecated Prefer `configuration.greeting.text`. Still honored for backwards compatibility; * when `configuration.greeting` is also set, its fields take precedence. */ greeting?: string; /** * Save the spoken greeting to the memory thread as an assistant message. * * @deprecated Prefer `configuration.greeting.persist`. Still honored for backwards compatibility. */ persistGreeting?: boolean; /** * Voice-pipeline observability. When the Mastra instance has observability configured, each * session opens a `voice call` trace: LiveKit's STT, TTS, end-of-utterance, VAD, and LLM * latency metrics become child spans, and every turn's Mastra agent run nests under the call, * which closes with a token/audio usage roll-up. Defaults to `true`; pass `false` to disable. */ observability?: boolean; inputOptions?: Parameters[0]['inputOptions']; outputOptions?: Parameters[0]['outputOptions']; /** Called after the session starts — attach event listeners, trigger replies, etc. */ onSessionStart?: (args: SessionStartArgs) => void | Promise; } export declare function resolveMemoryInstance(options: Pick, mastraAgent: MastraAgent | undefined, args: ResolveMastraAgentArgs, requestContext: RequestContext | undefined): Promise; export declare function buildTurnHandling(options: Pick, turnDetection: TurnDetectionSetting | undefined): voice.AgentSessionOptions['turnHandling']; /** * Per-call context handed to the `configuration` resolvers (greeting text, STT, TTS) so they can * key per-call / per-tenant behavior off the call. Built post-connect, so the dispatch metadata, * request context, and room name are all available. */ export interface VoiceCallContext { /** * Dispatch metadata for the call — the per-tenant routing signal (dialed number, tenant/agent id, * thread/resource, and any `requestContext` entries) parsed from the LiveKit job metadata. */ metadata: LiveKitSessionMetadata; /** The request context built from the dispatch metadata, if any. */ requestContext?: RequestContext; /** The LiveKit room name. */ roomName: string; /** The LiveKit job context, for anything the metadata doesn't carry. */ ctx: JobContext; } /** Context handed to a greeting resolver. Alias of {@link VoiceCallContext}. */ export type GreetingContext = VoiceCallContext; /** * A per-call resolver for a session component (STT / TTS): invoked once per call (post-connect) * with the call {@link VoiceCallContext}. Return `undefined` to fall back to the static top-level * option for that component. */ export type SessionComponentResolver = (context: VoiceCallContext) => T | undefined | void | Promise; /** * The spoken greeting: a fixed string, or a resolver invoked once per call (post-connect) with the * call {@link GreetingContext}. Use the resolver form for a per-tenant greeting derived from the * dispatch metadata / request context — one multi-tenant agent, a different opening per tenant. * Return `undefined` (or empty) for no greeting on that call. */ export type GreetingText = string | ((context: GreetingContext) => string | undefined | void | Promise); /** * The opening greeting spoken when the session starts — and the vehicle for a required AI * disclosure. Under the EU AI Act (Art. 50) a person interacting with an AI system must be told so * at the first interaction; set `allowInterruptions: false` so that disclosure can't be talked over. */ export interface GreetingConfiguration { /** * What is spoken when the session starts. A fixed string, or a resolver invoked once per call * (post-connect) with the call {@link GreetingContext} — return a per-tenant greeting built from * the dispatch metadata / request context (e.g. `` `Thanks for calling ${tenant}, you're speaking * with an AI assistant` ``). Omit, or return `undefined`, for no greeting. */ text?: GreetingText; /** * Whether the caller can interrupt (barge over) the greeting. Defaults to LiveKit's behavior * (interruptible). Set `false` to make the greeting play through — e.g. when it carries a * disclosure the caller is legally required to hear ("you're speaking with an AI assistant"), * so they can't talk over it and miss it. */ allowInterruptions?: boolean; /** * Wait for the greeting to finish playing before continuing (greeting persistence and * `onSessionStart`). Defaults to `false`. Pair with `allowInterruptions: false` when a * disclosure must fully play out before any post-greeting work runs. */ awaitPlayout?: boolean; /** * Save the spoken greeting to the memory thread as an assistant message, so the saved thread is * a faithful call transcript. Defaults to `true`; only applies when memory is enabled. */ persist?: boolean; /** * Periodically re-disclose the AI status on long calls (e.g. California SB 243). Once this many * milliseconds have elapsed since the last disclosure, the next turn's reply is prefixed with a * short reminder — spoken at the turn boundary, never mid-turn. Omit (or `0`) to disable. Example: * `repeatEvery: 3 * 60_000` re-discloses roughly every three minutes. * * CAVEAT: don't combine with `turnHandling.preemptiveGeneration`. The reminder clock resets when * the reminder is threaded into a reply stream; a speculative reply that gets discarded still * consumes the interval, so the next real turn can miss its re-disclosure. (Preemptive * generation is off by default and already incompatible with memory on this path.) */ repeatEvery?: number; /** The short reminder spoken on each re-disclosure. Defaults to a generic AI-assistant reminder. */ repeatText?: string; } /** * One consent item's requirement. `true` is shorthand for a required consent with defaults; the * object form carries the metadata a production consent record needs (whether it's required, and a * human-readable purpose for the spoken/logged prompt and the audit trail). */ export type ConsentRequirement = boolean | { /** Whether the caller's consent is required for this item. Defaults to `true` when present. */ required?: boolean; /** * Human-readable description of what is being consented to — for the spoken/logged consent * prompt and the stored consent record. E.g. "storing a summary of this call". */ purpose?: string; }; /** * The call's consent policy, as a **named, extensible set** — each item independently required * and independently granted at runtime, rather than one global "consented" flag. New consent * items are added as named keys so the model grows without reshaping existing ones or conflating * unrelated permissions (a production consent model, not one-size-fits-all). Declaring a policy * enforces nothing by itself: capture the caller's grant at runtime with `createConsentTool`, and * enforce it in your own code (at `onCallEnd`, or before any consent-gated action). */ export interface ConsentConfiguration { /** * Consent to store a summary of the call (observational-memory distillation, CRM notes, etc.). * When required, persist a call summary only if the caller has granted this consent. */ summaryStorage?: ConsentRequirement; } /** * Agent-initiated hang-up: let the agent end the call itself (say goodbye → hang up). Enable it here * and add a matching tool to the agent with `createEndCallTool` (both default to the tool name * `'endCall'`). The tool only signals intent — from inside `agent.stream()` it can't reach the room; * the worker owns the hang-up. On each turn the worker watches for the tool, waits for the agent's * closing words to finish playing (so the goodbye is never cut off), then disconnects — running * `onCallEnd` on the way out, exactly as a caller-initiated hang-up does. Works on the agent and * workflow reply paths. */ export interface EndCallConfiguration { /** * Name of the tool the agent calls to end the call — must match the `id` you gave * `createEndCallTool`. Defaults to `'endCall'`. */ tool?: string; /** * Optional closing line spoken (non-interruptibly) right before hanging up, after the agent's own * final words finish. Use it for a guaranteed sign-off or a compliance closing. Omit to just hang * up once the agent's closing words finish playing. */ message?: string; /** Reason recorded on the shutdown (shows up in LiveKit logs). Defaults to `'agent ended call'`. */ reason?: string; /** * Extra drain time in milliseconds between the closing words finishing and the room being * deleted. LiveKit's playout accounting is worker-local — when it reports the goodbye complete, * the caller's client still holds a few hundred milliseconds of buffered audio (network + * jitter buffer), and deleting the room immediately clips the tail of the goodbye mid-air. * Defaults to {@link DEFAULT_END_CALL_DRAIN_MS}; set `0` to hang up the instant playout ends. */ drainMs?: number; /** * Safety cap in milliseconds on how long to wait for the agent's closing words to finish playing * before hanging up, in case the speaking state never clears. Defaults to `30000`. */ maxWaitMs?: number; } /** * Grouped conversation & compliance configuration for {@link CreateLiveKitWorkerOptions.configuration}. * A single home for related policy knobs so they don't each become a top-level worker option. Today * it carries the greeting / AI-disclosure, consent requirements, agent-initiated hang-up, and * per-call STT/TTS selection; it's the intended landing spot for further compliance controls * (recording notice, data retention, …). */ export interface LiveKitWorkerConfiguration { /** The opening greeting / AI-disclosure, plus optional periodic re-disclosure. See {@link GreetingConfiguration}. */ greeting?: GreetingConfiguration; /** * The call's consent policy. **Declarative only — the worker enforces nothing by itself.** * Capture grants at runtime with `createConsentTool`; enforce them in your own code. See * {@link ConsentConfiguration}. */ consentPolicy?: ConsentConfiguration; /** Let the agent end the call itself (say goodbye → hang up). See {@link EndCallConfiguration}. */ endCall?: EndCallConfiguration; /** * Per-call speech-to-text: a resolver invoked once per call (post-connect) with the call * {@link VoiceCallContext}, returning anything the top-level `stt` option accepts (a plugin * instance or an inference model string like `'deepgram/nova-3'`). Use it to pick the * transcriber per tenant or language off the dispatch metadata / request context. Return * `undefined` to fall back to the top-level `stt`. Runs during call setup, so keep it fast — * when it constructs plugin instances, cache them across calls (e.g. a `Map` keyed by tenant). */ stt?: SessionComponentResolver; /** * Per-call text-to-speech: a resolver invoked once per call (post-connect) with the call * {@link VoiceCallContext}, returning anything the top-level `tts` option accepts (a plugin * instance or an inference model string like `'cartesia/sonic-3'`). Use it to give each tenant * its own voice / language. Return `undefined` to fall back to the top-level `tts`. Runs during * call setup, so keep it fast — when it constructs plugin instances, cache them across calls. */ tts?: SessionComponentResolver; /** * Per-call end-of-turn detection: a resolver invoked once per call (post-connect, inside the * LiveKit job) with the call {@link VoiceCallContext}, returning anything the top-level * `turnDetection` option accepts. LiveKit's `TurnDetector` instances read the job's inference * executor from their constructor, so they can only be built here — not at module scope. Return * `undefined` to fall back to the top-level `turnDetection`. * * The semantic turn detector's inference runners must register before the agent server boots, * so when this resolver is set the worker eagerly imports `@livekit/agents-plugin-livekit` * (if installed). Set the top-level `turnDetection` to `'multilingual'` or `'english'` to * pre-register only that model; otherwise both are kept available. */ turnDetection?: SessionComponentResolver; } /** * Resolves the effective greeting from the canonical `configuration.greeting` and the deprecated * top-level `greeting` / `persistGreeting` options. The legacy options are the base so existing * worker configs keep working unchanged; `configuration.greeting` overrides field-by-field. Exported * for unit testing. */ export declare function resolveGreetingConfig(options: Pick): GreetingConfiguration; /** * Resolves the greeting text for a call: returns a fixed string as-is, or invokes the resolver form * with the call context to produce a per-tenant greeting. Empty / whitespace-nothing results * normalize to `undefined` (no greeting). Exported for unit testing. */ export declare function resolveGreetingText(text: GreetingText | undefined, context: GreetingContext): Promise; /** * Resolves a per-call session component (STT / TTS): invokes the `configuration` resolver with the * call context, falling back to the static top-level option when there is no resolver or it * resolves to `undefined`. Exported for unit testing. */ export declare function resolveSessionComponent(resolver: SessionComponentResolver | undefined, fallback: T | undefined, context: VoiceCallContext): Promise; /** * Resolves this call's end-of-turn detection: the `configuration.turnDetection` resolver first, * then the static `turnDetection` option (loading the LiveKit semantic model for the * `'multilingual'` / `'english'` shorthands). Must run inside the job — LiveKit's turn detectors * read the job's inference executor when constructed. Exported for unit testing. */ export declare function resolveTurnDetection(options: Pick, context: VoiceCallContext): Promise; /** A {@link GreetingConfiguration} whose `text` resolver has been resolved to a plain string. */ type ResolvedGreetingConfiguration = Omit & { text?: string; }; /** * Speaks the session's opening greeting, honoring the interruption / playout options. Takes the * already-resolved greeting (see {@link resolveGreetingText}) and returns the LiveKit `SpeechHandle`, * or `undefined` when there is no greeting text. Extracted and exported so the greeting behavior is * unit-testable without a live room. */ export declare function speakGreeting(session: Pick, greeting: ResolvedGreetingConfiguration): Promise | undefined>; /** Default tool name the worker watches for to end the call. Matches `createEndCallTool`'s default. */ export declare const DEFAULT_END_CALL_TOOL = "endCall"; /** Default shutdown reason recorded when the agent ends the call. */ export declare const DEFAULT_END_CALL_REASON = "agent ended call"; /** Default safety cap on waiting for the agent's closing words to finish before hanging up. */ export declare const DEFAULT_END_CALL_MAX_WAIT_MS = 30000; /** * Default post-playout drain before the room is deleted. LiveKit's "playout completed" is * worker-local; the caller's client still holds network + jitter-buffer audio, so hanging up the * instant the state clears clips the tail of the goodbye. */ export declare const DEFAULT_END_CALL_DRAIN_MS = 800; /** The minimal logger surface these helpers use (the Mastra logger satisfies it). */ type WarnLogger = { warn: (message: string, ...args: unknown[]) => void; }; /** * Resolves once the agent is no longer producing or playing a reply — i.e. its state has left * `thinking`/`speaking` for `listening`/`idle`. Returns immediately when it's already idle. A * `maxWaitMs` safety cap guarantees it resolves even if the speaking state never clears. Used before * an agent-initiated hang-up so the closing words play out fully instead of being cut off by the * session close. Exported for unit testing. */ export declare function waitForAgentDoneSpeaking(session: Pick, maxWaitMs?: number): Promise; /** * Ends the call after the agent asked to (via its end-call tool): wait for the agent's closing words * to finish, speak an optional final `message`, hold a short drain (`drainMs`) so audio buffered at * the caller finishes playing, then disconnect. The teardown's session close * force-interrupts any playing speech, so the waits here MUST complete before we disconnect — that's * the whole point of the sequence. `ctx.deleteRoom()` hangs up the caller (SIP-safe); `ctx.shutdown()` * ends the job and runs the registered shutdown callbacks (`onCallEnd`), so end-of-call work happens * exactly as it does on a caller hang-up. Both run in a `finally` so a hiccup while waiting still ends * the call. Never rejects — every step is guarded and failures are logged, so callers can safely * fire-and-forget it (`void runEndCall(...)`) from hooks. Exported for unit testing. */ export declare function runEndCall(session: Pick, ctx: Pick, config: EndCallConfiguration, logger: WarnLogger): Promise; /** * Builds a LiveKit agent worker definition that answers voice sessions with Mastra agents. * * Use as the default export of your worker entry file, then run it with the LiveKit * agents CLI: * * ```ts * // src/mastra/voice-worker.ts * import { fileURLToPath } from 'node:url'; * import { createLiveKitWorker, runLiveKitWorker } from '@mastra/livekit/worker'; * import { mastra } from './index.js'; * * export default createLiveKitWorker({ * mastra, * stt: 'deepgram/nova-3', * tts: 'cartesia/sonic-3', * turnDetection: 'multilingual', * }); * * if (process.argv[1] === fileURLToPath(import.meta.url)) { * runLiveKitWorker({ entry: import.meta.url, agentName: 'mastra-voice' }); * } * ``` */ export declare function createLiveKitWorker(options: CreateLiveKitWorkerOptions): import("@livekit/agents").AgentDefinition; export {}; //# sourceMappingURL=worker.d.ts.map