@mastra/livekit 0.3.0 → 0.3.1-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-generator-B67QrY8L.cjs","names":["ReadableStream"],"sources":["../src/constants.ts","../src/metadata.ts","../src/workflow-generator.ts"],"sourcesContent":["/** Default LiveKit agent name used for explicit dispatch when none is configured. */\nexport const DEFAULT_LIVEKIT_AGENT_NAME = 'mastra-voice';\n","/**\n * Session metadata passed from the Mastra server to the LiveKit agent worker through\n * LiveKit's job dispatch metadata (a plain string, so this is JSON-serialized).\n */\nexport interface LiveKitSessionMetadata {\n /** Mastra agent to run, by registered key or agent id. */\n agentId?: string;\n /** Memory thread id. Defaults to the LiveKit room name when omitted. */\n threadId?: string;\n /** Memory resource id (typically the end user id). */\n resourceId?: string;\n /** Plain-object entries restored into a RequestContext for agent execution. */\n requestContext?: Record<string, unknown>;\n}\n\nexport function parseSessionMetadata(raw: string | undefined | null): LiveKitSessionMetadata {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as LiveKitSessionMetadata;\n }\n } catch {\n // Dispatch metadata is user-controlled and may not be JSON; treat as absent.\n }\n return {};\n}\n\nexport function serializeSessionMetadata(metadata: LiveKitSessionMetadata): string {\n return JSON.stringify(metadata);\n}\n","import { ReadableStream } from 'node:stream/web';\nimport type { WritableStream } from 'node:stream/web';\nimport type { TracingContext } from '@mastra/core/observability';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { Workflow } from '@mastra/core/workflows';\nimport type {\n VoiceReplyGenerator,\n VoiceToolCall,\n VoiceTurnCompleteContext,\n VoiceTurnCompleteHook,\n VoiceTurnContext,\n} from './bridge';\n\nexport interface WorkflowReplyGeneratorOptions {\n /** The Mastra workflow that generates each turn's reply. Runs once per turn (no suspend/resume). */\n workflow: Workflow;\n /**\n * Maps a turn into the workflow's `inputData`. Required — input schemas are caller-defined.\n * A common shape passes the full transcript so the workflow is stateless between turns, e.g.\n * `ctx => ({ history: chatContextToMessages(ctx.chatCtx) })`.\n */\n workflowInput: (ctx: VoiceTurnContext) => unknown | Promise<unknown>;\n /**\n * Only stream text from this step (by id). Defaults to every step that writes text to its\n * `writer`. Set when multiple steps write and only one produces the spoken reply.\n */\n replyStep?: string;\n /**\n * Fallback when the workflow streams no text via `writer`: derive the spoken reply from the\n * final run result. Without this, a non-streaming workflow stays silent. Streaming via the\n * step `writer` is preferred — it gives the caller low time-to-first-token.\n */\n resultText?: (result: unknown) => string | undefined | void;\n /**\n * Speak a short phrase while a tool call runs. Fires only for tool calls the reply step surfaces\n * to its `writer` — use {@link pipeAgentReplyToWriter} (or pipe the agent's `fullStream`) so\n * `tool-call` chunks reach the stream. See {@link MastraVoiceAgentOptions.toolFeedback}.\n */\n toolFeedback?: (toolCall: VoiceToolCall) => string | undefined | void;\n /**\n * Fired off the audio path after the reply streams, fire-and-forget. Carries the produced reply\n * text and any tool calls the workflow surfaced. See {@link MastraVoiceAgentOptions.onTurnComplete}.\n */\n onTurnComplete?: VoiceTurnCompleteHook;\n}\n\n/**\n * Unwraps the text carried by a `workflow-step-output` chunk's `payload.output`. A step that\n * pipes `agent.stream().textStream` into its `writer` produces raw strings; a step built from an\n * agent (`createStep(agent)`) produces full `text-delta` chunks. Returns the text for both, or\n * `undefined` for any other shape.\n */\nexport function unwrapStepText(output: unknown): string | undefined {\n if (typeof output === 'string') return output;\n if (output && typeof output === 'object' && (output as { type?: unknown }).type === 'text-delta') {\n const text = (output as { payload?: { text?: unknown } }).payload?.text;\n return typeof text === 'string' ? text : undefined;\n }\n return undefined;\n}\n\n/**\n * Unwraps a tool call carried by a `workflow-step-output` chunk's `payload.output`. A step that\n * pipes the agent's `fullStream` (rather than just `textStream`) into its `writer` surfaces\n * `tool-call` chunks; this returns the {@link VoiceToolCall} for those, or `undefined` otherwise.\n */\nexport function unwrapStepToolCall(output: unknown): VoiceToolCall | undefined {\n if (!output || typeof output !== 'object' || (output as { type?: unknown }).type !== 'tool-call') return undefined;\n const payload = (output as { payload?: { toolCallId?: unknown; toolName?: unknown; args?: unknown } }).payload;\n if (payload && typeof payload.toolCallId === 'string' && typeof payload.toolName === 'string') {\n return { toolCallId: payload.toolCallId, toolName: payload.toolName, args: payload.args };\n }\n return undefined;\n}\n\n/** The minimal shape of a Mastra agent stream consumed by {@link pipeAgentReplyToWriter}. */\nexport interface AgentReplyStreamLike {\n fullStream: AsyncIterable<unknown>;\n}\n\n/**\n * Streams a Mastra agent's reply into a workflow step's `writer` for the LiveKit workflow\n * entrypoint — the recommended way to drive a turn's reply from a step.\n *\n * It forwards the agent's text deltas (so text-to-speech starts before the full reply is ready)\n * AND its `tool-call` chunks (so {@link WorkflowReplyGeneratorOptions.toolFeedback} fires and\n * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` is populated). This is\n * the difference from piping only `agent.stream().textStream`, which silently drops tool calls.\n * Other chunk types (reasoning, tool results, lifecycle) are not forwarded, keeping the spoken\n * stream clean. Returns the accumulated reply text for the step to return.\n *\n * Pass the step's `abortSignal` to `agent.stream(...)` so barge-in stops generation promptly.\n *\n * ```ts\n * const generateResponse = createStep({\n * // ...\n * execute: async ({ inputData, mastra, writer, abortSignal }) => {\n * const stream = await mastra.getAgent('callCenter').stream(inputData.turn, { abortSignal });\n * const reply = await pipeAgentReplyToWriter(stream, writer);\n * return { reply };\n * },\n * });\n * ```\n */\nexport function pipeAgentReplyToWriter(\n agentStream: AgentReplyStreamLike,\n writer: WritableStream<unknown>,\n): Promise<string> {\n let text = '';\n // Re-emit only the chunks the voice path cares about, then pipe through the step writer — which\n // reuses pipeTo's proven backpressure + close handling rather than driving the writer by hand.\n const forwarded = new ReadableStream<unknown>({\n start: async controller => {\n for await (const chunk of agentStream.fullStream) {\n const type = (chunk as { type?: unknown })?.type;\n if (type === 'text-delta') {\n const delta = (chunk as { payload?: { text?: unknown } }).payload?.text;\n if (typeof delta === 'string' && delta) {\n text += delta;\n controller.enqueue(chunk);\n }\n } else if (type === 'tool-call') {\n controller.enqueue(chunk);\n }\n }\n controller.close();\n },\n });\n return forwarded.pipeTo(writer).then(() => text);\n}\n\n/**\n * A {@link VoiceReplyGenerator} backed by a Mastra workflow. Per turn it starts a fresh run to\n * completion (LiveKit owns the turn boundary, so there is no suspend/resume and no conversation\n * state carried between turns) and streams the text its steps write to their `writer`.\n *\n * A workflow's own stream emits structured step events, not token deltas — text only surfaces\n * when a step pipes it into the injected `writer`, arriving as `workflow-step-output` chunks. The\n * simplest correct reply step calls {@link pipeAgentReplyToWriter}, which forwards both text and\n * tool calls (and passes the step's `abortSignal` through `agent.stream` so barge-in stops\n * generation promptly). Piping only `agent.stream(...).textStream.pipeTo(writer)` works for text\n * but silently drops tool calls, so {@link WorkflowReplyGeneratorOptions.toolFeedback} and\n * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` stay empty.\n */\nexport function createWorkflowReplyGenerator(options: WorkflowReplyGeneratorOptions): VoiceReplyGenerator {\n const { workflow, workflowInput, replyStep, resultText, toolFeedback, onTurnComplete } = options;\n return async ctx => {\n const inputData = await workflowInput(ctx);\n const run = await workflow.createRun();\n\n const streamArgs: { inputData: unknown; tracingContext?: TracingContext; requestContext?: RequestContext } = {\n inputData,\n };\n if (ctx.tracingContext) streamArgs.tracingContext = ctx.tracingContext;\n // Forward the per-session request context so workflow steps see it, mirroring the agent path.\n if (ctx.requestContext) streamArgs.requestContext = ctx.requestContext;\n const output = run.stream(streamArgs);\n\n let cancelled = false;\n // Accumulated as the turn streams so the post-turn hook can see what was actually produced.\n let replyText = '';\n const toolCalls: VoiceToolCall[] = [];\n\n // Fire-and-forget after the reply has streamed: off the audio path and not awaited, so it\n // never delays the next turn. Errors are logged, not thrown. Mirrors createAgentReplyGenerator.\n const emitTurnComplete = (interrupted: boolean) => {\n if (!onTurnComplete) return;\n const completeCtx: VoiceTurnCompleteContext = { ...ctx, result: { text: replyText, toolCalls, interrupted } };\n Promise.resolve()\n .then(() => onTurnComplete(completeCtx))\n .catch(error => {\n console.warn('@mastra/livekit: onTurnComplete hook threw', error);\n });\n };\n\n return new ReadableStream<string>({\n start: async controller => {\n let streamedAny = false;\n try {\n for await (const chunk of output.fullStream) {\n if (cancelled) break;\n if (chunk.type !== 'workflow-step-output') continue;\n const payload = chunk.payload as { output?: unknown; stepName?: unknown };\n if (replyStep && payload.stepName !== replyStep) continue;\n const text = unwrapStepText(payload.output);\n if (text) {\n streamedAny = true;\n replyText += text;\n controller.enqueue(text);\n continue;\n }\n // A tool call only surfaces when the step pipes the agent's fullStream; when it does,\n // mirror the agent path — record it and speak any toolFeedback filler.\n const toolCall = unwrapStepToolCall(payload.output);\n if (toolCall) {\n toolCalls.push(toolCall);\n if (toolFeedback) {\n const filler = toolFeedback(toolCall);\n if (filler) controller.enqueue(filler.endsWith(' ') ? filler : `${filler} `);\n }\n }\n }\n if (!cancelled && !streamedAny && resultText) {\n const finalText = resultText(await output.result);\n if (finalText) {\n replyText += finalText;\n controller.enqueue(finalText);\n }\n }\n if (!cancelled) controller.close();\n // Success, or a clean barge-in break out of the loop: the turn is done either way.\n emitTurnComplete(cancelled);\n } catch (error) {\n // Barge-in cancels the run; that's not a failure — the turn still completed\n // (interrupted), so the hook still fires for memory reconciliation.\n if (cancelled) {\n emitTurnComplete(true);\n return;\n }\n controller.error(error);\n }\n },\n cancel: () => {\n cancelled = true;\n // Barge-in fires this synchronously; swallow any rejection from the run cancellation so a\n // failed cancel can't surface as an unhandled promise rejection.\n void Promise.resolve(run.cancel()).catch(error => {\n console.warn('@mastra/livekit: failed to cancel the workflow run on barge-in', error);\n });\n },\n });\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,180 @@
1
+ import { ReadableStream } from "stream/web";
2
+ //#region src/constants.ts
3
+ /** Default LiveKit agent name used for explicit dispatch when none is configured. */
4
+ const DEFAULT_LIVEKIT_AGENT_NAME = "mastra-voice";
5
+ //#endregion
6
+ //#region src/metadata.ts
7
+ function parseSessionMetadata(raw) {
8
+ if (!raw) return {};
9
+ try {
10
+ const parsed = JSON.parse(raw);
11
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
12
+ } catch {}
13
+ return {};
14
+ }
15
+ function serializeSessionMetadata(metadata) {
16
+ return JSON.stringify(metadata);
17
+ }
18
+ //#endregion
19
+ //#region src/workflow-generator.ts
20
+ /**
21
+ * Unwraps the text carried by a `workflow-step-output` chunk's `payload.output`. A step that
22
+ * pipes `agent.stream().textStream` into its `writer` produces raw strings; a step built from an
23
+ * agent (`createStep(agent)`) produces full `text-delta` chunks. Returns the text for both, or
24
+ * `undefined` for any other shape.
25
+ */
26
+ function unwrapStepText(output) {
27
+ if (typeof output === "string") return output;
28
+ if (output && typeof output === "object" && output.type === "text-delta") {
29
+ const text = output.payload?.text;
30
+ return typeof text === "string" ? text : void 0;
31
+ }
32
+ }
33
+ /**
34
+ * Unwraps a tool call carried by a `workflow-step-output` chunk's `payload.output`. A step that
35
+ * pipes the agent's `fullStream` (rather than just `textStream`) into its `writer` surfaces
36
+ * `tool-call` chunks; this returns the {@link VoiceToolCall} for those, or `undefined` otherwise.
37
+ */
38
+ function unwrapStepToolCall(output) {
39
+ if (!output || typeof output !== "object" || output.type !== "tool-call") return void 0;
40
+ const payload = output.payload;
41
+ if (payload && typeof payload.toolCallId === "string" && typeof payload.toolName === "string") return {
42
+ toolCallId: payload.toolCallId,
43
+ toolName: payload.toolName,
44
+ args: payload.args
45
+ };
46
+ }
47
+ /**
48
+ * Streams a Mastra agent's reply into a workflow step's `writer` for the LiveKit workflow
49
+ * entrypoint — the recommended way to drive a turn's reply from a step.
50
+ *
51
+ * It forwards the agent's text deltas (so text-to-speech starts before the full reply is ready)
52
+ * AND its `tool-call` chunks (so {@link WorkflowReplyGeneratorOptions.toolFeedback} fires and
53
+ * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` is populated). This is
54
+ * the difference from piping only `agent.stream().textStream`, which silently drops tool calls.
55
+ * Other chunk types (reasoning, tool results, lifecycle) are not forwarded, keeping the spoken
56
+ * stream clean. Returns the accumulated reply text for the step to return.
57
+ *
58
+ * Pass the step's `abortSignal` to `agent.stream(...)` so barge-in stops generation promptly.
59
+ *
60
+ * ```ts
61
+ * const generateResponse = createStep({
62
+ * // ...
63
+ * execute: async ({ inputData, mastra, writer, abortSignal }) => {
64
+ * const stream = await mastra.getAgent('callCenter').stream(inputData.turn, { abortSignal });
65
+ * const reply = await pipeAgentReplyToWriter(stream, writer);
66
+ * return { reply };
67
+ * },
68
+ * });
69
+ * ```
70
+ */
71
+ function pipeAgentReplyToWriter(agentStream, writer) {
72
+ let text = "";
73
+ return new ReadableStream({ start: async (controller) => {
74
+ for await (const chunk of agentStream.fullStream) {
75
+ const type = chunk?.type;
76
+ if (type === "text-delta") {
77
+ const delta = chunk.payload?.text;
78
+ if (typeof delta === "string" && delta) {
79
+ text += delta;
80
+ controller.enqueue(chunk);
81
+ }
82
+ } else if (type === "tool-call") controller.enqueue(chunk);
83
+ }
84
+ controller.close();
85
+ } }).pipeTo(writer).then(() => text);
86
+ }
87
+ /**
88
+ * A {@link VoiceReplyGenerator} backed by a Mastra workflow. Per turn it starts a fresh run to
89
+ * completion (LiveKit owns the turn boundary, so there is no suspend/resume and no conversation
90
+ * state carried between turns) and streams the text its steps write to their `writer`.
91
+ *
92
+ * A workflow's own stream emits structured step events, not token deltas — text only surfaces
93
+ * when a step pipes it into the injected `writer`, arriving as `workflow-step-output` chunks. The
94
+ * simplest correct reply step calls {@link pipeAgentReplyToWriter}, which forwards both text and
95
+ * tool calls (and passes the step's `abortSignal` through `agent.stream` so barge-in stops
96
+ * generation promptly). Piping only `agent.stream(...).textStream.pipeTo(writer)` works for text
97
+ * but silently drops tool calls, so {@link WorkflowReplyGeneratorOptions.toolFeedback} and
98
+ * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` stay empty.
99
+ */
100
+ function createWorkflowReplyGenerator(options) {
101
+ const { workflow, workflowInput, replyStep, resultText, toolFeedback, onTurnComplete } = options;
102
+ return async (ctx) => {
103
+ const inputData = await workflowInput(ctx);
104
+ const run = await workflow.createRun();
105
+ const streamArgs = { inputData };
106
+ if (ctx.tracingContext) streamArgs.tracingContext = ctx.tracingContext;
107
+ if (ctx.requestContext) streamArgs.requestContext = ctx.requestContext;
108
+ const output = run.stream(streamArgs);
109
+ let cancelled = false;
110
+ let replyText = "";
111
+ const toolCalls = [];
112
+ const emitTurnComplete = (interrupted) => {
113
+ if (!onTurnComplete) return;
114
+ const completeCtx = {
115
+ ...ctx,
116
+ result: {
117
+ text: replyText,
118
+ toolCalls,
119
+ interrupted
120
+ }
121
+ };
122
+ Promise.resolve().then(() => onTurnComplete(completeCtx)).catch((error) => {
123
+ console.warn("@mastra/livekit: onTurnComplete hook threw", error);
124
+ });
125
+ };
126
+ return new ReadableStream({
127
+ start: async (controller) => {
128
+ let streamedAny = false;
129
+ try {
130
+ for await (const chunk of output.fullStream) {
131
+ if (cancelled) break;
132
+ if (chunk.type !== "workflow-step-output") continue;
133
+ const payload = chunk.payload;
134
+ if (replyStep && payload.stepName !== replyStep) continue;
135
+ const text = unwrapStepText(payload.output);
136
+ if (text) {
137
+ streamedAny = true;
138
+ replyText += text;
139
+ controller.enqueue(text);
140
+ continue;
141
+ }
142
+ const toolCall = unwrapStepToolCall(payload.output);
143
+ if (toolCall) {
144
+ toolCalls.push(toolCall);
145
+ if (toolFeedback) {
146
+ const filler = toolFeedback(toolCall);
147
+ if (filler) controller.enqueue(filler.endsWith(" ") ? filler : `${filler} `);
148
+ }
149
+ }
150
+ }
151
+ if (!cancelled && !streamedAny && resultText) {
152
+ const finalText = resultText(await output.result);
153
+ if (finalText) {
154
+ replyText += finalText;
155
+ controller.enqueue(finalText);
156
+ }
157
+ }
158
+ if (!cancelled) controller.close();
159
+ emitTurnComplete(cancelled);
160
+ } catch (error) {
161
+ if (cancelled) {
162
+ emitTurnComplete(true);
163
+ return;
164
+ }
165
+ controller.error(error);
166
+ }
167
+ },
168
+ cancel: () => {
169
+ cancelled = true;
170
+ Promise.resolve(run.cancel()).catch((error) => {
171
+ console.warn("@mastra/livekit: failed to cancel the workflow run on barge-in", error);
172
+ });
173
+ }
174
+ });
175
+ };
176
+ }
177
+ //#endregion
178
+ export { DEFAULT_LIVEKIT_AGENT_NAME as a, serializeSessionMetadata as i, pipeAgentReplyToWriter as n, parseSessionMetadata as r, createWorkflowReplyGenerator as t };
179
+
180
+ //# sourceMappingURL=workflow-generator-BtfClQcM.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workflow-generator-BtfClQcM.js","names":[],"sources":["../src/constants.ts","../src/metadata.ts","../src/workflow-generator.ts"],"sourcesContent":["/** Default LiveKit agent name used for explicit dispatch when none is configured. */\nexport const DEFAULT_LIVEKIT_AGENT_NAME = 'mastra-voice';\n","/**\n * Session metadata passed from the Mastra server to the LiveKit agent worker through\n * LiveKit's job dispatch metadata (a plain string, so this is JSON-serialized).\n */\nexport interface LiveKitSessionMetadata {\n /** Mastra agent to run, by registered key or agent id. */\n agentId?: string;\n /** Memory thread id. Defaults to the LiveKit room name when omitted. */\n threadId?: string;\n /** Memory resource id (typically the end user id). */\n resourceId?: string;\n /** Plain-object entries restored into a RequestContext for agent execution. */\n requestContext?: Record<string, unknown>;\n}\n\nexport function parseSessionMetadata(raw: string | undefined | null): LiveKitSessionMetadata {\n if (!raw) return {};\n try {\n const parsed = JSON.parse(raw);\n if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {\n return parsed as LiveKitSessionMetadata;\n }\n } catch {\n // Dispatch metadata is user-controlled and may not be JSON; treat as absent.\n }\n return {};\n}\n\nexport function serializeSessionMetadata(metadata: LiveKitSessionMetadata): string {\n return JSON.stringify(metadata);\n}\n","import { ReadableStream } from 'node:stream/web';\nimport type { WritableStream } from 'node:stream/web';\nimport type { TracingContext } from '@mastra/core/observability';\nimport type { RequestContext } from '@mastra/core/request-context';\nimport type { Workflow } from '@mastra/core/workflows';\nimport type {\n VoiceReplyGenerator,\n VoiceToolCall,\n VoiceTurnCompleteContext,\n VoiceTurnCompleteHook,\n VoiceTurnContext,\n} from './bridge';\n\nexport interface WorkflowReplyGeneratorOptions {\n /** The Mastra workflow that generates each turn's reply. Runs once per turn (no suspend/resume). */\n workflow: Workflow;\n /**\n * Maps a turn into the workflow's `inputData`. Required — input schemas are caller-defined.\n * A common shape passes the full transcript so the workflow is stateless between turns, e.g.\n * `ctx => ({ history: chatContextToMessages(ctx.chatCtx) })`.\n */\n workflowInput: (ctx: VoiceTurnContext) => unknown | Promise<unknown>;\n /**\n * Only stream text from this step (by id). Defaults to every step that writes text to its\n * `writer`. Set when multiple steps write and only one produces the spoken reply.\n */\n replyStep?: string;\n /**\n * Fallback when the workflow streams no text via `writer`: derive the spoken reply from the\n * final run result. Without this, a non-streaming workflow stays silent. Streaming via the\n * step `writer` is preferred — it gives the caller low time-to-first-token.\n */\n resultText?: (result: unknown) => string | undefined | void;\n /**\n * Speak a short phrase while a tool call runs. Fires only for tool calls the reply step surfaces\n * to its `writer` — use {@link pipeAgentReplyToWriter} (or pipe the agent's `fullStream`) so\n * `tool-call` chunks reach the stream. See {@link MastraVoiceAgentOptions.toolFeedback}.\n */\n toolFeedback?: (toolCall: VoiceToolCall) => string | undefined | void;\n /**\n * Fired off the audio path after the reply streams, fire-and-forget. Carries the produced reply\n * text and any tool calls the workflow surfaced. See {@link MastraVoiceAgentOptions.onTurnComplete}.\n */\n onTurnComplete?: VoiceTurnCompleteHook;\n}\n\n/**\n * Unwraps the text carried by a `workflow-step-output` chunk's `payload.output`. A step that\n * pipes `agent.stream().textStream` into its `writer` produces raw strings; a step built from an\n * agent (`createStep(agent)`) produces full `text-delta` chunks. Returns the text for both, or\n * `undefined` for any other shape.\n */\nexport function unwrapStepText(output: unknown): string | undefined {\n if (typeof output === 'string') return output;\n if (output && typeof output === 'object' && (output as { type?: unknown }).type === 'text-delta') {\n const text = (output as { payload?: { text?: unknown } }).payload?.text;\n return typeof text === 'string' ? text : undefined;\n }\n return undefined;\n}\n\n/**\n * Unwraps a tool call carried by a `workflow-step-output` chunk's `payload.output`. A step that\n * pipes the agent's `fullStream` (rather than just `textStream`) into its `writer` surfaces\n * `tool-call` chunks; this returns the {@link VoiceToolCall} for those, or `undefined` otherwise.\n */\nexport function unwrapStepToolCall(output: unknown): VoiceToolCall | undefined {\n if (!output || typeof output !== 'object' || (output as { type?: unknown }).type !== 'tool-call') return undefined;\n const payload = (output as { payload?: { toolCallId?: unknown; toolName?: unknown; args?: unknown } }).payload;\n if (payload && typeof payload.toolCallId === 'string' && typeof payload.toolName === 'string') {\n return { toolCallId: payload.toolCallId, toolName: payload.toolName, args: payload.args };\n }\n return undefined;\n}\n\n/** The minimal shape of a Mastra agent stream consumed by {@link pipeAgentReplyToWriter}. */\nexport interface AgentReplyStreamLike {\n fullStream: AsyncIterable<unknown>;\n}\n\n/**\n * Streams a Mastra agent's reply into a workflow step's `writer` for the LiveKit workflow\n * entrypoint — the recommended way to drive a turn's reply from a step.\n *\n * It forwards the agent's text deltas (so text-to-speech starts before the full reply is ready)\n * AND its `tool-call` chunks (so {@link WorkflowReplyGeneratorOptions.toolFeedback} fires and\n * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` is populated). This is\n * the difference from piping only `agent.stream().textStream`, which silently drops tool calls.\n * Other chunk types (reasoning, tool results, lifecycle) are not forwarded, keeping the spoken\n * stream clean. Returns the accumulated reply text for the step to return.\n *\n * Pass the step's `abortSignal` to `agent.stream(...)` so barge-in stops generation promptly.\n *\n * ```ts\n * const generateResponse = createStep({\n * // ...\n * execute: async ({ inputData, mastra, writer, abortSignal }) => {\n * const stream = await mastra.getAgent('callCenter').stream(inputData.turn, { abortSignal });\n * const reply = await pipeAgentReplyToWriter(stream, writer);\n * return { reply };\n * },\n * });\n * ```\n */\nexport function pipeAgentReplyToWriter(\n agentStream: AgentReplyStreamLike,\n writer: WritableStream<unknown>,\n): Promise<string> {\n let text = '';\n // Re-emit only the chunks the voice path cares about, then pipe through the step writer — which\n // reuses pipeTo's proven backpressure + close handling rather than driving the writer by hand.\n const forwarded = new ReadableStream<unknown>({\n start: async controller => {\n for await (const chunk of agentStream.fullStream) {\n const type = (chunk as { type?: unknown })?.type;\n if (type === 'text-delta') {\n const delta = (chunk as { payload?: { text?: unknown } }).payload?.text;\n if (typeof delta === 'string' && delta) {\n text += delta;\n controller.enqueue(chunk);\n }\n } else if (type === 'tool-call') {\n controller.enqueue(chunk);\n }\n }\n controller.close();\n },\n });\n return forwarded.pipeTo(writer).then(() => text);\n}\n\n/**\n * A {@link VoiceReplyGenerator} backed by a Mastra workflow. Per turn it starts a fresh run to\n * completion (LiveKit owns the turn boundary, so there is no suspend/resume and no conversation\n * state carried between turns) and streams the text its steps write to their `writer`.\n *\n * A workflow's own stream emits structured step events, not token deltas — text only surfaces\n * when a step pipes it into the injected `writer`, arriving as `workflow-step-output` chunks. The\n * simplest correct reply step calls {@link pipeAgentReplyToWriter}, which forwards both text and\n * tool calls (and passes the step's `abortSignal` through `agent.stream` so barge-in stops\n * generation promptly). Piping only `agent.stream(...).textStream.pipeTo(writer)` works for text\n * but silently drops tool calls, so {@link WorkflowReplyGeneratorOptions.toolFeedback} and\n * {@link WorkflowReplyGeneratorOptions.onTurnComplete}'s `result.toolCalls` stay empty.\n */\nexport function createWorkflowReplyGenerator(options: WorkflowReplyGeneratorOptions): VoiceReplyGenerator {\n const { workflow, workflowInput, replyStep, resultText, toolFeedback, onTurnComplete } = options;\n return async ctx => {\n const inputData = await workflowInput(ctx);\n const run = await workflow.createRun();\n\n const streamArgs: { inputData: unknown; tracingContext?: TracingContext; requestContext?: RequestContext } = {\n inputData,\n };\n if (ctx.tracingContext) streamArgs.tracingContext = ctx.tracingContext;\n // Forward the per-session request context so workflow steps see it, mirroring the agent path.\n if (ctx.requestContext) streamArgs.requestContext = ctx.requestContext;\n const output = run.stream(streamArgs);\n\n let cancelled = false;\n // Accumulated as the turn streams so the post-turn hook can see what was actually produced.\n let replyText = '';\n const toolCalls: VoiceToolCall[] = [];\n\n // Fire-and-forget after the reply has streamed: off the audio path and not awaited, so it\n // never delays the next turn. Errors are logged, not thrown. Mirrors createAgentReplyGenerator.\n const emitTurnComplete = (interrupted: boolean) => {\n if (!onTurnComplete) return;\n const completeCtx: VoiceTurnCompleteContext = { ...ctx, result: { text: replyText, toolCalls, interrupted } };\n Promise.resolve()\n .then(() => onTurnComplete(completeCtx))\n .catch(error => {\n console.warn('@mastra/livekit: onTurnComplete hook threw', error);\n });\n };\n\n return new ReadableStream<string>({\n start: async controller => {\n let streamedAny = false;\n try {\n for await (const chunk of output.fullStream) {\n if (cancelled) break;\n if (chunk.type !== 'workflow-step-output') continue;\n const payload = chunk.payload as { output?: unknown; stepName?: unknown };\n if (replyStep && payload.stepName !== replyStep) continue;\n const text = unwrapStepText(payload.output);\n if (text) {\n streamedAny = true;\n replyText += text;\n controller.enqueue(text);\n continue;\n }\n // A tool call only surfaces when the step pipes the agent's fullStream; when it does,\n // mirror the agent path — record it and speak any toolFeedback filler.\n const toolCall = unwrapStepToolCall(payload.output);\n if (toolCall) {\n toolCalls.push(toolCall);\n if (toolFeedback) {\n const filler = toolFeedback(toolCall);\n if (filler) controller.enqueue(filler.endsWith(' ') ? filler : `${filler} `);\n }\n }\n }\n if (!cancelled && !streamedAny && resultText) {\n const finalText = resultText(await output.result);\n if (finalText) {\n replyText += finalText;\n controller.enqueue(finalText);\n }\n }\n if (!cancelled) controller.close();\n // Success, or a clean barge-in break out of the loop: the turn is done either way.\n emitTurnComplete(cancelled);\n } catch (error) {\n // Barge-in cancels the run; that's not a failure — the turn still completed\n // (interrupted), so the hook still fires for memory reconciliation.\n if (cancelled) {\n emitTurnComplete(true);\n return;\n }\n controller.error(error);\n }\n },\n cancel: () => {\n cancelled = true;\n // Barge-in fires this synchronously; swallow any rejection from the run cancellation so a\n // failed cancel can't surface as an unhandled promise rejection.\n void Promise.resolve(run.cancel()).catch(error => {\n console.warn('@mastra/livekit: failed to cancel the workflow run on barge-in', error);\n });\n },\n });\n };\n}\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,13 +1,12 @@
1
1
  {
2
2
  "name": "@mastra/livekit",
3
- "version": "0.3.0",
3
+ "version": "0.3.1-alpha.1",
4
4
  "description": "LiveKit voice integration for Mastra agents — realtime voice with semantic turn detection and barge-in",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
8
8
  "files": [
9
- "dist",
10
- "CHANGELOG.md"
9
+ "dist"
11
10
  ],
12
11
  "exports": {
13
12
  ".": {
@@ -84,23 +83,24 @@
84
83
  }
85
84
  },
86
85
  "devDependencies": {
87
- "@livekit/agents": "^1.4.5",
88
- "@livekit/agents-plugin-livekit": "^1.4.5",
89
- "@livekit/agents-plugin-silero": "^1.4.5",
86
+ "@livekit/agents": "^1.7.1",
87
+ "@livekit/agents-plugin-livekit": "^1.7.1",
88
+ "@livekit/agents-plugin-silero": "^1.7.1",
90
89
  "@types/node": "22.20.1",
91
- "eslint": "^10.4.1",
92
- "tsup": "^8.5.1",
93
- "typescript": "^6.0.3",
90
+ "eslint": "^10.7.0",
91
+ "tsdown": "0.22.9",
92
+ "typescript": "^7.0.2",
94
93
  "vitest": "4.1.10",
95
94
  "zod": "^4.4.3",
96
- "@internal/types-builder": "0.0.89",
97
- "@internal/lint": "0.0.114",
98
- "@mastra/core": "1.51.0"
95
+ "@internal/types-builder": "0.0.104",
96
+ "@internal/lint": "0.0.129",
97
+ "@mastra/core": "1.64.0-alpha.5"
99
98
  },
100
99
  "scripts": {
101
- "build:lib": "tsup --silent --config tsup.config.ts",
100
+ "build:lib": "tsdown --silent --config tsdown.config.ts",
102
101
  "build:watch": "pnpm build:lib --watch",
103
- "lint": "eslint .",
102
+ "lint": "oxlint . && eslint .",
103
+ "lint:fix": "oxlint --fix . && eslint --fix .",
104
104
  "test": "vitest run"
105
105
  }
106
106
  }