realtime-voice-agents 2.3.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/README.md CHANGED
@@ -184,6 +184,10 @@ const receptionist = new Agent({ name: 'Receptionist', instructions: '…', hand
184
184
 
185
185
  Each agent in `handoffs` becomes a `transfer_to_<id>` tool. On handoff the session settles the function call, swaps instructions + tools (`session.update` on OpenAI/xAI; close-and-reopen with context carry on Gemini), and triggers a natural continuation — the caller never hears a seam. Also available programmatically: `session.handoffTo('billing')`. Cycles are fine (billing can hand back).
186
186
 
187
+ **An agent that just took over cannot transfer again until the caller speaks.** The transfer tool is refused (`agent.handoff.blocked` fires, the model is told why, the active agent does not change); a caller turn — speech or a keypad entry — unlocks it. This makes transfer loops structurally impossible rather than merely discouraged: given the same replayed transcript, each incoming agent otherwise re-derives intent, decides the request is somebody else's, and passes it on. The trade-off is that a pure router node costs an extra caller turn, so direct arcs between agents beat hub-and-spoke. `session.handoffTo()` is host intent and bypasses the lock (it still arms it for the agent it installs).
188
+
189
+ The context an incoming agent receives is attributed, not flat: each replayed line names the agent that said it, and completed transfers appear as `[transfer] A -> B (reason: …)` lines — so it can see what was already answered and already routed.
190
+
187
191
  ## Built-in call controls
188
192
 
189
193
  ```ts
@@ -301,7 +305,7 @@ session.on('dtmf', ({ digit }) => {
301
305
 
302
306
  ## Events (session)
303
307
 
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`.
308
+ `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` / `agent.handoff.blocked` · `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`.
305
309
 
306
310
  ```ts
307
311
  bridge.on('session.started', (session) => {
@@ -9,6 +9,14 @@ interface TranscriptEntry {
9
9
  /** The caller cut this utterance off mid-playback. */
10
10
  interrupted?: boolean;
11
11
  }
12
+ /** One completed transfer, as recorded in the session's handoff history. */
13
+ interface HandoffRecord {
14
+ from: string;
15
+ to: string;
16
+ /** Milliseconds since call start. */
17
+ atMs: number;
18
+ reason?: string;
19
+ }
12
20
  //#endregion
13
21
  //#region src/session/usage.d.ts
14
22
  /** Accumulated token consumption for a call, normalized across providers. */
@@ -40,11 +48,7 @@ interface CallSnapshot {
40
48
  transcript: TranscriptEntry[];
41
49
  usage: UsageInfo;
42
50
  context: Record<string, unknown>;
43
- handoffHistory: Array<{
44
- from: string;
45
- to: string;
46
- atMs: number;
47
- }>;
51
+ handoffHistory: HandoffRecord[];
48
52
  /** Gemini session-resumption handle, when the provider supplies one. */
49
53
  resumptionHandle?: string;
50
54
  startedAtMs: number;
@@ -80,4 +84,4 @@ declare class InMemorySessionStore implements SessionStore {
80
84
  list(): CallSnapshot[];
81
85
  }
82
86
  //#endregion
83
- export { emptyUsage as a, UsageInfo as i, SessionStore as n, TranscriptEntry as o, CallSnapshot as r, InMemorySessionStore as t };
87
+ export { emptyUsage as a, UsageInfo as i, SessionStore as n, HandoffRecord as o, CallSnapshot as r, TranscriptEntry as s, InMemorySessionStore as t };
@@ -9,6 +9,14 @@ interface TranscriptEntry {
9
9
  /** The caller cut this utterance off mid-playback. */
10
10
  interrupted?: boolean;
11
11
  }
12
+ /** One completed transfer, as recorded in the session's handoff history. */
13
+ interface HandoffRecord {
14
+ from: string;
15
+ to: string;
16
+ /** Milliseconds since call start. */
17
+ atMs: number;
18
+ reason?: string;
19
+ }
12
20
  //#endregion
13
21
  //#region src/session/usage.d.ts
14
22
  /** Accumulated token consumption for a call, normalized across providers. */
@@ -40,11 +48,7 @@ interface CallSnapshot {
40
48
  transcript: TranscriptEntry[];
41
49
  usage: UsageInfo;
42
50
  context: Record<string, unknown>;
43
- handoffHistory: Array<{
44
- from: string;
45
- to: string;
46
- atMs: number;
47
- }>;
51
+ handoffHistory: HandoffRecord[];
48
52
  /** Gemini session-resumption handle, when the provider supplies one. */
49
53
  resumptionHandle?: string;
50
54
  startedAtMs: number;
@@ -80,4 +84,4 @@ declare class InMemorySessionStore implements SessionStore {
80
84
  list(): CallSnapshot[];
81
85
  }
82
86
  //#endregion
83
- export { emptyUsage as a, UsageInfo as i, SessionStore as n, TranscriptEntry as o, CallSnapshot as r, InMemorySessionStore as t };
87
+ export { emptyUsage as a, UsageInfo as i, SessionStore as n, HandoffRecord as o, CallSnapshot as r, TranscriptEntry as s, InMemorySessionStore as t };
package/dist/index.cjs CHANGED
@@ -800,9 +800,34 @@ function delayForAttempt(policy, attempt, random = Math.random) {
800
800
  }
801
801
  //#endregion
802
802
  //#region src/session/transcript.ts
803
- /** Render a transcript for history re-injection after reconnect/handoff. */
804
- function formatTranscriptForInjection(entries, maxTurns = 30) {
805
- return entries.slice(-maxTurns).map((entry) => `${entry.role === "user" ? "Caller" : "Agent"}: ${entry.text}`).join("\n");
803
+ /**
804
+ * Render a transcript for history re-injection after reconnect/handoff.
805
+ *
806
+ * Agent lines are attributed to the agent that said them and completed
807
+ * transfers are interleaved as `[transfer]` lines: an incoming agent handed a
808
+ * flat `Agent:` dialogue re-derives intent from scratch, decides the request
809
+ * belongs to somebody else, and transfers on — agents ping-ponging with no
810
+ * caller turn between them (field bug, Aug 2026). Replaying WHO said what and
811
+ * WHAT was already routed is what stops the second lap.
812
+ */
813
+ function formatTranscriptForInjection(entries, options = {}) {
814
+ const { maxTurns = 30, agentNames, handoffs = [] } = options;
815
+ const recent = entries.slice(-maxTurns);
816
+ if (recent.length === 0) return "";
817
+ const nameOf = (agentId) => (agentId ? agentNames?.get(agentId) : void 0) ?? agentId ?? "Agent";
818
+ const windowStartMs = recent[0].timestampMs;
819
+ const lines = recent.map((entry) => ({
820
+ atMs: entry.timestampMs,
821
+ text: entry.role === "user" ? `Caller: ${entry.text}` : `${nameOf(entry.agentId)}: ${entry.text}`
822
+ }));
823
+ for (const handoff of handoffs) {
824
+ if (handoff.atMs < windowStartMs) continue;
825
+ lines.push({
826
+ atMs: handoff.atMs,
827
+ text: `[transfer] ${nameOf(handoff.from)} -> ${nameOf(handoff.to)}` + (handoff.reason ? ` (reason: ${handoff.reason})` : "")
828
+ });
829
+ }
830
+ return lines.sort((a, b) => a.atMs - b.atMs).map((line) => line.text).join("\n");
806
831
  }
807
832
  //#endregion
808
833
  //#region src/session/usage.ts
@@ -1049,6 +1074,15 @@ var CallSession = class extends require_events.TypedEmitter {
1049
1074
  agents;
1050
1075
  handoffHistory = [];
1051
1076
  handoffInProgress = false;
1077
+ /**
1078
+ * An agent that just took over cannot transfer again until the caller has
1079
+ * spoken. Without it every incoming agent re-derives intent from the same
1080
+ * replayed transcript, decides the request is not its own, and transfers on
1081
+ * — agents ping-ponging with no caller turn between them (field bug, Aug
1082
+ * 2026). Programmatic `handoffTo()` is host intent and bypasses the lock,
1083
+ * but still arms it for the agent it installs.
1084
+ */
1085
+ handoffLockedUntilCallerTurn = false;
1052
1086
  /** Pre-synthesized greeting playout state. */
1053
1087
  pregreeting = null;
1054
1088
  /** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
@@ -1432,11 +1466,13 @@ var CallSession = class extends require_events.TypedEmitter {
1432
1466
  };
1433
1467
  this.transcriptEntries.push(entry);
1434
1468
  this.nudgeCount = 0;
1469
+ this.handoffLockedUntilCallerTurn = false;
1435
1470
  this.emit("transcript.user", entry);
1436
1471
  });
1437
1472
  provider.on("userSpeechStarted", () => {
1438
1473
  this.userSpeechActive = true;
1439
1474
  this.userSpeechStartedAtMs = Date.now();
1475
+ this.handoffLockedUntilCallerTurn = false;
1440
1476
  this.clearIdleTimer();
1441
1477
  this.nudgeCount = 0;
1442
1478
  this.emit("user.speech.started");
@@ -1732,6 +1768,17 @@ var CallSession = class extends require_events.TypedEmitter {
1732
1768
  this.deliverToolResult(call.id, { error: `unknown agent "${directive.targetAgentId}"` });
1733
1769
  return;
1734
1770
  }
1771
+ if (this.handoffLockedUntilCallerTurn) {
1772
+ this.rejectHandoff(target, directive.reason, call.id);
1773
+ this.emit("tool.completed", {
1774
+ ...baseInfo,
1775
+ strategy: tool.strategy,
1776
+ input,
1777
+ result: { handoffBlocked: target.id },
1778
+ durationMs: Date.now() - started
1779
+ });
1780
+ return;
1781
+ }
1735
1782
  this.deliverToolResult(call.id, {
1736
1783
  status: "transferring_conversation",
1737
1784
  to: target.name
@@ -2115,22 +2162,46 @@ var CallSession = class extends require_events.TypedEmitter {
2115
2162
  /** After a reconnect the provider session is blank — restore conversational context. */
2116
2163
  reinjectHistory(provider) {
2117
2164
  if (this.transcriptEntries.length === 0) return;
2118
- const summary = formatTranscriptForInjection(this.transcriptEntries);
2119
- 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.`, {
2165
+ const summary = formatTranscriptForInjection(this.transcriptEntries, {
2166
+ agentNames: new Map([...this.agents].map(([id, agent]) => [id, agent.name])),
2167
+ handoffs: this.handoffHistory
2168
+ });
2169
+ 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.`, {
2120
2170
  role: "system",
2121
2171
  triggerResponse: false
2122
2172
  });
2123
2173
  }
2174
+ /**
2175
+ * Refuse a model-initiated transfer from an agent the caller has not spoken
2176
+ * to yet. The refusal must reach the model as the tool result, or it simply
2177
+ * calls the same transfer tool again on its next turn.
2178
+ */
2179
+ rejectHandoff(target, reason, callId) {
2180
+ const from = this.activeAgentValue;
2181
+ this.log.warn(`blocked transfer ${from.id} -> ${target.id}: the caller has not spoken since the last one`);
2182
+ this.deliverToolResult(callId, {
2183
+ error: "transfer_rejected",
2184
+ 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."
2185
+ });
2186
+ this.emit("agent.handoff.blocked", {
2187
+ from,
2188
+ to: target,
2189
+ reason,
2190
+ cause: "no-caller-turn"
2191
+ });
2192
+ }
2124
2193
  async performHandoff(target, reason) {
2125
2194
  if (this.stateValue !== "active" || !this.provider) return;
2126
2195
  if (target.id === this.activeAgentValue.id) return;
2127
2196
  const from = this.activeAgentValue;
2128
2197
  this.activeAgentValue = target;
2198
+ this.handoffLockedUntilCallerTurn = true;
2129
2199
  this.toolset = this.buildToolset();
2130
2200
  this.handoffHistory.push({
2131
2201
  from: from.id,
2132
2202
  to: target.id,
2133
- atMs: Date.now() - this.startedAtMs
2203
+ atMs: Date.now() - this.startedAtMs,
2204
+ ...reason ? { reason } : {}
2134
2205
  });
2135
2206
  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}.` : "");
2136
2207
  const voiceChanges = target.voice !== void 0 && target.voice !== from.voice;
@@ -2219,6 +2290,7 @@ var CallSession = class extends require_events.TypedEmitter {
2219
2290
  this.keypadCollector.press(key);
2220
2291
  }
2221
2292
  onKeypadEntry(entry) {
2293
+ this.handoffLockedUntilCallerTurn = false;
2222
2294
  this.emit("keypad.entry", entry);
2223
2295
  const message = this.deps.options.keypad?.message;
2224
2296
  if (message === false) return;
package/dist/index.d.cts CHANGED
@@ -2,7 +2,7 @@ import { n as BackgroundAudioPreset, r as BackgroundAudioSpec, t as BackgroundAu
2
2
  import { t as TypedEmitter } from "./events-BUMYdETO.cjs";
3
3
  import { _ as Logger, a as ProviderToolSchema, c as SessionUpdateOptions, d as ProviderCloseInfo, f as ProviderEvents, g as VadTuningProfile, h as ProviderCapabilities, i as ProviderSessionInit, l as VadConfig, m as ProviderUsage, n as ProviderFactory, o as SendTextOptions, p as ProviderToolCall, r as ProviderFactoryContext, s as SendToolResultOptions, t as BaseRealtimeProvider, u as ProviderAudioDelta, v as consoleLogger, y as noopLogger } from "./BaseRealtimeProvider-BL75_HHh.cjs";
4
4
  import { i as WebSocketLike, m as TwilioStartEvent, r as TwilioMediaTransport } from "./transport-B_PJFIVd.cjs";
5
- import { a as emptyUsage, i as UsageInfo, n as SessionStore, o as TranscriptEntry, r as CallSnapshot, t as InMemorySessionStore } from "./InMemorySessionStore-B5_rq61L.cjs";
5
+ import { a as emptyUsage, i as UsageInfo, n as SessionStore, o as HandoffRecord, r as CallSnapshot, s as TranscriptEntry, t as InMemorySessionStore } from "./InMemorySessionStore-CRBmmCA6.cjs";
6
6
  import { i as TwilioRestClient, n as connectStreamTwiml } from "./twiml-z9LjoF4_.cjs";
7
7
  import { IncomingMessage } from "node:http";
8
8
  //#region src/tools/tool.d.ts
@@ -798,6 +798,16 @@ interface SessionEventMap {
798
798
  to: Agent;
799
799
  reason?: string;
800
800
  }) => void;
801
+ /**
802
+ * A transfer the model asked for was refused. Fires INSTEAD of
803
+ * `agent.handoff` — the active agent did not change.
804
+ */
805
+ 'agent.handoff.blocked': (info: {
806
+ from: Agent;
807
+ to: Agent;
808
+ reason?: string;
809
+ cause: string;
810
+ }) => void;
801
811
  interruption: (info: {
802
812
  responseId: string;
803
813
  playedMs: number;
@@ -920,6 +930,15 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
920
930
  private readonly agents;
921
931
  private readonly handoffHistory;
922
932
  private handoffInProgress;
933
+ /**
934
+ * An agent that just took over cannot transfer again until the caller has
935
+ * spoken. Without it every incoming agent re-derives intent from the same
936
+ * replayed transcript, decides the request is not its own, and transfers on
937
+ * — agents ping-ponging with no caller turn between them (field bug, Aug
938
+ * 2026). Programmatic `handoffTo()` is host intent and bypasses the lock,
939
+ * but still arms it for the agent it installs.
940
+ */
941
+ private handoffLockedUntilCallerTurn;
923
942
  /** Pre-synthesized greeting playout state. */
924
943
  private pregreeting;
925
944
  /** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
@@ -1069,6 +1088,12 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
1069
1088
  private reconnectProvider;
1070
1089
  /** After a reconnect the provider session is blank — restore conversational context. */
1071
1090
  private reinjectHistory;
1091
+ /**
1092
+ * Refuse a model-initiated transfer from an agent the caller has not spoken
1093
+ * to yet. The refusal must reach the model as the tool result, or it simply
1094
+ * calls the same transfer tool again on its next turn.
1095
+ */
1096
+ private rejectHandoff;
1072
1097
  private performHandoff;
1073
1098
  /**
1074
1099
  * Burst-write a stored μ-law greeting straight onto the Twilio socket —
@@ -1271,4 +1296,4 @@ declare class PlaybackTracker {
1271
1296
  private maybeForget;
1272
1297
  }
1273
1298
  //#endregion
1274
- export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_KEYPAD_CLEAR_MESSAGE, DEFAULT_KEYPAD_INSTRUCTIONS, DEFAULT_KEYPAD_OPTIONS, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, KeypadCollector, type KeypadCollectorHooks, type KeypadEntry, type KeypadEntryReason, type KeypadHandle, type KeypadKeyKind, type KeypadOptions, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, defaultKeypadMessage, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
1299
+ export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_KEYPAD_CLEAR_MESSAGE, DEFAULT_KEYPAD_INSTRUCTIONS, DEFAULT_KEYPAD_OPTIONS, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HandoffRecord, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, KeypadCollector, type KeypadCollectorHooks, type KeypadEntry, type KeypadEntryReason, type KeypadHandle, type KeypadKeyKind, type KeypadOptions, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, defaultKeypadMessage, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
package/dist/index.d.mts CHANGED
@@ -2,7 +2,7 @@ import { n as BackgroundAudioPreset, r as BackgroundAudioSpec, t as BackgroundAu
2
2
  import { t as TypedEmitter } from "./events-BUMYdETO.mjs";
3
3
  import { _ as Logger, a as ProviderToolSchema, c as SessionUpdateOptions, d as ProviderCloseInfo, f as ProviderEvents, g as VadTuningProfile, h as ProviderCapabilities, i as ProviderSessionInit, l as VadConfig, m as ProviderUsage, n as ProviderFactory, o as SendTextOptions, p as ProviderToolCall, r as ProviderFactoryContext, s as SendToolResultOptions, t as BaseRealtimeProvider, u as ProviderAudioDelta, v as consoleLogger, y as noopLogger } from "./BaseRealtimeProvider-CWJ81HIt.mjs";
4
4
  import { i as WebSocketLike, m as TwilioStartEvent, r as TwilioMediaTransport } from "./transport-CEaLFV4E.mjs";
5
- import { a as emptyUsage, i as UsageInfo, n as SessionStore, o as TranscriptEntry, r as CallSnapshot, t as InMemorySessionStore } from "./InMemorySessionStore-B5_rq61L.mjs";
5
+ import { a as emptyUsage, i as UsageInfo, n as SessionStore, o as HandoffRecord, r as CallSnapshot, s as TranscriptEntry, t as InMemorySessionStore } from "./InMemorySessionStore-CRBmmCA6.mjs";
6
6
  import { i as TwilioRestClient, n as connectStreamTwiml } from "./twiml-z9LjoF4_.mjs";
7
7
  import { IncomingMessage } from "node:http";
8
8
  //#region src/tools/tool.d.ts
@@ -798,6 +798,16 @@ interface SessionEventMap {
798
798
  to: Agent;
799
799
  reason?: string;
800
800
  }) => void;
801
+ /**
802
+ * A transfer the model asked for was refused. Fires INSTEAD of
803
+ * `agent.handoff` — the active agent did not change.
804
+ */
805
+ 'agent.handoff.blocked': (info: {
806
+ from: Agent;
807
+ to: Agent;
808
+ reason?: string;
809
+ cause: string;
810
+ }) => void;
801
811
  interruption: (info: {
802
812
  responseId: string;
803
813
  playedMs: number;
@@ -920,6 +930,15 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
920
930
  private readonly agents;
921
931
  private readonly handoffHistory;
922
932
  private handoffInProgress;
933
+ /**
934
+ * An agent that just took over cannot transfer again until the caller has
935
+ * spoken. Without it every incoming agent re-derives intent from the same
936
+ * replayed transcript, decides the request is not its own, and transfers on
937
+ * — agents ping-ponging with no caller turn between them (field bug, Aug
938
+ * 2026). Programmatic `handoffTo()` is host intent and bypasses the lock,
939
+ * but still arms it for the agent it installs.
940
+ */
941
+ private handoffLockedUntilCallerTurn;
923
942
  /** Pre-synthesized greeting playout state. */
924
943
  private pregreeting;
925
944
  /** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
@@ -1069,6 +1088,12 @@ declare class CallSession extends TypedEmitter<SessionEventMap> {
1069
1088
  private reconnectProvider;
1070
1089
  /** After a reconnect the provider session is blank — restore conversational context. */
1071
1090
  private reinjectHistory;
1091
+ /**
1092
+ * Refuse a model-initiated transfer from an agent the caller has not spoken
1093
+ * to yet. The refusal must reach the model as the tool result, or it simply
1094
+ * calls the same transfer tool again on its next turn.
1095
+ */
1096
+ private rejectHandoff;
1072
1097
  private performHandoff;
1073
1098
  /**
1074
1099
  * Burst-write a stored μ-law greeting straight onto the Twilio socket —
@@ -1271,4 +1296,4 @@ declare class PlaybackTracker {
1271
1296
  private maybeForget;
1272
1297
  }
1273
1298
  //#endregion
1274
- export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_KEYPAD_CLEAR_MESSAGE, DEFAULT_KEYPAD_INSTRUCTIONS, DEFAULT_KEYPAD_OPTIONS, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, KeypadCollector, type KeypadCollectorHooks, type KeypadEntry, type KeypadEntryReason, type KeypadHandle, type KeypadKeyKind, type KeypadOptions, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, defaultKeypadMessage, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
1299
+ export { Agent, type AgentDefinition, type ApprovalRequestInfo, type BackgroundAudioOptions, type BackgroundAudioPreset, type BackgroundAudioSpec, BaseRealtimeProvider, type BridgeConfig, type BridgeEventMap, type BuiltinToolsConfig, type CallEndReason, CallSession, type CallSessionFacade, type CallSnapshot, type CallStartedInfo, type CallState, type CaptureGreetingOptions, type CapturedGreeting, DEFAULT_KEYPAD_CLEAR_MESSAGE, DEFAULT_KEYPAD_INSTRUCTIONS, DEFAULT_KEYPAD_OPTIONS, DEFAULT_RECONNECT_POLICY, DEFAULT_SESSION_OPTIONS, type DeafnessOptions, type FinishCallToolOptions, type GreetingOptions, type HandoffDirective, type HandoffRecord, type HangupOptions, type IdleOptions, InMemorySessionStore, type InferSchema, type InterruptionBlockCause, InterruptionController, type InterruptionDecision, type InterruptionRateLimit, type InterruptionSettings, KeypadCollector, type KeypadCollectorHooks, type KeypadEntry, type KeypadEntryReason, type KeypadHandle, type KeypadKeyKind, type KeypadOptions, type Logger, type MarkEchoResult, NoiseAdaptiveVadController, type NoiseAdaptiveVadOptions, PlaybackTracker, type ProviderAudioDelta, type ProviderCapabilities, type ProviderCloseInfo, type ProviderEvents, type ProviderFactory, type ProviderFactoryContext, type ProviderFallbackInfo, type ProviderSessionInit, type ProviderToolCall, type ProviderToolSchema, type ProviderUsage, type ReconnectPolicy, type SendTextOptions, type SendToolResultOptions, SessionContext, type SessionEventMap, type SessionOptions, type SessionStore, type SessionUpdateOptions, type Tool, type ToolCallInfo, type ToolContext, type ToolDecoration, type ToolDefinition, type ToolMiddleware, type ToolRunInfo, type ToolStrategy, type TranscriptEntry, type TransferCallToolOptions, TwilioRealtimeBridge, type TwilioRestOptions, type TwilioStartEvent, type UsageInfo, type VadAdjustment, type VadConfig, type VadNoiseMetrics, type VadSuggestionInfo, type VadTuningProfile, type WebSocketLike, type ZodSchemaLike, captureGreetingAudio, collectAgentGraph, composeExecution, connectStreamTwiml, consoleLogger, createFinishCallTool, createHandoffTool, createTransferCallTool, decorateTool, defaultKeypadMessage, emptyUsage, handoffToolName, isHandoffDirective, noopLogger, resolveSessionOptions, tool, zodToJsonSchema };
package/dist/index.mjs CHANGED
@@ -796,9 +796,34 @@ function delayForAttempt(policy, attempt, random = Math.random) {
796
796
  }
797
797
  //#endregion
798
798
  //#region src/session/transcript.ts
799
- /** Render a transcript for history re-injection after reconnect/handoff. */
800
- function formatTranscriptForInjection(entries, maxTurns = 30) {
801
- 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");
802
827
  }
803
828
  //#endregion
804
829
  //#region src/session/usage.ts
@@ -1045,6 +1070,15 @@ var CallSession = class extends TypedEmitter {
1045
1070
  agents;
1046
1071
  handoffHistory = [];
1047
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;
1048
1082
  /** Pre-synthesized greeting playout state. */
1049
1083
  pregreeting = null;
1050
1084
  /** Noise-adaptive VAD (opt-in); built after connect from the provider's ACKed config. */
@@ -1428,11 +1462,13 @@ var CallSession = class extends TypedEmitter {
1428
1462
  };
1429
1463
  this.transcriptEntries.push(entry);
1430
1464
  this.nudgeCount = 0;
1465
+ this.handoffLockedUntilCallerTurn = false;
1431
1466
  this.emit("transcript.user", entry);
1432
1467
  });
1433
1468
  provider.on("userSpeechStarted", () => {
1434
1469
  this.userSpeechActive = true;
1435
1470
  this.userSpeechStartedAtMs = Date.now();
1471
+ this.handoffLockedUntilCallerTurn = false;
1436
1472
  this.clearIdleTimer();
1437
1473
  this.nudgeCount = 0;
1438
1474
  this.emit("user.speech.started");
@@ -1728,6 +1764,17 @@ var CallSession = class extends TypedEmitter {
1728
1764
  this.deliverToolResult(call.id, { error: `unknown agent "${directive.targetAgentId}"` });
1729
1765
  return;
1730
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
+ }
1731
1778
  this.deliverToolResult(call.id, {
1732
1779
  status: "transferring_conversation",
1733
1780
  to: target.name
@@ -2111,22 +2158,46 @@ var CallSession = class extends TypedEmitter {
2111
2158
  /** After a reconnect the provider session is blank — restore conversational context. */
2112
2159
  reinjectHistory(provider) {
2113
2160
  if (this.transcriptEntries.length === 0) return;
2114
- const summary = formatTranscriptForInjection(this.transcriptEntries);
2115
- 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.`, {
2116
2166
  role: "system",
2117
2167
  triggerResponse: false
2118
2168
  });
2119
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
+ }
2120
2189
  async performHandoff(target, reason) {
2121
2190
  if (this.stateValue !== "active" || !this.provider) return;
2122
2191
  if (target.id === this.activeAgentValue.id) return;
2123
2192
  const from = this.activeAgentValue;
2124
2193
  this.activeAgentValue = target;
2194
+ this.handoffLockedUntilCallerTurn = true;
2125
2195
  this.toolset = this.buildToolset();
2126
2196
  this.handoffHistory.push({
2127
2197
  from: from.id,
2128
2198
  to: target.id,
2129
- atMs: Date.now() - this.startedAtMs
2199
+ atMs: Date.now() - this.startedAtMs,
2200
+ ...reason ? { reason } : {}
2130
2201
  });
2131
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}.` : "");
2132
2203
  const voiceChanges = target.voice !== void 0 && target.voice !== from.voice;
@@ -2215,6 +2286,7 @@ var CallSession = class extends TypedEmitter {
2215
2286
  this.keypadCollector.press(key);
2216
2287
  }
2217
2288
  onKeypadEntry(entry) {
2289
+ this.handoffLockedUntilCallerTurn = false;
2218
2290
  this.emit("keypad.entry", entry);
2219
2291
  const message = this.deps.options.keypad?.message;
2220
2292
  if (message === false) return;
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.3.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",