@kuralle-syrinx/mastra 2.1.0

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.
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kuralle
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
package/README.md ADDED
@@ -0,0 +1,43 @@
1
+ # @kuralle-syrinx/mastra
2
+
3
+ The Mastra adapter for the Syrinx [`Reasoner`](../core/README.md#the-reasoner-seam) seam: **`fromMastraAgent(agent) → Reasoner`**. Drop it into [`ReasoningBridge`](../aisdk/README.md) to drive the voice pipeline with a Mastra `Agent` instead of a raw AI SDK backend — no pipeline change.
4
+
5
+ ## Usage
6
+
7
+ ```ts
8
+ import { Agent } from "@mastra/core/agent";
9
+ import { createOpenAI } from "@ai-sdk/openai";
10
+ import { ReasoningBridge } from "@kuralle-syrinx/aisdk";
11
+ import { fromMastraAgent } from "@kuralle-syrinx/mastra";
12
+
13
+ const agent = new Agent({
14
+ name: "support",
15
+ instructions: "You are a helpful voice assistant.",
16
+ model: createOpenAI({ apiKey })("gpt-4.1-mini"),
17
+ });
18
+ session.registerPlugin("bridge", new ReasoningBridge(fromMastraAgent(agent)));
19
+ ```
20
+
21
+ ## How it maps
22
+
23
+ `fromMastraAgent` awaits `agent.stream(messages)` and iterates `output.fullStream` (a `ReadableStream<{type,payload}>`), mapping each chunk → `ReasoningPart`:
24
+
25
+ | Mastra chunk | → |
26
+ |---|---|
27
+ | `text-delta` (`payload.text`) | `text-delta` |
28
+ | `tool-call` (`payload.{toolCallId,toolName,args}`) | `tool-call` |
29
+ | `tool-result` (`payload.{toolCallId,toolName,result}`) | `tool-result` |
30
+ | `tool-call-suspended` (`payload.suspendPayload`, `output.runId`) | terminal `suspended` (Sprint 3 / suspend-resume) |
31
+ | `finish` (`payload.stepResult.reason`) | `finish` (stop/tool/length) or terminal `error` for abnormal reasons |
32
+ | `error` (`payload.error`) | terminal `error` |
33
+ | anything else | dropped |
34
+
35
+ Verified against `@mastra/core@1.41.0`. A `turn.resume` routes to `agent.resumeStream(data, {runId})` instead of `stream(...)`. No buffering — each chunk is yielded immediately.
36
+
37
+ `MastraAgentLike` is the minimal structural type the adapter needs (`stream` + `resumeStream`) — the concrete `@mastra/core` `Agent` satisfies it.
38
+
39
+ ## Gotchas
40
+
41
+ - **`@mastra/core` is a `peerDependency`** — the consumer instantiates and owns the `Agent` (and its Mastra version). The adapter only touches `stream`/`resumeStream`/`fullStream`/`runId`.
42
+ - **Node / `nodejs_compat` only.** `@mastra/core` imports `events`/`fs`/`path`/`crypto` and won't bundle for a plain browser/edge target (`--platform=browser`). On Cloudflare Workers it requires `nodejs_compat` (see `@kuralle-syrinx/server-workers-mastra`). The lean AI-SDK product worker deliberately keeps Mastra out of its bundle.
43
+ - **History stays bridge-owned** (RFC §4.5): run the agent stateless-per-turn (no Mastra memory) so the bridge's spoken-prefix barge-in remains authoritative.
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@kuralle-syrinx/mastra",
3
+ "version": "2.1.0",
4
+ "private": false,
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "dependencies": {
10
+ "@kuralle-syrinx/core": "2.1.0"
11
+ },
12
+ "peerDependencies": {
13
+ "@mastra/core": ">=1.41.0 <2"
14
+ },
15
+ "devDependencies": {
16
+ "@mastra/core": "^1.41.0",
17
+ "typescript": "^5.7.0",
18
+ "vitest": "^2.1.0"
19
+ },
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit",
22
+ "test": "vitest run"
23
+ }
24
+ }
@@ -0,0 +1,283 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { describe, expect, it } from "vitest";
4
+ import type { Reasoner, ReasonerTurn, ReasoningPart } from "@kuralle-syrinx/core";
5
+ import { fromMastraAgent, type MastraAgentLike, type MastraChunk } from "./from-mastra.js";
6
+
7
+ function baseTurn(): ReasonerTurn {
8
+ return {
9
+ userText: "Hi",
10
+ messages: [{ role: "system", content: "test" }],
11
+ signal: new AbortController().signal,
12
+ };
13
+ }
14
+
15
+ function textDelta(text: string): MastraChunk {
16
+ return { type: "text-delta", payload: { text } };
17
+ }
18
+
19
+ function toolCall(toolCallId: string, toolName: string, args: Record<string, unknown>): MastraChunk {
20
+ return { type: "tool-call", payload: { toolCallId, toolName, args } };
21
+ }
22
+
23
+ function toolResult(toolCallId: string, toolName: string, result: unknown): MastraChunk {
24
+ return { type: "tool-result", payload: { toolCallId, toolName, result } };
25
+ }
26
+
27
+ function errorChunk(error: unknown): MastraChunk {
28
+ return { type: "error", payload: { error } };
29
+ }
30
+
31
+ function finish(reason: string): MastraChunk {
32
+ return { type: "finish", payload: { stepResult: { reason } } };
33
+ }
34
+
35
+ function chunksToStream(chunks: MastraChunk[]): ReadableStream<MastraChunk> {
36
+ return new ReadableStream({
37
+ start(controller) {
38
+ for (const ch of chunks) controller.enqueue(ch);
39
+ controller.close();
40
+ },
41
+ });
42
+ }
43
+
44
+ function delayedStream(first: MastraChunk): { stream: ReadableStream<MastraChunk>; release: () => void } {
45
+ let release!: () => void;
46
+ const gate = new Promise<void>((resolve) => {
47
+ release = resolve;
48
+ });
49
+ const stream = new ReadableStream<MastraChunk>({
50
+ async start(controller) {
51
+ controller.enqueue(first);
52
+ await gate;
53
+ controller.close();
54
+ },
55
+ });
56
+ return { stream, release };
57
+ }
58
+
59
+ function fakeAgent(
60
+ chunks: MastraChunk[] | ReadableStream<MastraChunk>,
61
+ opts?: {
62
+ runId?: string;
63
+ resume?: {
64
+ chunks: MastraChunk[] | ReadableStream<MastraChunk>;
65
+ runId?: string;
66
+ };
67
+ spy?: {
68
+ streamCalled?: boolean;
69
+ resumeCalled?: boolean;
70
+ resumeData?: unknown;
71
+ resumeOptions?: { runId: string; toolCallId?: string; abortSignal?: AbortSignal };
72
+ };
73
+ },
74
+ ): MastraAgentLike {
75
+ const spy = opts?.spy;
76
+ return {
77
+ async stream() {
78
+ if (spy) spy.streamCalled = true;
79
+ return {
80
+ runId: opts?.runId ?? "r1",
81
+ fullStream: chunks instanceof ReadableStream ? chunks : chunksToStream(chunks),
82
+ };
83
+ },
84
+ async resumeStream(resumeData, options) {
85
+ if (spy) {
86
+ spy.resumeCalled = true;
87
+ spy.resumeData = resumeData;
88
+ spy.resumeOptions = options;
89
+ }
90
+ const resumeChunks = opts?.resume?.chunks ?? [];
91
+ return {
92
+ runId: opts?.resume?.runId ?? opts?.runId ?? "r1",
93
+ fullStream:
94
+ resumeChunks instanceof ReadableStream ? resumeChunks : chunksToStream(resumeChunks),
95
+ };
96
+ },
97
+ };
98
+ }
99
+
100
+ async function collectParts(reasoner: Reasoner, turn: ReasonerTurn): Promise<ReasoningPart[]> {
101
+ const parts: ReasoningPart[] = [];
102
+ for await (const part of reasoner.stream(turn)) {
103
+ parts.push(part);
104
+ }
105
+ return parts;
106
+ }
107
+
108
+ describe("fromMastraAgent", () => {
109
+ it("maps happy path: deltas, tool-call, tool-result, finish:stop", async () => {
110
+ const reasoner = fromMastraAgent(
111
+ fakeAgent([
112
+ textDelta("Hello "),
113
+ textDelta("world."),
114
+ toolCall("tc-1", "get_weather", { city: "NYC" }),
115
+ toolResult("tc-1", "get_weather", { temp: 72 }),
116
+ finish("stop"),
117
+ ]),
118
+ );
119
+
120
+ const parts = await collectParts(reasoner, baseTurn());
121
+
122
+ expect(parts).toEqual([
123
+ { type: "text-delta", text: "Hello " },
124
+ { type: "text-delta", text: "world." },
125
+ {
126
+ type: "tool-call",
127
+ toolId: "tc-1",
128
+ toolName: "get_weather",
129
+ args: { city: "NYC" },
130
+ },
131
+ {
132
+ type: "tool-result",
133
+ toolId: "tc-1",
134
+ toolName: "get_weather",
135
+ result: JSON.stringify({ temp: 72 }),
136
+ },
137
+ { type: "finish", reason: "stop", text: "Hello world." },
138
+ ]);
139
+ });
140
+
141
+ it("maps error chunk to terminal error", async () => {
142
+ const reasoner = fromMastraAgent(
143
+ fakeAgent([textDelta("partial"), errorChunk(new Error("provider failed"))]),
144
+ );
145
+
146
+ const parts = await collectParts(reasoner, baseTurn());
147
+
148
+ expect(parts).toHaveLength(2);
149
+ expect(parts[0]).toEqual({ type: "text-delta", text: "partial" });
150
+ expect(parts[1]?.type).toBe("error");
151
+ if (parts[1]?.type === "error") {
152
+ expect(parts[1].cause.message).toBe("provider failed");
153
+ expect(parts[1].recoverable).toBe(false);
154
+ }
155
+ });
156
+
157
+ it("maps abnormal finish (content-filter) to terminal error", async () => {
158
+ const reasoner = fromMastraAgent(fakeAgent([finish("content-filter")]));
159
+
160
+ const parts = await collectParts(reasoner, baseTurn());
161
+
162
+ expect(parts).toHaveLength(1);
163
+ expect(parts[0]?.type).toBe("error");
164
+ if (parts[0]?.type === "error") {
165
+ expect(parts[0].cause.message).toBe(
166
+ "Mastra provider did not complete normally: content-filter",
167
+ );
168
+ expect(parts[0].recoverable).toBe(false);
169
+ }
170
+ });
171
+
172
+ it("maps finish(length) to finish with accumulated text", async () => {
173
+ const reasoner = fromMastraAgent(fakeAgent([textDelta("truncated"), finish("length")]));
174
+
175
+ const parts = await collectParts(reasoner, baseTurn());
176
+
177
+ expect(parts).toEqual([
178
+ { type: "text-delta", text: "truncated" },
179
+ { type: "finish", reason: "length", text: "truncated" },
180
+ ]);
181
+ });
182
+
183
+ it("drops reasoning-delta and workflow chunks", async () => {
184
+ const reasoner = fromMastraAgent(
185
+ fakeAgent([
186
+ { type: "reasoning-delta", payload: { text: "thinking..." } },
187
+ { type: "workflow-step", payload: { step: 1 } },
188
+ textDelta("answer"),
189
+ finish("stop"),
190
+ ]),
191
+ );
192
+
193
+ const parts = await collectParts(reasoner, baseTurn());
194
+
195
+ expect(parts).toEqual([
196
+ { type: "text-delta", text: "answer" },
197
+ { type: "finish", reason: "stop", text: "answer" },
198
+ ]);
199
+ });
200
+
201
+ it("yields first text-delta before source stream completes (no buffering)", async () => {
202
+ const { stream, release } = delayedStream(textDelta("immediate"));
203
+ const reasoner = fromMastraAgent(fakeAgent(stream));
204
+
205
+ const iterator = reasoner.stream(baseTurn())[Symbol.asyncIterator]();
206
+ const first = await iterator.next();
207
+
208
+ expect(first.done).toBe(false);
209
+ expect(first.value).toEqual({ type: "text-delta", text: "immediate" });
210
+ release();
211
+ });
212
+
213
+ it("returns without yielding when turn.signal is already aborted (barge-in)", async () => {
214
+ const controller = new AbortController();
215
+ controller.abort();
216
+ const turn: ReasonerTurn = { ...baseTurn(), signal: controller.signal };
217
+
218
+ const reasoner = fromMastraAgent(
219
+ fakeAgent([textDelta("should not appear"), finish("stop")]),
220
+ );
221
+
222
+ const parts = await collectParts(reasoner, turn);
223
+
224
+ expect(parts).toEqual([]);
225
+ });
226
+
227
+ it("maps tool-call-suspended to terminal suspended part", async () => {
228
+ const reasoner = fromMastraAgent(
229
+ fakeAgent(
230
+ [
231
+ {
232
+ type: "tool-call-suspended",
233
+ payload: { suspendPayload: { message: "Approve the refund?" } },
234
+ },
235
+ ],
236
+ { runId: "run-1" },
237
+ ),
238
+ );
239
+
240
+ const parts = await collectParts(reasoner, baseTurn());
241
+
242
+ expect(parts).toEqual([
243
+ {
244
+ type: "suspended",
245
+ runId: "run-1",
246
+ prompt: "Approve the refund?",
247
+ payload: { message: "Approve the refund?" },
248
+ },
249
+ ]);
250
+ });
251
+
252
+ it("resume turn calls resumeStream instead of stream", async () => {
253
+ const spy = {
254
+ streamCalled: false,
255
+ resumeCalled: false,
256
+ resumeData: undefined as unknown,
257
+ resumeOptions: undefined as
258
+ | { runId: string; toolCallId?: string; abortSignal?: AbortSignal }
259
+ | undefined,
260
+ };
261
+ const reasoner = fromMastraAgent(
262
+ fakeAgent([], {
263
+ resume: { chunks: [textDelta("Done."), finish("stop")], runId: "run-1" },
264
+ spy,
265
+ }),
266
+ );
267
+
268
+ const turn: ReasonerTurn = {
269
+ ...baseTurn(),
270
+ resume: { runId: "run-1", data: { approved: true } },
271
+ };
272
+ const parts = await collectParts(reasoner, turn);
273
+
274
+ expect(spy.streamCalled).toBe(false);
275
+ expect(spy.resumeCalled).toBe(true);
276
+ expect(spy.resumeData).toEqual({ approved: true });
277
+ expect(spy.resumeOptions).toEqual({ runId: "run-1", abortSignal: turn.signal });
278
+ expect(parts).toEqual([
279
+ { type: "text-delta", text: "Done." },
280
+ { type: "finish", reason: "stop", text: "Done." },
281
+ ]);
282
+ });
283
+ });
@@ -0,0 +1,126 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import type { Reasoner, ReasonerTurn, ReasonerMessage, ReasoningPart } from "@kuralle-syrinx/core";
3
+ import { categorizeLlmError, isRecoverable } from "@kuralle-syrinx/core";
4
+
5
+ type MastraStreamOutput = { readonly runId: string; readonly fullStream: ReadableStream<MastraChunk> };
6
+
7
+ export interface MastraAgentLike {
8
+ stream(
9
+ messages: MastraMessage[],
10
+ options?: { abortSignal?: AbortSignal },
11
+ ): Promise<MastraStreamOutput>;
12
+ resumeStream(
13
+ resumeData: unknown,
14
+ options: { runId: string; toolCallId?: string; abortSignal?: AbortSignal },
15
+ ): Promise<MastraStreamOutput>;
16
+ }
17
+
18
+ export interface MastraChunk {
19
+ readonly type: string;
20
+ readonly payload: Record<string, unknown>;
21
+ }
22
+
23
+ type MastraMessage = { role: "system" | "user" | "assistant" | "tool"; content: string };
24
+
25
+ export function fromMastraAgent(agent: MastraAgentLike): Reasoner {
26
+ return { stream: (turn) => streamFromMastra(agent, turn) };
27
+ }
28
+
29
+ function buildMessages(turn: ReasonerTurn): MastraMessage[] {
30
+ return [
31
+ ...turn.messages.map((m: ReasonerMessage) => ({ role: m.role, content: m.content })),
32
+ { role: "user", content: turn.userText },
33
+ ];
34
+ }
35
+
36
+ async function* streamFromMastra(
37
+ agent: MastraAgentLike,
38
+ turn: ReasonerTurn,
39
+ ): AsyncGenerator<ReasoningPart> {
40
+ const out = turn.resume
41
+ ? await agent.resumeStream(turn.resume.data, {
42
+ runId: turn.resume.runId,
43
+ abortSignal: turn.signal,
44
+ })
45
+ : await agent.stream(buildMessages(turn), { abortSignal: turn.signal });
46
+ let acc = "";
47
+ for await (const chunk of out.fullStream) {
48
+ if (turn.signal.aborted) return;
49
+ switch (chunk.type) {
50
+ case "text-delta": {
51
+ const t = String(chunk.payload.text ?? "");
52
+ acc += t;
53
+ yield { type: "text-delta", text: t };
54
+ break;
55
+ }
56
+ case "tool-call":
57
+ yield {
58
+ type: "tool-call",
59
+ toolId: String(chunk.payload.toolCallId ?? ""),
60
+ toolName: String(chunk.payload.toolName ?? ""),
61
+ args: toRecord(chunk.payload.args),
62
+ };
63
+ break;
64
+ case "tool-result":
65
+ yield {
66
+ type: "tool-result",
67
+ toolId: String(chunk.payload.toolCallId ?? ""),
68
+ toolName: String(chunk.payload.toolName ?? ""),
69
+ result: stringifyResult(chunk.payload.result),
70
+ };
71
+ break;
72
+ case "error":
73
+ yield toErrorPart(chunk.payload.error);
74
+ return;
75
+ case "finish": {
76
+ const reason = chunk.payload.stepResult as { reason?: string } | undefined;
77
+ const finishReason = reason?.reason;
78
+ if (finishReason === "stop" || finishReason === "tool-calls" || finishReason === "length") {
79
+ yield {
80
+ type: "finish",
81
+ reason: finishReason === "tool-calls" ? "tool" : finishReason,
82
+ text: acc,
83
+ };
84
+ return;
85
+ }
86
+ yield toErrorPart(
87
+ new Error(`Mastra provider did not complete normally: ${String(finishReason)}`),
88
+ );
89
+ return;
90
+ }
91
+ case "tool-call-suspended": {
92
+ const sp = chunk.payload.suspendPayload as Record<string, unknown> | undefined;
93
+ const prompt =
94
+ typeof sp?.["message"] === "string"
95
+ ? (sp["message"] as string)
96
+ : typeof sp?.["prompt"] === "string"
97
+ ? (sp["prompt"] as string)
98
+ : undefined;
99
+ yield {
100
+ type: "suspended",
101
+ runId: out.runId,
102
+ prompt,
103
+ payload: chunk.payload.suspendPayload,
104
+ };
105
+ return;
106
+ }
107
+ default:
108
+ break;
109
+ }
110
+ }
111
+ yield toErrorPart(new Error("Mastra stream ended without a finish chunk"));
112
+ }
113
+
114
+ function toErrorPart(error: unknown): ReasoningPart {
115
+ const cause = error instanceof Error ? error : new Error(String(error));
116
+ return { type: "error", cause, recoverable: isRecoverable(categorizeLlmError(cause)) };
117
+ }
118
+
119
+ function stringifyResult(r: unknown): string {
120
+ return typeof r === "string" ? r : JSON.stringify(r);
121
+ }
122
+
123
+ function toRecord(value: unknown): Record<string, unknown> {
124
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
125
+ return value as Record<string, unknown>;
126
+ }
package/src/index.ts ADDED
@@ -0,0 +1,2 @@
1
+ // SPDX-License-Identifier: MIT
2
+ export { fromMastraAgent, type MastraAgentLike, type MastraChunk } from "./from-mastra.js";
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "types": ["vitest/globals"],
10
+ "noEmit": true
11
+ },
12
+ "include": ["src/**/*.ts"]
13
+ }