realtime-voice-agents 2.2.0 → 2.4.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/dist/index.mjs CHANGED
@@ -189,6 +189,105 @@ function createHandoffTool(target) {
189
189
  });
190
190
  }
191
191
  //#endregion
192
+ //#region src/dtmf/KeypadCollector.ts
193
+ const DEFAULT_KEYPAD_OPTIONS = {
194
+ submitKey: "#",
195
+ clearKey: "*",
196
+ interDigitTimeoutMs: 4e3,
197
+ interruptOnKeypress: true
198
+ };
199
+ /**
200
+ * Appended to the agent instructions when keypad input is enabled. Short and
201
+ * neutral on purpose: what the messages are, not how to run the dialog.
202
+ */
203
+ 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.";
204
+ /** Default text of the user turn injected for a completed entry. */
205
+ function defaultKeypadMessage(entry) {
206
+ const { digits } = entry;
207
+ return `[keypad] I typed on my phone keypad: ${digits} — ${digits.length} digit${digits.length === 1 ? "" : "s"}. Digit by digit: ${[...digits].join(" ")}`;
208
+ }
209
+ /** Default text of the user turn injected when the caller presses the clear key. */
210
+ const DEFAULT_KEYPAD_CLEAR_MESSAGE = "[keypad] I pressed star — I want to start over and retype from the beginning.";
211
+ var KeypadCollector = class {
212
+ submitKey;
213
+ clearKey;
214
+ interDigitTimeoutMs;
215
+ maxDigits;
216
+ hooks;
217
+ buffer = "";
218
+ timer = null;
219
+ disposed = false;
220
+ constructor(options, hooks) {
221
+ this.submitKey = options.submitKey ?? DEFAULT_KEYPAD_OPTIONS.submitKey;
222
+ this.clearKey = options.clearKey ?? DEFAULT_KEYPAD_OPTIONS.clearKey;
223
+ this.interDigitTimeoutMs = options.interDigitTimeoutMs ?? DEFAULT_KEYPAD_OPTIONS.interDigitTimeoutMs;
224
+ this.maxDigits = options.maxDigits !== void 0 && options.maxDigits > 0 ? options.maxDigits : void 0;
225
+ this.hooks = hooks;
226
+ }
227
+ get digits() {
228
+ return this.buffer;
229
+ }
230
+ /** Feed one keypress (a Twilio `dtmf` frame). Returns what the key meant. */
231
+ press(key) {
232
+ if (this.disposed) return "ignored";
233
+ if (key === this.submitKey) {
234
+ this.cancelTimer();
235
+ this.complete("submit");
236
+ return "submit";
237
+ }
238
+ if (key === this.clearKey) {
239
+ this.cancelTimer();
240
+ const discarded = this.buffer;
241
+ this.buffer = "";
242
+ this.hooks.onClear({ discarded });
243
+ return "clear";
244
+ }
245
+ if (!/^[0-9]$/.test(key)) return "ignored";
246
+ this.cancelTimer();
247
+ this.buffer += key;
248
+ if (this.maxDigits !== void 0 && this.buffer.length >= this.maxDigits) this.complete("maxDigits");
249
+ else this.armTimer();
250
+ return "digit";
251
+ }
252
+ clear() {
253
+ this.cancelTimer();
254
+ this.buffer = "";
255
+ }
256
+ submit() {
257
+ if (this.disposed) return;
258
+ this.cancelTimer();
259
+ this.complete("submit");
260
+ }
261
+ /** Release the timer; pending digits are dropped (the call is over). */
262
+ dispose() {
263
+ this.disposed = true;
264
+ this.cancelTimer();
265
+ this.buffer = "";
266
+ }
267
+ complete(reason) {
268
+ if (!this.buffer) return;
269
+ const digits = this.buffer;
270
+ this.buffer = "";
271
+ this.hooks.onEntry({
272
+ digits,
273
+ reason
274
+ });
275
+ }
276
+ armTimer() {
277
+ this.timer = setTimeout(() => {
278
+ this.timer = null;
279
+ this.complete("timeout");
280
+ }, this.interDigitTimeoutMs);
281
+ this.timer.unref?.();
282
+ }
283
+ cancelTimer() {
284
+ if (this.timer) {
285
+ clearTimeout(this.timer);
286
+ this.timer = null;
287
+ }
288
+ }
289
+ };
290
+ //#endregion
192
291
  //#region src/interruption/InterruptionController.ts
193
292
  var InterruptionController = class {
194
293
  settings;
@@ -697,9 +796,34 @@ function delayForAttempt(policy, attempt, random = Math.random) {
697
796
  }
698
797
  //#endregion
699
798
  //#region src/session/transcript.ts
700
- /** Render a transcript for history re-injection after reconnect/handoff. */
701
- function formatTranscriptForInjection(entries, maxTurns = 30) {
702
- return entries.slice(-maxTurns).map((entry) => `${entry.role === "user" ? "Caller" : "Agent"}: ${entry.text}`).join("\n");
799
+ /**
800
+ * Render a transcript for history re-injection after reconnect/handoff.
801
+ *
802
+ * Agent lines are attributed to the agent that said them and completed
803
+ * transfers are interleaved as `[transfer]` lines: an incoming agent handed a
804
+ * flat `Agent:` dialogue re-derives intent from scratch, decides the request
805
+ * belongs to somebody else, and transfers on — agents ping-ponging with no
806
+ * caller turn between them (field bug, Aug 2026). Replaying WHO said what and
807
+ * WHAT was already routed is what stops the second lap.
808
+ */
809
+ function formatTranscriptForInjection(entries, options = {}) {
810
+ const { maxTurns = 30, agentNames, handoffs = [] } = options;
811
+ const recent = entries.slice(-maxTurns);
812
+ if (recent.length === 0) return "";
813
+ const nameOf = (agentId) => (agentId ? agentNames?.get(agentId) : void 0) ?? agentId ?? "Agent";
814
+ const windowStartMs = recent[0].timestampMs;
815
+ const lines = recent.map((entry) => ({
816
+ atMs: entry.timestampMs,
817
+ text: entry.role === "user" ? `Caller: ${entry.text}` : `${nameOf(entry.agentId)}: ${entry.text}`
818
+ }));
819
+ for (const handoff of handoffs) {
820
+ if (handoff.atMs < windowStartMs) continue;
821
+ lines.push({
822
+ atMs: handoff.atMs,
823
+ text: `[transfer] ${nameOf(handoff.from)} -> ${nameOf(handoff.to)}` + (handoff.reason ? ` (reason: ${handoff.reason})` : "")
824
+ });
825
+ }
826
+ return lines.sort((a, b) => a.atMs - b.atMs).map((line) => line.text).join("\n");
703
827
  }
704
828
  //#endregion
705
829
  //#region src/session/usage.ts
@@ -889,11 +1013,17 @@ var CallSession = class extends TypedEmitter {
889
1013
  streamSid;
890
1014
  callInfo;
891
1015
  context;
1016
+ /**
1017
+ * Keypad (DTMF) input handle: digits buffered so far, `clear()`, `submit()`.
1018
+ * Inert (empty, no-ops) unless the `keypad` session option is configured.
1019
+ */
1020
+ keypad;
892
1021
  stateValue = "connecting";
893
1022
  deps;
894
1023
  log;
895
1024
  tracker = new PlaybackTracker();
896
1025
  interruptions;
1026
+ keypadCollector;
897
1027
  usageAccumulator = new UsageAccumulator();
898
1028
  toolQueue = new ToolResultQueue();
899
1029
  transcriptEntries = [];
@@ -940,6 +1070,15 @@ var CallSession = class extends TypedEmitter {
940
1070
  agents;
941
1071
  handoffHistory = [];
942
1072
  handoffInProgress = false;
1073
+ /**
1074
+ * An agent that just took over cannot transfer again until the caller has
1075
+ * spoken. Without it every incoming agent re-derives intent from the same
1076
+ * replayed transcript, decides the request is not its own, and transfers on
1077
+ * — agents ping-ponging with no caller turn between them (field bug, Aug
1078
+ * 2026). Programmatic `handoffTo()` is host intent and bypasses the lock,
1079
+ * but still arms it for the agent it installs.
1080
+ */
1081
+ handoffLockedUntilCallerTurn = false;
943
1082
  /** Pre-synthesized greeting playout state. */
944
1083
  pregreeting = null;
945
1084
  /** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
@@ -967,6 +1106,18 @@ var CallSession = class extends TypedEmitter {
967
1106
  this.activeAgentValue = deps.agent;
968
1107
  this.context = new SessionContext(deps.options.context);
969
1108
  this.interruptions = new InterruptionController(deps.options.interruptions);
1109
+ this.keypadCollector = deps.options.keypad ? new KeypadCollector(deps.options.keypad, {
1110
+ onEntry: (entry) => this.onKeypadEntry(entry),
1111
+ onClear: (info) => this.onKeypadCleared(info)
1112
+ }) : null;
1113
+ const collector = this.keypadCollector;
1114
+ this.keypad = {
1115
+ get digits() {
1116
+ return collector?.digits ?? "";
1117
+ },
1118
+ clear: () => collector?.clear(),
1119
+ submit: () => collector?.submit()
1120
+ };
970
1121
  const params = deps.start.start.customParameters ?? {};
971
1122
  this.callInfo = {
972
1123
  direction: params.direction === "outbound" ? "outbound" : "inbound",
@@ -1208,9 +1359,16 @@ var CallSession = class extends TypedEmitter {
1208
1359
  }
1209
1360
  return tools;
1210
1361
  }
1362
+ /** Agent instructions plus the kit's own notes that must survive handoffs (keypad). */
1363
+ composeInstructions() {
1364
+ const base = this.activeAgentValue.resolveInstructions(this.context);
1365
+ const keypad = this.deps.options.keypad;
1366
+ if (!keypad || keypad.instructions === false) return base;
1367
+ 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."}`;
1368
+ }
1211
1369
  buildProviderInit() {
1212
1370
  return {
1213
- instructions: this.activeAgentValue.resolveInstructions(this.context) + (this.pregreeting ? `\n\nYou already opened the call by saying: "${this.pregreeting.text}". Do not greet again — continue the conversation from there.` : ""),
1371
+ 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.` : ""),
1214
1372
  voice: this.activeAgentValue.voice,
1215
1373
  vad: this.vadOverride !== void 0 ? this.vadOverride : this.deps.options.vad,
1216
1374
  bridgeOwnsInterruptions: true,
@@ -1233,7 +1391,9 @@ var CallSession = class extends TypedEmitter {
1233
1391
  transport.on("dtmf", (event) => {
1234
1392
  this.clearIdleTimer();
1235
1393
  this.nudgeCount = 0;
1236
- this.emit("dtmf", { digit: event.dtmf.digit });
1394
+ const digit = event.dtmf.digit;
1395
+ this.handleKeypress(digit);
1396
+ this.emit("dtmf", { digit });
1237
1397
  });
1238
1398
  transport.on("stop", () => void this.teardown("caller-hangup"));
1239
1399
  transport.on("close", () => void this.teardown("caller-hangup"));
@@ -1302,11 +1462,13 @@ var CallSession = class extends TypedEmitter {
1302
1462
  };
1303
1463
  this.transcriptEntries.push(entry);
1304
1464
  this.nudgeCount = 0;
1465
+ this.handoffLockedUntilCallerTurn = false;
1305
1466
  this.emit("transcript.user", entry);
1306
1467
  });
1307
1468
  provider.on("userSpeechStarted", () => {
1308
1469
  this.userSpeechActive = true;
1309
1470
  this.userSpeechStartedAtMs = Date.now();
1471
+ this.handoffLockedUntilCallerTurn = false;
1310
1472
  this.clearIdleTimer();
1311
1473
  this.nudgeCount = 0;
1312
1474
  this.emit("user.speech.started");
@@ -1602,6 +1764,17 @@ var CallSession = class extends TypedEmitter {
1602
1764
  this.deliverToolResult(call.id, { error: `unknown agent "${directive.targetAgentId}"` });
1603
1765
  return;
1604
1766
  }
1767
+ if (this.handoffLockedUntilCallerTurn) {
1768
+ this.rejectHandoff(target, directive.reason, call.id);
1769
+ this.emit("tool.completed", {
1770
+ ...baseInfo,
1771
+ strategy: tool.strategy,
1772
+ input,
1773
+ result: { handoffBlocked: target.id },
1774
+ durationMs: Date.now() - started
1775
+ });
1776
+ return;
1777
+ }
1605
1778
  this.deliverToolResult(call.id, {
1606
1779
  status: "transferring_conversation",
1607
1780
  to: target.name
@@ -1985,29 +2158,53 @@ var CallSession = class extends TypedEmitter {
1985
2158
  /** After a reconnect the provider session is blank — restore conversational context. */
1986
2159
  reinjectHistory(provider) {
1987
2160
  if (this.transcriptEntries.length === 0) return;
1988
- const summary = formatTranscriptForInjection(this.transcriptEntries);
1989
- provider.sendText(`Context: this phone call reconnected mid-conversation. Transcript so far:\n${summary}\nContinue naturally from where it left off; do not greet again.`, {
2161
+ const summary = formatTranscriptForInjection(this.transcriptEntries, {
2162
+ agentNames: new Map([...this.agents].map(([id, agent]) => [id, agent.name])),
2163
+ handoffs: this.handoffHistory
2164
+ });
2165
+ provider.sendText(`Context: this phone call reconnected mid-conversation. You are ${this.activeAgentValue.name}. Each line below names who said it; \`[transfer]\` lines are routing that ALREADY happened.\n${summary}\nAnything already answered or already routed above must not be routed again. Continue naturally from where it left off; do not greet again.`, {
1990
2166
  role: "system",
1991
2167
  triggerResponse: false
1992
2168
  });
1993
2169
  }
2170
+ /**
2171
+ * Refuse a model-initiated transfer from an agent the caller has not spoken
2172
+ * to yet. The refusal must reach the model as the tool result, or it simply
2173
+ * calls the same transfer tool again on its next turn.
2174
+ */
2175
+ rejectHandoff(target, reason, callId) {
2176
+ const from = this.activeAgentValue;
2177
+ this.log.warn(`blocked transfer ${from.id} -> ${target.id}: the caller has not spoken since the last one`);
2178
+ this.deliverToolResult(callId, {
2179
+ error: "transfer_rejected",
2180
+ message: "You just took over this call and the caller has not spoken since. Do not transfer again yet — handle their request yourself, or ask them what they need. You may transfer once they reply."
2181
+ });
2182
+ this.emit("agent.handoff.blocked", {
2183
+ from,
2184
+ to: target,
2185
+ reason,
2186
+ cause: "no-caller-turn"
2187
+ });
2188
+ }
1994
2189
  async performHandoff(target, reason) {
1995
2190
  if (this.stateValue !== "active" || !this.provider) return;
1996
2191
  if (target.id === this.activeAgentValue.id) return;
1997
2192
  const from = this.activeAgentValue;
1998
2193
  this.activeAgentValue = target;
2194
+ this.handoffLockedUntilCallerTurn = true;
1999
2195
  this.toolset = this.buildToolset();
2000
2196
  this.handoffHistory.push({
2001
2197
  from: from.id,
2002
2198
  to: target.id,
2003
- atMs: Date.now() - this.startedAtMs
2199
+ atMs: Date.now() - this.startedAtMs,
2200
+ ...reason ? { reason } : {}
2004
2201
  });
2005
2202
  const continueInstruction = `You are now ${target.name}. Continue the SAME phone conversation naturally — acknowledge the caller and take over; do not restart with a cold greeting.` + (reason ? ` Transfer context: ${reason}.` : "");
2006
2203
  const voiceChanges = target.voice !== void 0 && target.voice !== from.voice;
2007
2204
  if (!(!this.provider.capabilities.sessionUpdate || voiceChanges && !this.provider.capabilities.voiceChangeMidSession && this.deps.options.handoffVoicePolicy === "reconnect")) {
2008
2205
  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)`);
2009
2206
  await this.provider.updateSession({
2010
- instructions: this.activeAgentValue.resolveInstructions(this.context),
2207
+ instructions: this.composeInstructions(),
2011
2208
  tools: this.buildProviderInit().tools,
2012
2209
  providerOptions: target.providerOptions
2013
2210
  });
@@ -2083,6 +2280,30 @@ var CallSession = class extends TypedEmitter {
2083
2280
  this.transcriptEntries.push(entry);
2084
2281
  this.emit("transcript.agent", entry);
2085
2282
  }
2283
+ handleKeypress(key) {
2284
+ if (!this.keypadCollector) return;
2285
+ if (this.deps.options.keypad?.interruptOnKeypress !== false) this.interrupt();
2286
+ this.keypadCollector.press(key);
2287
+ }
2288
+ onKeypadEntry(entry) {
2289
+ this.handoffLockedUntilCallerTurn = false;
2290
+ this.emit("keypad.entry", entry);
2291
+ const message = this.deps.options.keypad?.message;
2292
+ if (message === false) return;
2293
+ this.provider?.sendText((message ?? defaultKeypadMessage)(entry), {
2294
+ role: "user",
2295
+ triggerResponse: true
2296
+ });
2297
+ }
2298
+ onKeypadCleared(info) {
2299
+ this.emit("keypad.cleared", info);
2300
+ const clearMessage = this.deps.options.keypad?.clearMessage;
2301
+ if (clearMessage === false) return;
2302
+ this.provider?.sendText(clearMessage ?? "[keypad] I pressed star — I want to start over and retype from the beginning.", {
2303
+ role: "user",
2304
+ triggerResponse: true
2305
+ });
2306
+ }
2086
2307
  /** Armed whenever the agent goes quiet and we're waiting on the caller. */
2087
2308
  armIdleTimer() {
2088
2309
  const idle = this.deps.options.idle;
@@ -2142,6 +2363,7 @@ var CallSession = class extends TypedEmitter {
2142
2363
  for (const timer of this.timers) clearTimeout(timer);
2143
2364
  this.timers.clear();
2144
2365
  this.clearIdleTimer();
2366
+ this.keypadCollector?.dispose();
2145
2367
  this.bgAudio.stop({ immediate: true });
2146
2368
  for (const controller of this.runningTools.values()) controller.abort(/* @__PURE__ */ new Error("call ended"));
2147
2369
  this.runningTools.clear();
@@ -2449,4 +2671,4 @@ async function captureGreetingAudio(options) {
2449
2671
  });
2450
2672
  }
2451
2673
  //#endregion
2452
- export { Agent, BaseRealtimeProvider, CallSession, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, InMemorySessionStore, InterruptionController, NoiseAdaptiveVadController, PlaybackTracker, SessionContext, TwilioRealtimeBridge, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
2674
+ export { Agent, BaseRealtimeProvider, CallSession, DEFAULT_KEYPAD_CLEAR_MESSAGE, DEFAULT_KEYPAD_INSTRUCTIONS, DEFAULT_KEYPAD_OPTIONS, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, InMemorySessionStore, InterruptionController, KeypadCollector, NoiseAdaptiveVadController, PlaybackTracker, SessionContext, TwilioRealtimeBridge, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, defaultKeypadMessage, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
package/dist/store.d.cts CHANGED
@@ -1,2 +1,2 @@
1
- import { i as UsageInfo, n as SessionStore, o as TranscriptEntry, r as CallSnapshot, t as InMemorySessionStore } from "./InMemorySessionStore-B5_rq61L.cjs";
2
- export { type CallSnapshot, InMemorySessionStore, type SessionStore, type TranscriptEntry, type UsageInfo };
1
+ import { i as UsageInfo, n as SessionStore, o as HandoffRecord, r as CallSnapshot, s as TranscriptEntry, t as InMemorySessionStore } from "./InMemorySessionStore-CRBmmCA6.cjs";
2
+ export { type CallSnapshot, type HandoffRecord, InMemorySessionStore, type SessionStore, type TranscriptEntry, type UsageInfo };
package/dist/store.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { i as UsageInfo, n as SessionStore, o as TranscriptEntry, r as CallSnapshot, t as InMemorySessionStore } from "./InMemorySessionStore-B5_rq61L.mjs";
2
- export { type CallSnapshot, InMemorySessionStore, type SessionStore, type TranscriptEntry, type UsageInfo };
1
+ import { i as UsageInfo, n as SessionStore, o as HandoffRecord, r as CallSnapshot, s as TranscriptEntry, t as InMemorySessionStore } from "./InMemorySessionStore-CRBmmCA6.mjs";
2
+ export { type CallSnapshot, type HandoffRecord, InMemorySessionStore, type SessionStore, type TranscriptEntry, type UsageInfo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "realtime-voice-agents",
3
- "version": "2.2.0",
3
+ "version": "2.4.0",
4
4
  "description": "Provider-agnostic bridge between Twilio Media Streams and realtime speech-to-speech AI APIs (OpenAI Realtime, xAI Grok Voice, Gemini Live). Multi-agent handoffs, Zod tools with execution strategies, mark-based playback tracking, interruption guards, and hold audio — for Node.js voice agents over the phone.",
5
5
  "keywords": [
6
6
  "twilio",