realtime-voice-agents 2.1.0 → 2.3.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/README.md +52 -2
- package/dist/gemini.cjs +14 -5
- package/dist/gemini.d.cts +6 -1
- package/dist/gemini.d.mts +6 -1
- package/dist/gemini.mjs +14 -5
- package/dist/index.cjs +215 -14
- package/dist/index.d.cts +187 -1
- package/dist/index.d.mts +187 -1
- package/dist/index.mjs +211 -15
- package/dist/openai.cjs +14 -5
- package/dist/openai.d.cts +8 -1
- package/dist/openai.d.mts +8 -1
- package/dist/openai.mjs +14 -5
- package/dist/testing.cjs +29 -2
- package/dist/testing.d.cts +30 -0
- package/dist/testing.d.mts +30 -0
- package/dist/testing.mjs +29 -2
- package/dist/xai.cjs +14 -5
- package/dist/xai.d.cts +6 -1
- package/dist/xai.d.mts +6 -1
- package/dist/xai.mjs +14 -5
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -104,6 +104,26 @@ geminiLive({ model: 'gemini-2.5-flash-native-audio-preview-12-2025', voice: 'Aoe
|
|
|
104
104
|
|
|
105
105
|
One `SessionOptions` surface configures all three; where a provider can't honor a knob, the fallback is documented and pinned by the parity test suite.
|
|
106
106
|
|
|
107
|
+
## Provider fallbacks
|
|
108
|
+
|
|
109
|
+
One bad API key, an exhausted quota, or a provider outage should not send your calls to dead air. Give the bridge backup providers and it tries them in order while the call is being established:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
const bridge = new TwilioRealtimeBridge({
|
|
113
|
+
agent,
|
|
114
|
+
provider: openaiRealtime(), // primary
|
|
115
|
+
fallbacks: [xaiRealtime(), geminiLive()], // tried in order if it fails to come up
|
|
116
|
+
});
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **Covers the real failure modes.** A missing API key (the factories defer their credential check to call time precisely so the chain can absorb it), an expired/revoked key (HTTP 401), exhausted credits/quota (403/429), a provider internal error (5xx or a dropped socket), and a hung endpoint (connect timeout) all walk the chain — anything that keeps a provider from coming up.
|
|
120
|
+
- **Connect-time only.** A dead provider is dropped and the next one is tried immediately — no backoff between attempts. Once a provider answers, the call stays with it: mid-call reconnects reuse the same provider (per the `session.reconnect` policy), and a mid-call death past that budget fails the call rather than switching voices mid-conversation.
|
|
121
|
+
- **Observable.** Each advance emits `provider.fallback` (`{ from, to, error }`) on the session — count these to alarm on a degraded primary.
|
|
122
|
+
- **Voices don't cross vendors.** Configure the voice per factory (`openaiRealtime({ voice: 'marin' })`, `xaiRealtime({ voice: 'eve' })`) rather than on the `Agent` — an OpenAI voice name would fail the xAI/Gemini connect and the chain would skip past a healthy provider.
|
|
123
|
+
- **Latency.** Each dead provider costs up to its `connectTimeoutMs` (default 10s) before the next is tried — set a tighter one on the primary if its endpoint tends to hang rather than refuse. A [pre-synthesized greeting](#pre-synthesized-greeting-15s-to-first-word) bursts onto the line before any handshake, so the caller hears a voice while the chain walks.
|
|
124
|
+
|
|
125
|
+
Testing it: `FakeOpenAIServer.start({ refuseConnections: true })` gives you a provider that is "down", and `{ rejectUpgrade: { status: 401, body: 'invalid_api_key' } }` one that rejects like a real auth/quota failure (both flippable at runtime to script recoveries) — see `src/bridge/fallback.test.ts` for ready-made scenarios.
|
|
126
|
+
|
|
107
127
|
## Tools: Zod schemas + execution strategies
|
|
108
128
|
|
|
109
129
|
```ts
|
|
@@ -250,9 +270,38 @@ session: { greeting: { mode: 'agent-initiates',
|
|
|
250
270
|
|
|
251
271
|
Bundled presets (all synthesized, license-free, seamless loops): `elevator-jazz`, `lofi`, `keyboard-typing`, `thinking-hum`, `ringing` — or `{ custom: bufferOrPath }` with your own 8 kHz μ-law. Drift-corrected 20 ms pacing, refcounted across concurrent tools, ~1 s start delay so fast tools stay silent, fade in/out, 60 s failsafe, and instant preemption when real speech arrives. Manual control: `session.playBackgroundAudio('lofi')` / `stopBackgroundAudio()`.
|
|
252
272
|
|
|
273
|
+
## Keypad input (DTMF, opt-in)
|
|
274
|
+
|
|
275
|
+
Callers type an ID, a phone number, a confirmation code — Twilio delivers each key as its own `dtmf` frame, ~1s apart, and a model that sees ten fragments answers "I didn't get that" ten times. `keypad` turns keypresses into one entry: digits buffer, `#` submits, `*` clears, `maxDigits` auto-submits, 4s without a key flushes what's there (so the agent can say "that's only 7 digits — again, please"). Each keypress stops the agent mid-sentence (typing means "I'm answering"), and the entry reaches the model as a **user** turn — `[keypad] I typed on my phone keypad: 0541234567 — 10 digits. Digit by digit: 0 5 4 …` — that triggers the response answering it. A short note appended to the agent instructions tells the model what `[keypad]` messages are.
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
session: {
|
|
279
|
+
keypad: {}, // {} = defaults below
|
|
280
|
+
// maxDigits: 9, // auto-submit at N digits (no # needed)
|
|
281
|
+
// interDigitTimeoutMs: 4000, submitKey: '#', clearKey: '*',
|
|
282
|
+
// interruptOnKeypress: true, // false: the agent keeps talking while the caller types
|
|
283
|
+
// message: (entry) => string | false, // wording of the injected user turn; false = events only
|
|
284
|
+
// clearMessage: string | false, // what the model hears on *; false = nothing
|
|
285
|
+
// instructions: string | false, // the appended note; false = your prompt says it
|
|
286
|
+
}
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
Observe or take over with events and the `session.keypad` handle (`digits`, `clear()`, `submit()`):
|
|
290
|
+
|
|
291
|
+
```ts
|
|
292
|
+
session.on('keypad.entry', ({ digits, reason }) => { /* reason: 'submit' | 'timeout' | 'maxDigits' */ });
|
|
293
|
+
session.on('keypad.cleared', ({ discarded }) => { /* caller pressed * */ });
|
|
294
|
+
// Raw keypresses still fire per key — AFTER the collector consumed them, so the handle is current:
|
|
295
|
+
session.on('dtmf', ({ digit }) => {
|
|
296
|
+
if (digit === '0' && session.keypad.digits === '0') { session.keypad.clear(); void session.transferTo(OPERATOR); }
|
|
297
|
+
});
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
`message: false` keeps the collection and events but injects nothing — validate the entry yourself and `session.sendText(..., { role: 'user', triggerResponse: true })` what the model should hear. Role `user`, not `system`: a trailing system item is skipped by the response it triggers and only lands one response later (field-tested on xAI). Without `keypad` configured nothing changes: raw `dtmf` events only, as before. Letters A–D are ignored; the buffer dies with the call.
|
|
301
|
+
|
|
253
302
|
## Events (session)
|
|
254
303
|
|
|
255
|
-
`call.started/ended/failed` · `provider.connected/reconnecting/reconnected/closed` · `agent.speech.started/ended` (generation) · **`playback.started/finished/interrupted`** (what the caller heard, mark-confirmed) · `user.speech.started/ended` · `transcript.user/agent` · `tool.started/completed/failed` · `tool.approval.required` · `agent.handoff` · `interruption` / `interruption.blocked` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `background_audio.started/stopped` · `dtmf` · `usage.updated` · `error`.
|
|
304
|
+
`call.started/ended/failed` · `provider.connected/fallback/reconnecting/reconnected/closed` · `agent.speech.started/ended` (generation) · **`playback.started/finished/interrupted`** (what the caller heard, mark-confirmed) · `user.speech.started/ended` · `transcript.user/agent` · `tool.started/completed/failed` · `tool.approval.required` · `agent.handoff` · `interruption` / `interruption.blocked` · `vad.suggestion` / `vad.adjusted` (noise-adaptive VAD) · `background_audio.started/stopped` · `dtmf` (raw keypress) · `keypad.entry` / `keypad.cleared` (keypad input) · `usage.updated` · `error`.
|
|
256
305
|
|
|
257
306
|
```ts
|
|
258
307
|
bridge.on('session.started', (session) => {
|
|
@@ -280,6 +329,7 @@ session: {
|
|
|
280
329
|
hangup: { markTimeoutMs: 7000 }, // goodbye watchdog
|
|
281
330
|
vad: undefined, // normalized VAD, mapped per provider
|
|
282
331
|
noiseAdaptiveVad: undefined, // opt-in noise → VAD escalation ({} enables; see its section)
|
|
332
|
+
keypad: undefined, // opt-in DTMF → one user turn per entry ({} enables; see its section)
|
|
283
333
|
toolResultDelivery: 'afterPlayback', // or 'immediate'
|
|
284
334
|
toolBackgroundAudio: undefined, // default hold audio for tools
|
|
285
335
|
handoffVoicePolicy: 'keep', // or 'reconnect' to switch voices
|
|
@@ -296,7 +346,7 @@ Outbound calls: the greeting waits for a human — feed your status callback int
|
|
|
296
346
|
`realtime-voice-agents/testing` ships the harness this package is tested with:
|
|
297
347
|
|
|
298
348
|
- **`FakeTwilioMediaStream`** — a scripted caller with an exact playout simulation: marks echo only after the media before them "plays"; `clear` discards buffered audio and echoes pending marks, like real Twilio.
|
|
299
|
-
- **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops).
|
|
349
|
+
- **`FakeOpenAIServer`** — a real-WebSocket GA-protocol server you script (`sendAudioResponse`, `sendToolCall`, `sendSpeechStarted`, drops, `refuseConnections` for down-provider/fallback scenarios).
|
|
300
350
|
- **`FakeGeminiLive`** — a scripted `@google/genai` seam for the Gemini provider.
|
|
301
351
|
|
|
302
352
|
```ts
|
package/dist/gemini.cjs
CHANGED
|
@@ -373,12 +373,14 @@ const GEMINI_VOICES = [
|
|
|
373
373
|
"Kore",
|
|
374
374
|
"Puck"
|
|
375
375
|
];
|
|
376
|
-
/**
|
|
376
|
+
/**
|
|
377
|
+
* Create a Gemini Live provider factory for the bridge. Credentials are
|
|
378
|
+
* resolved per call (see `openaiRealtime` — missing credentials fail that
|
|
379
|
+
* call's connect so a fallback chain can absorb it, instead of crashing
|
|
380
|
+
* config construction).
|
|
381
|
+
*/
|
|
377
382
|
function geminiLive(options = {}) {
|
|
378
|
-
const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
|
|
379
|
-
if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
|
|
380
383
|
const config = {
|
|
381
|
-
apiKey,
|
|
382
384
|
vertex: options.vertex,
|
|
383
385
|
model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
384
386
|
voice: options.voice ?? "Aoede",
|
|
@@ -391,7 +393,14 @@ function geminiLive(options = {}) {
|
|
|
391
393
|
connectTimeoutMs: options.connectTimeoutMs,
|
|
392
394
|
connector: options.connector
|
|
393
395
|
};
|
|
394
|
-
return ({ logger }) =>
|
|
396
|
+
return ({ logger }) => {
|
|
397
|
+
const apiKey = require_env.resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
|
|
398
|
+
if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
|
|
399
|
+
return new GeminiLiveProvider({
|
|
400
|
+
...config,
|
|
401
|
+
apiKey
|
|
402
|
+
}, logger);
|
|
403
|
+
};
|
|
395
404
|
}
|
|
396
405
|
//#endregion
|
|
397
406
|
exports.GEMINI_DEFAULT_MODEL = GEMINI_DEFAULT_MODEL;
|
package/dist/gemini.d.cts
CHANGED
|
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
|
|
|
35
35
|
/** Test seam: replaces the @google/genai live connection. */
|
|
36
36
|
connector?: GeminiLiveConnector;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Create a Gemini Live provider factory for the bridge. Credentials are
|
|
40
|
+
* resolved per call (see `openaiRealtime` — missing credentials fail that
|
|
41
|
+
* call's connect so a fallback chain can absorb it, instead of crashing
|
|
42
|
+
* config construction).
|
|
43
|
+
*/
|
|
39
44
|
declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
|
|
40
45
|
//#endregion
|
|
41
46
|
export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
|
package/dist/gemini.d.mts
CHANGED
|
@@ -35,7 +35,12 @@ interface GeminiLiveOptions {
|
|
|
35
35
|
/** Test seam: replaces the @google/genai live connection. */
|
|
36
36
|
connector?: GeminiLiveConnector;
|
|
37
37
|
}
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* Create a Gemini Live provider factory for the bridge. Credentials are
|
|
40
|
+
* resolved per call (see `openaiRealtime` — missing credentials fail that
|
|
41
|
+
* call's connect so a fallback chain can absorb it, instead of crashing
|
|
42
|
+
* config construction).
|
|
43
|
+
*/
|
|
39
44
|
declare function geminiLive(options?: GeminiLiveOptions): ProviderFactory;
|
|
40
45
|
//#endregion
|
|
41
46
|
export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, type GeminiConnectParams, type GeminiLiveConnector, GeminiLiveOptions, GeminiLiveProvider, type GeminiLiveProviderConfig, type GeminiLiveSessionLike, geminiLive };
|
package/dist/gemini.mjs
CHANGED
|
@@ -372,12 +372,14 @@ const GEMINI_VOICES = [
|
|
|
372
372
|
"Kore",
|
|
373
373
|
"Puck"
|
|
374
374
|
];
|
|
375
|
-
/**
|
|
375
|
+
/**
|
|
376
|
+
* Create a Gemini Live provider factory for the bridge. Credentials are
|
|
377
|
+
* resolved per call (see `openaiRealtime` — missing credentials fail that
|
|
378
|
+
* call's connect so a fallback chain can absorb it, instead of crashing
|
|
379
|
+
* config construction).
|
|
380
|
+
*/
|
|
376
381
|
function geminiLive(options = {}) {
|
|
377
|
-
const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
|
|
378
|
-
if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
|
|
379
382
|
const config = {
|
|
380
|
-
apiKey,
|
|
381
383
|
vertex: options.vertex,
|
|
382
384
|
model: options.model ?? "gemini-2.5-flash-native-audio-preview-12-2025",
|
|
383
385
|
voice: options.voice ?? "Aoede",
|
|
@@ -390,7 +392,14 @@ function geminiLive(options = {}) {
|
|
|
390
392
|
connectTimeoutMs: options.connectTimeoutMs,
|
|
391
393
|
connector: options.connector
|
|
392
394
|
};
|
|
393
|
-
return ({ logger }) =>
|
|
395
|
+
return ({ logger }) => {
|
|
396
|
+
const apiKey = resolveApiKey(options.apiKey, GEMINI_KEY_ENV_VARS);
|
|
397
|
+
if (!apiKey && !options.vertex && !options.connector) throw new Error(`geminiLive: credentials missing (pass apiKey, set one of ${GEMINI_KEY_ENV_VARS.join("/")}, or configure vertex)`);
|
|
398
|
+
return new GeminiLiveProvider({
|
|
399
|
+
...config,
|
|
400
|
+
apiKey
|
|
401
|
+
}, logger);
|
|
402
|
+
};
|
|
394
403
|
}
|
|
395
404
|
//#endregion
|
|
396
405
|
export { GEMINI_DEFAULT_MODEL, GEMINI_DEFAULT_VOICE, GEMINI_KEY_ENV_VARS, GEMINI_VOICES, GeminiLiveProvider, geminiLive };
|
package/dist/index.cjs
CHANGED
|
@@ -193,6 +193,105 @@ function createHandoffTool(target) {
|
|
|
193
193
|
});
|
|
194
194
|
}
|
|
195
195
|
//#endregion
|
|
196
|
+
//#region src/dtmf/KeypadCollector.ts
|
|
197
|
+
const DEFAULT_KEYPAD_OPTIONS = {
|
|
198
|
+
submitKey: "#",
|
|
199
|
+
clearKey: "*",
|
|
200
|
+
interDigitTimeoutMs: 4e3,
|
|
201
|
+
interruptOnKeypress: true
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Appended to the agent instructions when keypad input is enabled. Short and
|
|
205
|
+
* neutral on purpose: what the messages are, not how to run the dialog.
|
|
206
|
+
*/
|
|
207
|
+
const DEFAULT_KEYPAD_INSTRUCTIONS = "Keypad input: the caller may type on their phone keypad instead of speaking. Keypresses arrive as a user message starting with \"[keypad]\" that contains the typed digits — treat it as the caller's answer.";
|
|
208
|
+
/** Default text of the user turn injected for a completed entry. */
|
|
209
|
+
function defaultKeypadMessage(entry) {
|
|
210
|
+
const { digits } = entry;
|
|
211
|
+
return `[keypad] I typed on my phone keypad: ${digits} — ${digits.length} digit${digits.length === 1 ? "" : "s"}. Digit by digit: ${[...digits].join(" ")}`;
|
|
212
|
+
}
|
|
213
|
+
/** Default text of the user turn injected when the caller presses the clear key. */
|
|
214
|
+
const DEFAULT_KEYPAD_CLEAR_MESSAGE = "[keypad] I pressed star — I want to start over and retype from the beginning.";
|
|
215
|
+
var KeypadCollector = class {
|
|
216
|
+
submitKey;
|
|
217
|
+
clearKey;
|
|
218
|
+
interDigitTimeoutMs;
|
|
219
|
+
maxDigits;
|
|
220
|
+
hooks;
|
|
221
|
+
buffer = "";
|
|
222
|
+
timer = null;
|
|
223
|
+
disposed = false;
|
|
224
|
+
constructor(options, hooks) {
|
|
225
|
+
this.submitKey = options.submitKey ?? DEFAULT_KEYPAD_OPTIONS.submitKey;
|
|
226
|
+
this.clearKey = options.clearKey ?? DEFAULT_KEYPAD_OPTIONS.clearKey;
|
|
227
|
+
this.interDigitTimeoutMs = options.interDigitTimeoutMs ?? DEFAULT_KEYPAD_OPTIONS.interDigitTimeoutMs;
|
|
228
|
+
this.maxDigits = options.maxDigits !== void 0 && options.maxDigits > 0 ? options.maxDigits : void 0;
|
|
229
|
+
this.hooks = hooks;
|
|
230
|
+
}
|
|
231
|
+
get digits() {
|
|
232
|
+
return this.buffer;
|
|
233
|
+
}
|
|
234
|
+
/** Feed one keypress (a Twilio `dtmf` frame). Returns what the key meant. */
|
|
235
|
+
press(key) {
|
|
236
|
+
if (this.disposed) return "ignored";
|
|
237
|
+
if (key === this.submitKey) {
|
|
238
|
+
this.cancelTimer();
|
|
239
|
+
this.complete("submit");
|
|
240
|
+
return "submit";
|
|
241
|
+
}
|
|
242
|
+
if (key === this.clearKey) {
|
|
243
|
+
this.cancelTimer();
|
|
244
|
+
const discarded = this.buffer;
|
|
245
|
+
this.buffer = "";
|
|
246
|
+
this.hooks.onClear({ discarded });
|
|
247
|
+
return "clear";
|
|
248
|
+
}
|
|
249
|
+
if (!/^[0-9]$/.test(key)) return "ignored";
|
|
250
|
+
this.cancelTimer();
|
|
251
|
+
this.buffer += key;
|
|
252
|
+
if (this.maxDigits !== void 0 && this.buffer.length >= this.maxDigits) this.complete("maxDigits");
|
|
253
|
+
else this.armTimer();
|
|
254
|
+
return "digit";
|
|
255
|
+
}
|
|
256
|
+
clear() {
|
|
257
|
+
this.cancelTimer();
|
|
258
|
+
this.buffer = "";
|
|
259
|
+
}
|
|
260
|
+
submit() {
|
|
261
|
+
if (this.disposed) return;
|
|
262
|
+
this.cancelTimer();
|
|
263
|
+
this.complete("submit");
|
|
264
|
+
}
|
|
265
|
+
/** Release the timer; pending digits are dropped (the call is over). */
|
|
266
|
+
dispose() {
|
|
267
|
+
this.disposed = true;
|
|
268
|
+
this.cancelTimer();
|
|
269
|
+
this.buffer = "";
|
|
270
|
+
}
|
|
271
|
+
complete(reason) {
|
|
272
|
+
if (!this.buffer) return;
|
|
273
|
+
const digits = this.buffer;
|
|
274
|
+
this.buffer = "";
|
|
275
|
+
this.hooks.onEntry({
|
|
276
|
+
digits,
|
|
277
|
+
reason
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
armTimer() {
|
|
281
|
+
this.timer = setTimeout(() => {
|
|
282
|
+
this.timer = null;
|
|
283
|
+
this.complete("timeout");
|
|
284
|
+
}, this.interDigitTimeoutMs);
|
|
285
|
+
this.timer.unref?.();
|
|
286
|
+
}
|
|
287
|
+
cancelTimer() {
|
|
288
|
+
if (this.timer) {
|
|
289
|
+
clearTimeout(this.timer);
|
|
290
|
+
this.timer = null;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
};
|
|
294
|
+
//#endregion
|
|
196
295
|
//#region src/interruption/InterruptionController.ts
|
|
197
296
|
var InterruptionController = class {
|
|
198
297
|
settings;
|
|
@@ -893,11 +992,17 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
893
992
|
streamSid;
|
|
894
993
|
callInfo;
|
|
895
994
|
context;
|
|
995
|
+
/**
|
|
996
|
+
* Keypad (DTMF) input handle: digits buffered so far, `clear()`, `submit()`.
|
|
997
|
+
* Inert (empty, no-ops) unless the `keypad` session option is configured.
|
|
998
|
+
*/
|
|
999
|
+
keypad;
|
|
896
1000
|
stateValue = "connecting";
|
|
897
1001
|
deps;
|
|
898
1002
|
log;
|
|
899
1003
|
tracker = new PlaybackTracker();
|
|
900
1004
|
interruptions;
|
|
1005
|
+
keypadCollector;
|
|
901
1006
|
usageAccumulator = new UsageAccumulator();
|
|
902
1007
|
toolQueue = new ToolResultQueue();
|
|
903
1008
|
transcriptEntries = [];
|
|
@@ -907,6 +1012,8 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
907
1012
|
runningTools = /* @__PURE__ */ new Map();
|
|
908
1013
|
timers = /* @__PURE__ */ new Set();
|
|
909
1014
|
provider = null;
|
|
1015
|
+
/** Primary + fallbacks, in try-order. Only walked while connecting. */
|
|
1016
|
+
providerChain;
|
|
910
1017
|
activeAgentValue;
|
|
911
1018
|
generating = false;
|
|
912
1019
|
currentResponseId = null;
|
|
@@ -965,9 +1072,22 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
965
1072
|
this.callSid = deps.start.start.callSid;
|
|
966
1073
|
this.streamSid = deps.start.start.streamSid ?? deps.start.streamSid;
|
|
967
1074
|
this.log = require_BaseRealtimeProvider.childLogger(deps.logger, { callSid: this.callSid });
|
|
1075
|
+
this.providerChain = [deps.providerFactory, ...deps.fallbacks ?? []];
|
|
968
1076
|
this.activeAgentValue = deps.agent;
|
|
969
1077
|
this.context = new SessionContext(deps.options.context);
|
|
970
1078
|
this.interruptions = new InterruptionController(deps.options.interruptions);
|
|
1079
|
+
this.keypadCollector = deps.options.keypad ? new KeypadCollector(deps.options.keypad, {
|
|
1080
|
+
onEntry: (entry) => this.onKeypadEntry(entry),
|
|
1081
|
+
onClear: (info) => this.onKeypadCleared(info)
|
|
1082
|
+
}) : null;
|
|
1083
|
+
const collector = this.keypadCollector;
|
|
1084
|
+
this.keypad = {
|
|
1085
|
+
get digits() {
|
|
1086
|
+
return collector?.digits ?? "";
|
|
1087
|
+
},
|
|
1088
|
+
clear: () => collector?.clear(),
|
|
1089
|
+
submit: () => collector?.submit()
|
|
1090
|
+
};
|
|
971
1091
|
const params = deps.start.start.customParameters ?? {};
|
|
972
1092
|
this.callInfo = {
|
|
973
1093
|
direction: params.direction === "outbound" ? "outbound" : "inbound",
|
|
@@ -1003,17 +1123,7 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1003
1123
|
/** Connect the provider and activate the call. Called by the bridge. */
|
|
1004
1124
|
async begin() {
|
|
1005
1125
|
this.playPreGreeting();
|
|
1006
|
-
|
|
1007
|
-
this.provider = this.deps.providerFactory({
|
|
1008
|
-
logger: this.log,
|
|
1009
|
-
callSid: this.callSid
|
|
1010
|
-
});
|
|
1011
|
-
this.wireProvider(this.provider);
|
|
1012
|
-
await this.provider.connect(this.buildProviderInit());
|
|
1013
|
-
} catch (error) {
|
|
1014
|
-
this.fail(error instanceof Error ? error : new Error(String(error)), "provider-failed");
|
|
1015
|
-
return;
|
|
1016
|
-
}
|
|
1126
|
+
if (!await this.connectInitialProvider()) return;
|
|
1017
1127
|
if (this.stateValue !== "connecting") return;
|
|
1018
1128
|
this.stateValue = "active";
|
|
1019
1129
|
this.emit("provider.connected");
|
|
@@ -1039,6 +1149,55 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1039
1149
|
this.saveSnapshot();
|
|
1040
1150
|
this.maybeGreet();
|
|
1041
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* Walk primary + fallbacks until one connects. A provider that fails to
|
|
1154
|
+
* come up is detached BEFORE the chain advances, so a half-open socket's
|
|
1155
|
+
* late events can never reach the session once the next provider owns the
|
|
1156
|
+
* call. Connect-time only by design: once a provider has answered, the
|
|
1157
|
+
* call stays with it. Returns false after failing the call (chain
|
|
1158
|
+
* exhausted) or when the call was torn down while connecting.
|
|
1159
|
+
*/
|
|
1160
|
+
async connectInitialProvider() {
|
|
1161
|
+
let lastFrom = "provider";
|
|
1162
|
+
let lastError = null;
|
|
1163
|
+
for (const factory of this.providerChain) {
|
|
1164
|
+
let provider;
|
|
1165
|
+
try {
|
|
1166
|
+
provider = factory({
|
|
1167
|
+
logger: this.log,
|
|
1168
|
+
callSid: this.callSid
|
|
1169
|
+
});
|
|
1170
|
+
} catch (error) {
|
|
1171
|
+
lastError = toError(error);
|
|
1172
|
+
this.log.warn("provider factory threw — trying the next fallback", { error: String(lastError) });
|
|
1173
|
+
continue;
|
|
1174
|
+
}
|
|
1175
|
+
if (lastError) this.emit("provider.fallback", {
|
|
1176
|
+
from: lastFrom,
|
|
1177
|
+
to: provider.name,
|
|
1178
|
+
error: lastError
|
|
1179
|
+
});
|
|
1180
|
+
this.provider = provider;
|
|
1181
|
+
this.wireProvider(provider);
|
|
1182
|
+
try {
|
|
1183
|
+
await provider.connect(this.buildProviderInit());
|
|
1184
|
+
return true;
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
provider.removeAllListeners();
|
|
1187
|
+
provider.close().catch(() => {});
|
|
1188
|
+
this.provider = null;
|
|
1189
|
+
if (this.stateValue !== "connecting") return false;
|
|
1190
|
+
lastFrom = provider.name;
|
|
1191
|
+
lastError = toError(error);
|
|
1192
|
+
this.log.warn("provider failed to connect", {
|
|
1193
|
+
provider: provider.name,
|
|
1194
|
+
error: String(lastError)
|
|
1195
|
+
});
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
this.fail(lastError ?? /* @__PURE__ */ new Error("provider connect failed"), "provider-failed");
|
|
1199
|
+
return false;
|
|
1200
|
+
}
|
|
1042
1201
|
/** The outbound leg was answered (host's Twilio status callback). */
|
|
1043
1202
|
notifyAnswered() {
|
|
1044
1203
|
if (this.answered) return;
|
|
@@ -1170,9 +1329,16 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1170
1329
|
}
|
|
1171
1330
|
return tools;
|
|
1172
1331
|
}
|
|
1332
|
+
/** Agent instructions plus the kit's own notes that must survive handoffs (keypad). */
|
|
1333
|
+
composeInstructions() {
|
|
1334
|
+
const base = this.activeAgentValue.resolveInstructions(this.context);
|
|
1335
|
+
const keypad = this.deps.options.keypad;
|
|
1336
|
+
if (!keypad || keypad.instructions === false) return base;
|
|
1337
|
+
return `${base}\n\n${keypad.instructions ?? "Keypad input: the caller may type on their phone keypad instead of speaking. Keypresses arrive as a user message starting with \"[keypad]\" that contains the typed digits — treat it as the caller's answer."}`;
|
|
1338
|
+
}
|
|
1173
1339
|
buildProviderInit() {
|
|
1174
1340
|
return {
|
|
1175
|
-
instructions: this.
|
|
1341
|
+
instructions: this.composeInstructions() + (this.pregreeting ? `\n\nYou already opened the call by saying: "${this.pregreeting.text}". Do not greet again — continue the conversation from there.` : ""),
|
|
1176
1342
|
voice: this.activeAgentValue.voice,
|
|
1177
1343
|
vad: this.vadOverride !== void 0 ? this.vadOverride : this.deps.options.vad,
|
|
1178
1344
|
bridgeOwnsInterruptions: true,
|
|
@@ -1195,7 +1361,9 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1195
1361
|
transport.on("dtmf", (event) => {
|
|
1196
1362
|
this.clearIdleTimer();
|
|
1197
1363
|
this.nudgeCount = 0;
|
|
1198
|
-
|
|
1364
|
+
const digit = event.dtmf.digit;
|
|
1365
|
+
this.handleKeypress(digit);
|
|
1366
|
+
this.emit("dtmf", { digit });
|
|
1199
1367
|
});
|
|
1200
1368
|
transport.on("stop", () => void this.teardown("caller-hangup"));
|
|
1201
1369
|
transport.on("close", () => void this.teardown("caller-hangup"));
|
|
@@ -1969,7 +2137,7 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
1969
2137
|
if (!(!this.provider.capabilities.sessionUpdate || voiceChanges && !this.provider.capabilities.voiceChangeMidSession && this.deps.options.handoffVoicePolicy === "reconnect")) {
|
|
1970
2138
|
if (voiceChanges && !this.provider.capabilities.voiceChangeMidSession) this.log.warn(`agent "${target.id}" declares voice "${target.voice}" but the provider cannot change voice mid-session — keeping the current voice (set handoffVoicePolicy: 'reconnect' to switch)`);
|
|
1971
2139
|
await this.provider.updateSession({
|
|
1972
|
-
instructions: this.
|
|
2140
|
+
instructions: this.composeInstructions(),
|
|
1973
2141
|
tools: this.buildProviderInit().tools,
|
|
1974
2142
|
providerOptions: target.providerOptions
|
|
1975
2143
|
});
|
|
@@ -2045,6 +2213,29 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
2045
2213
|
this.transcriptEntries.push(entry);
|
|
2046
2214
|
this.emit("transcript.agent", entry);
|
|
2047
2215
|
}
|
|
2216
|
+
handleKeypress(key) {
|
|
2217
|
+
if (!this.keypadCollector) return;
|
|
2218
|
+
if (this.deps.options.keypad?.interruptOnKeypress !== false) this.interrupt();
|
|
2219
|
+
this.keypadCollector.press(key);
|
|
2220
|
+
}
|
|
2221
|
+
onKeypadEntry(entry) {
|
|
2222
|
+
this.emit("keypad.entry", entry);
|
|
2223
|
+
const message = this.deps.options.keypad?.message;
|
|
2224
|
+
if (message === false) return;
|
|
2225
|
+
this.provider?.sendText((message ?? defaultKeypadMessage)(entry), {
|
|
2226
|
+
role: "user",
|
|
2227
|
+
triggerResponse: true
|
|
2228
|
+
});
|
|
2229
|
+
}
|
|
2230
|
+
onKeypadCleared(info) {
|
|
2231
|
+
this.emit("keypad.cleared", info);
|
|
2232
|
+
const clearMessage = this.deps.options.keypad?.clearMessage;
|
|
2233
|
+
if (clearMessage === false) return;
|
|
2234
|
+
this.provider?.sendText(clearMessage ?? "[keypad] I pressed star — I want to start over and retype from the beginning.", {
|
|
2235
|
+
role: "user",
|
|
2236
|
+
triggerResponse: true
|
|
2237
|
+
});
|
|
2238
|
+
}
|
|
2048
2239
|
/** Armed whenever the agent goes quiet and we're waiting on the caller. */
|
|
2049
2240
|
armIdleTimer() {
|
|
2050
2241
|
const idle = this.deps.options.idle;
|
|
@@ -2104,6 +2295,7 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
2104
2295
|
for (const timer of this.timers) clearTimeout(timer);
|
|
2105
2296
|
this.timers.clear();
|
|
2106
2297
|
this.clearIdleTimer();
|
|
2298
|
+
this.keypadCollector?.dispose();
|
|
2107
2299
|
this.bgAudio.stop({ immediate: true });
|
|
2108
2300
|
for (const controller of this.runningTools.values()) controller.abort(/* @__PURE__ */ new Error("call ended"));
|
|
2109
2301
|
this.runningTools.clear();
|
|
@@ -2159,6 +2351,9 @@ var CallSession = class extends require_events.TypedEmitter {
|
|
|
2159
2351
|
const PREGREETING_MARK = "pre:greeting";
|
|
2160
2352
|
/** 400ms per frame — matches production burst-write implementations. */
|
|
2161
2353
|
const PREGREETING_CHUNK_BYTES = 3200;
|
|
2354
|
+
function toError(value) {
|
|
2355
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
2356
|
+
}
|
|
2162
2357
|
function safeJsonStringify(value) {
|
|
2163
2358
|
try {
|
|
2164
2359
|
return JSON.stringify(value) ?? "null";
|
|
@@ -2293,6 +2488,7 @@ var TwilioRealtimeBridge = class extends require_events.TypedEmitter {
|
|
|
2293
2488
|
transport,
|
|
2294
2489
|
start,
|
|
2295
2490
|
providerFactory: this.config.provider,
|
|
2491
|
+
fallbacks: this.config.fallbacks,
|
|
2296
2492
|
agent,
|
|
2297
2493
|
options,
|
|
2298
2494
|
store: this.store,
|
|
@@ -2410,10 +2606,14 @@ async function captureGreetingAudio(options) {
|
|
|
2410
2606
|
exports.Agent = Agent;
|
|
2411
2607
|
exports.BaseRealtimeProvider = require_BaseRealtimeProvider.BaseRealtimeProvider;
|
|
2412
2608
|
exports.CallSession = CallSession;
|
|
2609
|
+
exports.DEFAULT_KEYPAD_CLEAR_MESSAGE = DEFAULT_KEYPAD_CLEAR_MESSAGE;
|
|
2610
|
+
exports.DEFAULT_KEYPAD_INSTRUCTIONS = DEFAULT_KEYPAD_INSTRUCTIONS;
|
|
2611
|
+
exports.DEFAULT_KEYPAD_OPTIONS = DEFAULT_KEYPAD_OPTIONS;
|
|
2413
2612
|
exports.DEFAULT_RECONNECT_POLICY = DEFAULT_RECONNECT_POLICY;
|
|
2414
2613
|
exports.DEFAULT_SESSION_OPTIONS = DEFAULT_SESSION_OPTIONS;
|
|
2415
2614
|
exports.InMemorySessionStore = require_InMemorySessionStore.InMemorySessionStore;
|
|
2416
2615
|
exports.InterruptionController = InterruptionController;
|
|
2616
|
+
exports.KeypadCollector = KeypadCollector;
|
|
2417
2617
|
exports.NoiseAdaptiveVadController = NoiseAdaptiveVadController;
|
|
2418
2618
|
exports.PlaybackTracker = PlaybackTracker;
|
|
2419
2619
|
exports.SessionContext = SessionContext;
|
|
@@ -2427,6 +2627,7 @@ exports.createFinishCallTool = createFinishCallTool;
|
|
|
2427
2627
|
exports.createHandoffTool = createHandoffTool;
|
|
2428
2628
|
exports.createTransferCallTool = createTransferCallTool;
|
|
2429
2629
|
exports.decorateTool = decorateTool;
|
|
2630
|
+
exports.defaultKeypadMessage = defaultKeypadMessage;
|
|
2430
2631
|
exports.emptyUsage = emptyUsage;
|
|
2431
2632
|
exports.handoffToolName = handoffToolName;
|
|
2432
2633
|
exports.isHandoffDirective = isHandoffDirective;
|