@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.
- package/.turbo/turbo-build.log +9 -9
- package/CHANGELOG.md +13 -0
- package/dist/assemblyai-C969QGi4.js +35 -0
- package/dist/cartesia-BfQPOQ7Y.js +37 -0
- package/dist/host/_pipeline-test-fakes.d.ts +3 -1
- package/dist/host/providers/stt/deepgram.d.ts +28 -0
- package/dist/host/providers/tts/cartesia.d.ts +1 -1
- package/dist/host/providers/tts/rime.d.ts +44 -0
- package/dist/host/runtime-barrel.d.ts +4 -2
- package/dist/host/runtime-barrel.js +1432 -1208
- package/dist/host/runtime.d.ts +2 -2
- package/dist/host/s2s.d.ts +16 -16
- package/dist/host/session-core.d.ts +37 -0
- package/dist/host/transports/pipeline-transport.d.ts +48 -0
- package/dist/host/transports/s2s-transport.d.ts +19 -0
- package/dist/host/transports/types.d.ts +45 -0
- package/dist/host/ws-handler.d.ts +14 -10
- package/dist/sdk/protocol.d.ts +6 -5
- package/dist/sdk/providers/llm-barrel.js +1 -1
- package/dist/sdk/providers/stt/deepgram.d.ts +35 -0
- package/dist/sdk/providers/stt-barrel.d.ts +1 -0
- package/dist/sdk/providers/stt-barrel.js +2 -2
- package/dist/sdk/providers/tts/cartesia.d.ts +12 -4
- package/dist/sdk/providers/tts/rime.d.ts +42 -0
- package/dist/sdk/providers/tts-barrel.d.ts +1 -0
- package/dist/sdk/providers/tts-barrel.js +2 -2
- package/host/_pipeline-test-fakes.ts +6 -3
- package/host/_test-utils.ts +209 -128
- package/host/cleanup.test.ts +25 -298
- package/host/integration/pipeline-reference.integration.test.ts +30 -35
- package/host/providers/resolve.ts +10 -2
- package/host/providers/stt/deepgram.test.ts +229 -0
- package/host/providers/stt/deepgram.ts +172 -0
- package/host/providers/tts/cartesia.ts +7 -3
- package/host/providers/tts/rime.test.ts +251 -0
- package/host/providers/tts/rime.ts +322 -0
- package/host/runtime-barrel.ts +4 -2
- package/host/runtime.test.ts +13 -46
- package/host/runtime.ts +131 -23
- package/host/s2s.test.ts +122 -131
- package/host/s2s.ts +44 -52
- package/host/session-core.test.ts +257 -0
- package/host/session-core.ts +262 -0
- package/host/transports/pipeline-transport.test.ts +651 -0
- package/host/transports/pipeline-transport.ts +532 -0
- package/host/{fixture-replay.test.ts → transports/s2s-transport-fixtures.test.ts} +76 -106
- package/host/transports/s2s-transport.test.ts +56 -0
- package/host/transports/s2s-transport.ts +116 -0
- package/host/transports/types.test.ts +22 -0
- package/host/transports/types.ts +51 -0
- package/host/ws-handler.test.ts +324 -242
- package/host/ws-handler.ts +56 -59
- package/package.json +2 -1
- package/sdk/__snapshots__/exports.test.ts.snap +3 -3
- package/sdk/protocol-compat.test.ts +8 -0
- package/sdk/protocol.ts +6 -5
- package/sdk/providers/stt/deepgram.ts +43 -0
- package/sdk/providers/stt-barrel.ts +2 -0
- package/sdk/providers/tts/cartesia.ts +15 -5
- package/sdk/providers/tts/rime.ts +52 -0
- package/sdk/providers/tts-barrel.ts +2 -0
- package/dist/assemblyai-Cxg9eobY.js +0 -18
- package/dist/cartesia-DwDk2tEu.js +0 -10
- package/dist/host/pipeline-session-ctx.d.ts +0 -24
- package/dist/host/pipeline-session.d.ts +0 -52
- package/dist/host/session-ctx.d.ts +0 -73
- package/dist/host/session.d.ts +0 -62
- package/host/pipeline-session-ctx.test.ts +0 -31
- package/host/pipeline-session-ctx.ts +0 -36
- package/host/pipeline-session.test.ts +0 -672
- package/host/pipeline-session.ts +0 -533
- package/host/s2s-fixtures.test.ts +0 -237
- package/host/session-ctx.test.ts +0 -387
- package/host/session-ctx.ts +0 -134
- package/host/session-fixture-replay.test.ts +0 -128
- package/host/session.test.ts +0 -634
- package/host/session.ts +0 -412
- /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
|
+
}
|
package/host/runtime-barrel.ts
CHANGED
|
@@ -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";
|
package/host/runtime.test.ts
CHANGED
|
@@ -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,
|
|
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").
|
|
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").
|
|
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").
|
|
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
|
-
|
|
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 =
|
|
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").
|
|
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 =
|
|
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 {
|
|
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
|
-
}):
|
|
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,
|
|
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
|
-
}):
|
|
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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
336
|
-
|
|
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
|
-
|
|
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 ──────────────────────────────────────────────
|