@alexkroman1/aai 1.4.5 → 1.5.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.
Files changed (78) hide show
  1. package/.turbo/turbo-build.log +9 -9
  2. package/CHANGELOG.md +13 -0
  3. package/dist/assemblyai-C969QGi4.js +35 -0
  4. package/dist/cartesia-BfQPOQ7Y.js +37 -0
  5. package/dist/host/_pipeline-test-fakes.d.ts +3 -1
  6. package/dist/host/providers/stt/deepgram.d.ts +28 -0
  7. package/dist/host/providers/tts/cartesia.d.ts +1 -1
  8. package/dist/host/providers/tts/rime.d.ts +44 -0
  9. package/dist/host/runtime-barrel.d.ts +4 -2
  10. package/dist/host/runtime-barrel.js +1432 -1208
  11. package/dist/host/runtime.d.ts +2 -2
  12. package/dist/host/s2s.d.ts +16 -16
  13. package/dist/host/session-core.d.ts +37 -0
  14. package/dist/host/transports/pipeline-transport.d.ts +48 -0
  15. package/dist/host/transports/s2s-transport.d.ts +19 -0
  16. package/dist/host/transports/types.d.ts +45 -0
  17. package/dist/host/ws-handler.d.ts +14 -10
  18. package/dist/sdk/protocol.d.ts +6 -5
  19. package/dist/sdk/providers/llm-barrel.js +1 -1
  20. package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
  21. package/dist/sdk/providers/stt-barrel.d.ts +1 -0
  22. package/dist/sdk/providers/stt-barrel.js +2 -2
  23. package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
  24. package/dist/sdk/providers/tts/rime.d.ts +42 -0
  25. package/dist/sdk/providers/tts-barrel.d.ts +1 -0
  26. package/dist/sdk/providers/tts-barrel.js +2 -2
  27. package/host/_pipeline-test-fakes.ts +6 -3
  28. package/host/_test-utils.ts +209 -128
  29. package/host/cleanup.test.ts +25 -298
  30. package/host/integration/pipeline-reference.integration.test.ts +30 -35
  31. package/host/providers/resolve.ts +10 -2
  32. package/host/providers/stt/deepgram.test.ts +229 -0
  33. package/host/providers/stt/deepgram.ts +172 -0
  34. package/host/providers/tts/cartesia.ts +7 -3
  35. package/host/providers/tts/rime.test.ts +251 -0
  36. package/host/providers/tts/rime.ts +322 -0
  37. package/host/runtime-barrel.ts +4 -2
  38. package/host/runtime.test.ts +13 -46
  39. package/host/runtime.ts +131 -23
  40. package/host/s2s.test.ts +122 -131
  41. package/host/s2s.ts +44 -52
  42. package/host/session-core.test.ts +257 -0
  43. package/host/session-core.ts +262 -0
  44. package/host/transports/pipeline-transport.test.ts +651 -0
  45. package/host/transports/pipeline-transport.ts +532 -0
  46. package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
  47. package/host/transports/s2s-transport.test.ts +56 -0
  48. package/host/transports/s2s-transport.ts +116 -0
  49. package/host/transports/types.test.ts +22 -0
  50. package/host/transports/types.ts +51 -0
  51. package/host/ws-handler.test.ts +324 -242
  52. package/host/ws-handler.ts +56 -59
  53. package/package.json +2 -1
  54. package/sdk/__snapshots__/exports.test.ts.snap +3 -3
  55. package/sdk/protocol-compat.test.ts +8 -0
  56. package/sdk/protocol.ts +6 -5
  57. package/sdk/providers/stt/deepgram.ts +43 -0
  58. package/sdk/providers/stt-barrel.ts +2 -0
  59. package/sdk/providers/tts/cartesia.ts +15 -5
  60. package/sdk/providers/tts/rime.ts +52 -0
  61. package/sdk/providers/tts-barrel.ts +2 -0
  62. package/dist/assemblyai-Cxg9eobY.js +0 -18
  63. package/dist/cartesia-DwDk2tEu.js +0 -10
  64. package/dist/host/pipeline-session-ctx.d.ts +0 -24
  65. package/dist/host/pipeline-session.d.ts +0 -52
  66. package/dist/host/session-ctx.d.ts +0 -73
  67. package/dist/host/session.d.ts +0 -62
  68. package/host/pipeline-session-ctx.test.ts +0 -31
  69. package/host/pipeline-session-ctx.ts +0 -36
  70. package/host/pipeline-session.test.ts +0 -672
  71. package/host/pipeline-session.ts +0 -533
  72. package/host/s2s-fixtures.test.ts +0 -237
  73. package/host/session-ctx.test.ts +0 -387
  74. package/host/session-ctx.ts +0 -134
  75. package/host/session-fixture-replay.test.ts +0 -128
  76. package/host/session.test.ts +0 -634
  77. package/host/session.ts +0 -412
  78. /package/dist/{anthropic-BrUCPKUc.js → anthropic-CcLZygAr.js} +0 -0
@@ -0,0 +1,322 @@
1
+ // Copyright 2026 the AAI authors. MIT license.
2
+ /**
3
+ * Rime TTS opener (host-only).
4
+ *
5
+ * The user-facing descriptor factory (`rime(...)`) lives in
6
+ * `sdk/providers/tts/rime.ts`. This module is the host-side
7
+ * counterpart: it takes the descriptor options + an API key and
8
+ * returns a {@link TtsOpener} that the pipeline session drives.
9
+ *
10
+ * **Protocol.** Connects to Rime's `ws2` JSON WebSocket endpoint
11
+ * (`wss://users-ws.rime.ai/ws2`). Client-to-server messages are JSON:
12
+ * - `{ "text": "..." }` — append text to the synthesis buffer
13
+ * - `{ "operation": "clear" }` — drop buffered text (barge-in)
14
+ * - `{ "operation": "eos" }` — drain buffer, close connection (NOT used
15
+ * during a session: it would tear down the WS, forcing reconnect per
16
+ * turn). We force end-of-turn synthesis with a trailing `"."` instead.
17
+ * The server responds with JSON frames:
18
+ * - `{ type: "chunk", data: <base64 PCM16 LE>, contextId: string | null }`
19
+ * - `{ type: "timestamps", ... }` (ignored)
20
+ * - `{ type: "error", message: string }` (surfaced as `tts_stream_error`)
21
+ *
22
+ * **Single long-lived connection per session.** Rime buffers text until it
23
+ * sees terminal punctuation (`.`, `?`, `!`), so we use one WebSocket per
24
+ * `open()` call and reuse it across turns. `clear` resets the buffer
25
+ * between cancellations.
26
+ *
27
+ * **Done detection.** After `flush()` sends a trailing `"."` to force the
28
+ * server to synthesize any half-buffered text, we arm a quiescence timer
29
+ * that fires 500 ms after the last received audio chunk. When it fires,
30
+ * `done` is emitted.
31
+ *
32
+ * **Audio format.** The URL requests `audioFormat=pcm` at the negotiated
33
+ * `sampleRate`, which returns raw PCM16 little-endian. We decode the base64
34
+ * payload and construct a zero-copy `Int16Array` view over the decoded bytes.
35
+ */
36
+
37
+ import { createNanoEvents, type Emitter } from "nanoevents";
38
+ import WebSocket from "ws";
39
+ import { RIME_DEFAULT_VOICE, type RimeOptions } from "../../../sdk/providers/tts/rime.ts";
40
+ import {
41
+ makeTtsError,
42
+ type TtsEvents,
43
+ type TtsOpener,
44
+ type TtsOpenOptions,
45
+ type TtsSession,
46
+ } from "../../../sdk/providers.ts";
47
+
48
+ /** Internal: TtsSession with a test-only handle to the raw WebSocket. */
49
+ export interface RimeSession extends TtsSession {
50
+ /** @internal Test-only: exposes the underlying raw WebSocket. */
51
+ readonly _ws: WebSocket;
52
+ }
53
+
54
+ /** PCM16 sample rates accepted by the Rime `ws2` endpoint. */
55
+ const RIME_PCM16_RATES = [
56
+ 8000, 16_000, 22_050, 24_000, 44_100, 48_000,
57
+ ] as const satisfies readonly number[];
58
+
59
+ function assertSupportedSampleRate(rate: number): number {
60
+ if ((RIME_PCM16_RATES as readonly number[]).includes(rate)) return rate;
61
+ throw makeTtsError(
62
+ "tts_connect_failed",
63
+ `Rime TTS: unsupported sample rate ${rate}. Supported: ${RIME_PCM16_RATES.join(", ")}.`,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Decode a base64 string from Rime into a zero-copy `Int16Array`.
69
+ *
70
+ * Rime's `ws2` endpoint returns base64-encoded PCM16 LE in each chunk.
71
+ * `Buffer.from(base64, "base64")` gives us a Node.js Buffer (which is a
72
+ * Uint8Array subclass) with `byteOffset === 0`. PCM16 bytes always come in
73
+ * pairs so the length is guaranteed to be even.
74
+ */
75
+ function base64ToPcm(data: string): Int16Array {
76
+ const bytes = Buffer.from(data, "base64");
77
+ // Defensive: drop a trailing odd byte rather than throwing on misalignment.
78
+ const evenLen = bytes.byteLength - (bytes.byteLength % 2);
79
+ if (evenLen === 0) return new Int16Array(0);
80
+ return new Int16Array(bytes.buffer, bytes.byteOffset, evenLen / 2);
81
+ }
82
+
83
+ /**
84
+ * Shape of JSON messages received from Rime's `ws2` endpoint.
85
+ *
86
+ * Only `chunk` messages carry audio; `timestamps` messages are informational
87
+ * and can be ignored for the current use case.
88
+ */
89
+ interface RimeMessage {
90
+ type: "chunk" | "timestamps" | "error" | string;
91
+ /** Base64-encoded PCM16 LE audio. Present on `type === "chunk"`. */
92
+ data?: string;
93
+ /** Context discriminator for the in-flight utterance. May be null. */
94
+ contextId?: string | null;
95
+ /** Error description. Present on `type === "error"`. */
96
+ message?: string;
97
+ }
98
+
99
+ /** Quiescence timeout in ms — how long to wait after the last audio chunk before emitting `done`. */
100
+ const QUIESCENCE_MS = 500;
101
+
102
+ /**
103
+ * After `flush()`, how long to wait for the FIRST audio chunk before
104
+ * giving up and emitting `done`. Greeting and short replies hit this
105
+ * path: `flush()` runs immediately after `sendText()`, so audio TTFB
106
+ * exceeds the 500 ms quiescence window. Once the first chunk arrives,
107
+ * we transition to the shorter quiescence timeout.
108
+ */
109
+ const FIRST_AUDIO_TIMEOUT_MS = 5000;
110
+
111
+ /** Wait for the WebSocket `open` event; reject on first `error`. */
112
+ function waitForOpen(ws: WebSocket): Promise<void> {
113
+ return new Promise<void>((resolve, reject) => {
114
+ const onOpen = () => {
115
+ ws.removeListener("error", onError);
116
+ resolve();
117
+ };
118
+ const onError = (err: Error) => {
119
+ ws.removeListener("open", onOpen);
120
+ reject(
121
+ makeTtsError(
122
+ "tts_connect_failed",
123
+ `Rime TTS: connect failed: ${err?.message ?? String(err)}`,
124
+ ),
125
+ );
126
+ };
127
+ ws.once("open", onOpen);
128
+ ws.once("error", onError);
129
+ });
130
+ }
131
+
132
+ /**
133
+ * Handle one incoming WebSocket message frame.
134
+ *
135
+ * Extracted into a top-level function to keep `open()` under the cognitive
136
+ * complexity limit while retaining full access to the session state via refs.
137
+ */
138
+ function handleRimeMessage(
139
+ raw: WebSocket.Data,
140
+ emitter: Emitter<TtsEvents>,
141
+ armQuiescence: () => void,
142
+ isActiveTimer: () => boolean,
143
+ ): void {
144
+ let msg: RimeMessage;
145
+ try {
146
+ msg = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as RimeMessage;
147
+ } catch {
148
+ // Unparseable frame — ignore.
149
+ return;
150
+ }
151
+
152
+ if (msg.type === "chunk" && typeof msg.data === "string") {
153
+ const pcm = base64ToPcm(msg.data);
154
+ if (pcm.length > 0) {
155
+ emitter.emit("audio", pcm);
156
+ // While we're waiting on a flush (long timer for first audio, or
157
+ // short timer between chunks), each chunk resets to the short
158
+ // quiescence window — so `done` fires only after audio stops.
159
+ if (isActiveTimer()) armQuiescence();
160
+ }
161
+ return;
162
+ }
163
+ if (msg.type === "error") {
164
+ emitter.emit(
165
+ "error",
166
+ makeTtsError("tts_stream_error", `Rime TTS: ${msg.message ?? "unknown error"}`),
167
+ );
168
+ }
169
+ // Ignore `type === "timestamps"` and unknown message types.
170
+ }
171
+
172
+ /** Build a {@link TtsOpener} from resolved Rime descriptor options. */
173
+ export function openRime(opts: RimeOptions): TtsOpener {
174
+ return {
175
+ name: "rime",
176
+ async open(openOpts: TtsOpenOptions): Promise<TtsSession> {
177
+ const apiKey = openOpts.apiKey || process.env.RIME_API_KEY;
178
+ if (!apiKey) {
179
+ throw makeTtsError(
180
+ "tts_auth_failed",
181
+ "Rime TTS: missing API key. Set RIME_API_KEY in the agent env.",
182
+ );
183
+ }
184
+
185
+ const sampleRate = assertSupportedSampleRate(openOpts.sampleRate);
186
+ const model = opts.model ?? "mistv2";
187
+ const lang = opts.language ?? "eng";
188
+ const voice = opts.voice ?? RIME_DEFAULT_VOICE;
189
+
190
+ // Construct the ws2 URL with query parameters.
191
+ const url = `wss://users-ws.rime.ai/ws2?speaker=${encodeURIComponent(voice)}&modelId=${encodeURIComponent(model)}&audioFormat=pcm&samplingRate=${sampleRate}&lang=${encodeURIComponent(lang)}`;
192
+
193
+ let ws: WebSocket;
194
+ try {
195
+ ws = new WebSocket(url, { headers: { Authorization: `Bearer ${apiKey}` } });
196
+ } catch (cause) {
197
+ throw makeTtsError(
198
+ "tts_connect_failed",
199
+ `Rime TTS: failed to create WebSocket: ${cause instanceof Error ? cause.message : String(cause)}`,
200
+ );
201
+ }
202
+
203
+ await waitForOpen(ws);
204
+
205
+ const emitter: Emitter<TtsEvents> = createNanoEvents<TtsEvents>();
206
+ let closed = false;
207
+ let doneEmitted = false;
208
+ /**
209
+ * After `flush()`, we arm a timer that fires `done`. Initial timeout is
210
+ * `FIRST_AUDIO_TIMEOUT_MS` to give Rime headroom on TTFB; the first
211
+ * chunk swaps it for a shorter `QUIESCENCE_MS` window that resets on
212
+ * each subsequent chunk. `cancel()` emits `done` synchronously.
213
+ */
214
+ let quiescenceTimer: ReturnType<typeof setTimeout> | null = null;
215
+
216
+ const clearQuiescence = () => {
217
+ if (quiescenceTimer !== null) {
218
+ clearTimeout(quiescenceTimer);
219
+ quiescenceTimer = null;
220
+ }
221
+ };
222
+
223
+ const emitDoneOnce = () => {
224
+ clearQuiescence();
225
+ if (doneEmitted || closed) return;
226
+ doneEmitted = true;
227
+ emitter.emit("done");
228
+ };
229
+
230
+ const armQuiescence = () => {
231
+ clearQuiescence();
232
+ quiescenceTimer = setTimeout(emitDoneOnce, QUIESCENCE_MS);
233
+ };
234
+
235
+ const armFirstAudioTimer = () => {
236
+ clearQuiescence();
237
+ quiescenceTimer = setTimeout(emitDoneOnce, FIRST_AUDIO_TIMEOUT_MS);
238
+ };
239
+
240
+ ws.on("message", (raw: WebSocket.Data) => {
241
+ if (closed) return;
242
+ handleRimeMessage(raw, emitter, armQuiescence, () => quiescenceTimer !== null);
243
+ });
244
+
245
+ ws.on("error", (err: Error) => {
246
+ if (closed) return;
247
+ emitter.emit(
248
+ "error",
249
+ makeTtsError("tts_stream_error", `Rime TTS stream error: ${err?.message ?? String(err)}`),
250
+ );
251
+ });
252
+
253
+ ws.on("close", () => {
254
+ if (closed) return;
255
+ // Unexpected server-side close — surface `done` so the pipeline
256
+ // doesn't hang waiting for an utterance that will never complete.
257
+ emitDoneOnce();
258
+ });
259
+
260
+ const close = async (): Promise<void> => {
261
+ if (closed) return;
262
+ closed = true;
263
+ clearQuiescence();
264
+ try {
265
+ ws.close();
266
+ } catch {
267
+ // Swallow: caller has already decided to tear down.
268
+ }
269
+ };
270
+
271
+ if (openOpts.signal.aborted) {
272
+ void close();
273
+ } else {
274
+ openOpts.signal.addEventListener("abort", () => void close(), { once: true });
275
+ }
276
+
277
+ const session: RimeSession = {
278
+ sendText(text: string) {
279
+ if (closed || text.length === 0) return;
280
+ if (ws.readyState !== WebSocket.OPEN) return;
281
+ // Reset done state at the start of a new turn.
282
+ doneEmitted = false;
283
+ ws.send(JSON.stringify({ text }));
284
+ },
285
+
286
+ flush() {
287
+ if (closed) return;
288
+ if (ws.readyState !== WebSocket.OPEN) return;
289
+ // Force synthesis of any text buffered behind a missing terminal
290
+ // punctuation: append a trailing `"."`. Sending the JSON `eos`
291
+ // operation would close the connection, requiring a reconnect on
292
+ // every turn — `"."` keeps the WS reusable. Use the longer
293
+ // first-audio timer until the initial chunk arrives; the chunk
294
+ // handler swaps it for short quiescence on each subsequent chunk.
295
+ ws.send(JSON.stringify({ text: "." }));
296
+ armFirstAudioTimer();
297
+ },
298
+
299
+ cancel() {
300
+ if (closed) return;
301
+ // Drop Rime's server-side buffer for barge-in.
302
+ if (ws.readyState === WebSocket.OPEN) {
303
+ ws.send(JSON.stringify({ operation: "clear" }));
304
+ }
305
+ // Emit `done` synchronously — the orchestrator's state machine
306
+ // advances on `done`, and barge-in must not be microtask-deferred.
307
+ emitDoneOnce();
308
+ },
309
+
310
+ on(event, fn) {
311
+ return emitter.on(event, fn);
312
+ },
313
+
314
+ close,
315
+
316
+ _ws: ws,
317
+ };
318
+
319
+ return session;
320
+ },
321
+ };
322
+ }
@@ -17,8 +17,10 @@ export * from "./builtin-tools.ts";
17
17
  export * from "./runtime.ts";
18
18
  export * from "./runtime-config.ts";
19
19
  export * from "./server.ts";
20
- export * from "./session.ts";
21
- export * from "./session-ctx.ts";
20
+ export * from "./session-core.ts";
22
21
  export * from "./tool-executor.ts";
22
+ export * from "./transports/pipeline-transport.ts";
23
+ export * from "./transports/s2s-transport.ts";
24
+ export * from "./transports/types.ts";
23
25
  export * from "./unstorage-kv.ts";
24
26
  export * from "./ws-handler.ts";
@@ -11,10 +11,10 @@ import {
11
11
  createFakeTtsProvider,
12
12
  } from "./_pipeline-test-fakes.ts";
13
13
  import { CONFORMANCE_AGENT, testRuntime } from "./_runtime-conformance.ts";
14
- import { flush, makeAgent, makeClient, makeMockHandle, silentLogger } from "./_test-utils.ts";
14
+ import { flush, makeAgent, makeClientSink, makeMockHandle, silentLogger } from "./_test-utils.ts";
15
15
  import { createRuntime } from "./runtime.ts";
16
- import { _internals } from "./session.ts";
17
16
  import { executeToolCall } from "./tool-executor.ts";
17
+ import { _internals } from "./transports/s2s-transport.ts";
18
18
  import { createUnstorageKv } from "./unstorage-kv.ts";
19
19
 
20
20
  describe("toAgentConfig", () => {
@@ -360,11 +360,7 @@ describe("createRuntime shutdown", () => {
360
360
 
361
361
  test("shutdown stops active sessions gracefully", async () => {
362
362
  const mockHandle = makeMockHandle();
363
- const connectSpy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
364
- // Fire "ready" so session.start() resolves
365
- setTimeout(() => mockHandle._fire("ready", { sessionId: "mock-sid" }), 0);
366
- return mockHandle;
367
- });
363
+ const connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
368
364
 
369
365
  const agent = makeAgent();
370
366
  const runtime = createRuntime({ agent, env: {}, logger: silentLogger });
@@ -392,10 +388,7 @@ describe("createRuntime shutdown", () => {
392
388
  mockHandle.close = vi.fn(() => {
393
389
  throw new Error("close failed");
394
390
  });
395
- const connectSpy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
396
- setTimeout(() => mockHandle._fire("ready", { sessionId: "mock-sid" }), 0);
397
- return mockHandle;
398
- });
391
+ const connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
399
392
 
400
393
  const agent = makeAgent();
401
394
  const runtime = createRuntime({ agent, env: {}, logger });
@@ -422,10 +415,7 @@ describe("createRuntime shutdown", () => {
422
415
  mockHandle.close = vi.fn(() => {
423
416
  // intentionally do nothing — session stop will hang
424
417
  });
425
- const connectSpy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
426
- setTimeout(() => mockHandle._fire("ready", { sessionId: "mock-sid" }), 0);
427
- return mockHandle;
428
- });
418
+ const connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
429
419
 
430
420
  const agent = makeAgent();
431
421
  const runtime = createRuntime({
@@ -483,12 +473,7 @@ describe("createRuntime createSession", () => {
483
473
  test("createSession returns a Session object", () => {
484
474
  const agent = makeAgent();
485
475
  const runtime = createRuntime({ agent, env: {} });
486
- const client = {
487
- open: true,
488
- event: vi.fn(),
489
- playAudioChunk: vi.fn(),
490
- playAudioDone: vi.fn(),
491
- };
476
+ const client = makeClientSink();
492
477
  const session = runtime.createSession({
493
478
  id: "test-session",
494
479
  agent: agent.name,
@@ -506,12 +491,7 @@ describe("createRuntime createSession", () => {
506
491
  test("createSession passes skipGreeting option", () => {
507
492
  const agent = makeAgent();
508
493
  const runtime = createRuntime({ agent, env: {} });
509
- const client = {
510
- open: true,
511
- event: vi.fn(),
512
- playAudioChunk: vi.fn(),
513
- playAudioDone: vi.fn(),
514
- };
494
+ const client = makeClientSink();
515
495
  // Should not throw when skipGreeting is set
516
496
  const session = runtime.createSession({
517
497
  id: "test-session",
@@ -525,12 +505,7 @@ describe("createRuntime createSession", () => {
525
505
  test("createSession passes resumeFrom option", () => {
526
506
  const agent = makeAgent();
527
507
  const runtime = createRuntime({ agent, env: {} });
528
- const client = {
529
- open: true,
530
- event: vi.fn(),
531
- playAudioChunk: vi.fn(),
532
- playAudioDone: vi.fn(),
533
- };
508
+ const client = makeClientSink();
534
509
  const session = runtime.createSession({
535
510
  id: "test-session",
536
511
  agent: agent.name,
@@ -604,13 +579,8 @@ describe("createRuntime with custom options", () => {
604
579
  agent,
605
580
  env: { ASSEMBLYAI_API_KEY: "test-api-key" },
606
581
  });
607
- const client = {
608
- open: true,
609
- event: vi.fn(),
610
- playAudioChunk: vi.fn(),
611
- playAudioDone: vi.fn(),
612
- };
613
- // Should not throw — the API key gets passed to createS2sSession internally
582
+ const client = makeClientSink();
583
+ // Should not throw — the API key gets passed to createS2sTransport internally
614
584
  const session = runtime.createSession({
615
585
  id: "test-session",
616
586
  agent: agent.name,
@@ -651,7 +621,7 @@ describe("Runtime — session routing", () => {
651
621
  tts,
652
622
  });
653
623
 
654
- const client = makeClient();
624
+ const client = makeClientSink();
655
625
  const session = runtime.createSession({
656
626
  id: "sess-pipeline",
657
627
  agent: "test-agent",
@@ -677,10 +647,7 @@ describe("Runtime — session routing", () => {
677
647
 
678
648
  test("manifest without stt/llm/tts routes to S2sSession (createWebSocket IS called)", async () => {
679
649
  const mockHandle = makeMockHandle();
680
- const connectSpy = vi.spyOn(_internals, "connectS2s").mockImplementation(async () => {
681
- setTimeout(() => mockHandle._fire("ready", { sessionId: "mock-sid" }), 0);
682
- return mockHandle;
683
- });
650
+ const connectSpy = vi.spyOn(_internals, "connectS2s").mockResolvedValue(mockHandle);
684
651
 
685
652
  const createWebSocket = vi.fn();
686
653
  const runtime = createRuntime({
@@ -690,7 +657,7 @@ describe("Runtime — session routing", () => {
690
657
  createWebSocket,
691
658
  });
692
659
 
693
- const client = makeClient();
660
+ const client = makeClientSink();
694
661
  const session = runtime.createSession({
695
662
  id: "sess-s2s",
696
663
  agent: "test-agent",
package/host/runtime.ts CHANGED
@@ -15,6 +15,8 @@ import { DEFAULT_SHUTDOWN_TIMEOUT_MS } from "../sdk/constants.ts";
15
15
  import type { Kv } from "../sdk/kv.ts";
16
16
  import type { ClientSink } from "../sdk/protocol.ts";
17
17
  import { buildReadyConfig, type ReadyConfig } from "../sdk/protocol.ts";
18
+ import { DEEPGRAM_KIND } from "../sdk/providers/stt/deepgram.ts";
19
+ import { RIME_KIND } from "../sdk/providers/tts/rime.ts";
18
20
  import {
19
21
  assertProviderTriple,
20
22
  type LlmProvider,
@@ -23,19 +25,68 @@ import {
23
25
  type TtsOpener,
24
26
  type TtsProvider,
25
27
  } from "../sdk/providers.ts";
28
+ import { buildSystemPrompt } from "../sdk/system-prompt.ts";
26
29
  import type { AgentDef } from "../sdk/types.ts";
27
30
  import { toolError } from "../sdk/utils.ts";
28
31
  import { resolveAllBuiltins } from "./builtin-tools.ts";
29
- import { createPipelineSession } from "./pipeline-session.ts";
30
32
  import { resolveApiKey, resolveLlm, resolveStt, resolveTts } from "./providers/resolve.ts";
31
33
  import type { Logger, S2SConfig } from "./runtime-config.ts";
32
34
  import { consoleLogger, DEFAULT_S2S_CONFIG } from "./runtime-config.ts";
33
35
  import type { CreateS2sWebSocket } from "./s2s.ts";
34
- import { createS2sSession, type Session } from "./session.ts";
36
+ import { createSessionCore, type SessionCore } from "./session-core.ts";
35
37
  import { type ExecuteTool, executeToolCall } from "./tool-executor.ts";
38
+ import { createPipelineTransport } from "./transports/pipeline-transport.ts";
39
+ import { createS2sTransport } from "./transports/s2s-transport.ts";
40
+ import type { Transport, TransportCallbacks } from "./transports/types.ts";
36
41
  import { createUnstorageKv } from "./unstorage-kv.ts";
37
42
  import { type SessionWebSocket, wireSessionSocket } from "./ws-handler.ts";
38
43
 
44
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
45
+
46
+ /**
47
+ * Resolve the API key env-var for the configured STT provider.
48
+ *
49
+ * Each STT provider uses its own env var (e.g. `ASSEMBLYAI_API_KEY`,
50
+ * `DEEPGRAM_API_KEY`). We read the kind from the descriptor if it is one;
51
+ * pre-resolved openers have no kind field so we fall back to AssemblyAI for
52
+ * backward compatibility (openers supply their own key at open-time anyway).
53
+ */
54
+ function resolveSttApiKey(
55
+ stt: SttProvider | SttOpener | undefined,
56
+ env: Record<string, string>,
57
+ ): string {
58
+ // SttProvider descriptors carry a `kind` field; SttOpener does not.
59
+ const kind =
60
+ stt != null && "kind" in stt && typeof (stt as SttProvider).kind === "string"
61
+ ? (stt as SttProvider).kind
62
+ : undefined;
63
+ if (kind === DEEPGRAM_KIND) return resolveApiKey("DEEPGRAM_API_KEY", env);
64
+ // Default: ASSEMBLYAI_KIND or pre-resolved opener (backward compat).
65
+ return resolveApiKey("ASSEMBLYAI_API_KEY", env);
66
+ }
67
+
68
+ /**
69
+ * Resolve the API key env-var for the configured TTS provider.
70
+ *
71
+ * Each TTS provider uses its own env var (e.g. `CARTESIA_API_KEY`,
72
+ * `RIME_API_KEY`). We read the kind from the descriptor if it is one;
73
+ * pre-resolved openers have no kind field so we fall back to Cartesia for
74
+ * backward compatibility (openers supply their own key at open-time anyway).
75
+ */
76
+ function resolveTtsApiKey(
77
+ tts: TtsProvider | TtsOpener | undefined,
78
+ env: Record<string, string>,
79
+ ): string {
80
+ // TtsProvider descriptors carry a `kind` field; TtsOpener does not.
81
+ const kind =
82
+ tts != null && "kind" in tts && typeof (tts as TtsProvider).kind === "string"
83
+ ? (tts as TtsProvider).kind
84
+ : undefined;
85
+ if (kind === RIME_KIND) return resolveApiKey("RIME_API_KEY", env);
86
+ // Default: CARTESIA_KIND or pre-resolved opener (backward compat).
87
+ return resolveApiKey("CARTESIA_API_KEY", env);
88
+ }
89
+
39
90
  // ─── Runtime adapter (formerly adapter.ts) ──────────────────────────────────
40
91
 
41
92
  /** Per-session options passed to {@link AgentRuntime.startSession}. */
@@ -189,7 +240,7 @@ export type Runtime = AgentRuntime & {
189
240
  client: ClientSink;
190
241
  skipGreeting?: boolean;
191
242
  resumeFrom?: string;
192
- }): Session;
243
+ }): SessionCore;
193
244
  };
194
245
 
195
246
  /**
@@ -217,7 +268,7 @@ export function createRuntime(opts: RuntimeOptions): Runtime {
217
268
  } = opts;
218
269
  const mode = assertProviderTriple(opts.stt, opts.llm, opts.tts);
219
270
  const agentConfig = toAgentConfig(agent);
220
- const sessions = new Map<string, Session>();
271
+ const sessions = new Map<string, SessionCore>();
221
272
  const sinkMap = new Map<string, ClientSink>();
222
273
  const readyConfig: ReadyConfig = buildReadyConfig(s2sConfig);
223
274
 
@@ -310,44 +361,101 @@ export function createRuntime(opts: RuntimeOptions): Runtime {
310
361
  client: ClientSink;
311
362
  skipGreeting?: boolean;
312
363
  resumeFrom?: string;
313
- }): Session {
364
+ }): SessionCore {
314
365
  sinkMap.set(sessionOpts.id, sessionOpts.client);
366
+
367
+ const isPipeline = Boolean(pipelineProviders);
368
+ const hasTools = toolSchemas.length > 0 || (agentConfig.builtinTools?.length ?? 0) > 0;
369
+ const systemPrompt = buildSystemPrompt(agentConfig, {
370
+ hasTools,
371
+ voice: true,
372
+ toolGuidance,
373
+ });
374
+
375
+ // Late-bound reference: callbacks are constructed before SessionCore exists,
376
+ // so we capture a reference and fill it in below.
377
+ let core: SessionCore | null = null;
378
+ function bindCore(): SessionCore {
379
+ if (!core) throw new Error("SessionCore not yet created");
380
+ return core;
381
+ }
382
+
383
+ const callbacks: TransportCallbacks = {
384
+ onReplyStarted: (replyId) => bindCore().onReplyStarted(replyId),
385
+ onReplyDone: () => bindCore().onReplyDone(),
386
+ onCancelled: () => bindCore().onCancelled(),
387
+ onAudioChunk: (bytes) => bindCore().onAudioChunk(bytes),
388
+ onAudioDone: () => bindCore().onAudioDone(),
389
+ onUserTranscript: (text) => bindCore().onUserTranscript(text),
390
+ onAgentTranscript: (text, interrupted) => bindCore().onAgentTranscript(text, interrupted),
391
+ // Pipeline: tools execute inside streamText; forward the call to the
392
+ // client sink for UI observability only. Going through SessionCore.onToolCall
393
+ // would re-execute the tool and leave pendingTools non-empty, hanging the turn.
394
+ onToolCall: isPipeline
395
+ ? (id, name, args) =>
396
+ sessionOpts.client.event({ type: "tool_call", toolCallId: id, toolName: name, args })
397
+ : (id, name, args) => bindCore().onToolCall(id, name, args),
398
+ onError: (code, message) => bindCore().onError(code, message),
399
+ onSpeechStarted: () => bindCore().onSpeechStarted(),
400
+ onSpeechStopped: () => bindCore().onSpeechStopped(),
401
+ };
402
+
403
+ let transport: Transport;
315
404
  if (pipelineProviders) {
316
- return createPipelineSession({
317
- id: sessionOpts.id,
405
+ transport = createPipelineTransport({
406
+ sid: sessionOpts.id,
318
407
  agent: sessionOpts.agent,
319
- client: sessionOpts.client,
320
- agentConfig,
321
- toolSchemas,
322
- toolGuidance,
323
- executeTool,
324
408
  stt: pipelineProviders.stt,
325
409
  llm: pipelineProviders.llm,
326
410
  tts: pipelineProviders.tts,
327
- sttApiKey: resolveApiKey("ASSEMBLYAI_API_KEY", env),
328
- ttsApiKey: resolveApiKey("CARTESIA_API_KEY", env),
411
+ callbacks,
412
+ sessionConfig: {
413
+ systemPrompt,
414
+ greeting: agentConfig.greeting,
415
+ tools: toolSchemas,
416
+ },
417
+ toolSchemas,
418
+ executeTool,
419
+ providerKeys: {
420
+ stt: resolveSttApiKey(opts.stt, env),
421
+ tts: resolveTtsApiKey(opts.tts, env),
422
+ },
329
423
  sttSampleRate: s2sConfig.inputSampleRate,
330
424
  ttsSampleRate: s2sConfig.outputSampleRate,
425
+ maxSteps: agentConfig.maxSteps,
426
+ toolChoice: agentConfig.toolChoice,
331
427
  skipGreeting: sessionOpts.skipGreeting ?? false,
332
428
  logger,
333
429
  });
430
+ } else {
431
+ transport = createS2sTransport({
432
+ apiKey: env.ASSEMBLYAI_API_KEY ?? "",
433
+ s2sConfig,
434
+ sessionConfig: {
435
+ systemPrompt,
436
+ tools: toolSchemas as import("./s2s.ts").S2sToolSchema[],
437
+ ...(agentConfig.greeting !== undefined ? { greeting: agentConfig.greeting } : {}),
438
+ },
439
+ toolSchemas: toolSchemas as import("./s2s.ts").S2sToolSchema[],
440
+ callbacks,
441
+ sid: sessionOpts.id,
442
+ agent: sessionOpts.agent,
443
+ ...(createWebSocket ? { createWebSocket } : {}),
444
+ logger,
445
+ });
334
446
  }
335
- const apiKey = env.ASSEMBLYAI_API_KEY ?? "";
336
- return createS2sSession({
447
+
448
+ core = createSessionCore({
337
449
  id: sessionOpts.id,
338
450
  agent: sessionOpts.agent,
339
451
  client: sessionOpts.client,
340
452
  agentConfig,
341
- toolSchemas,
342
- toolGuidance,
343
- apiKey,
344
- s2sConfig,
345
453
  executeTool,
346
- ...(createWebSocket ? { createWebSocket } : {}),
347
- skipGreeting: sessionOpts.skipGreeting ?? false,
454
+ transport,
348
455
  logger,
349
- ...(sessionOpts.resumeFrom ? { resumeFrom: sessionOpts.resumeFrom } : {}),
350
456
  });
457
+
458
+ return core;
351
459
  }
352
460
 
353
461
  // ── AgentRuntime methods ──────────────────────────────────────────────