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 +5 -1
- package/dist/{InMemorySessionStore-B5_rq61L.d.cts → InMemorySessionStore-CRBmmCA6.d.cts} +10 -6
- package/dist/{InMemorySessionStore-B5_rq61L.d.mts → InMemorySessionStore-CRBmmCA6.d.mts} +10 -6
- package/dist/index.cjs +78 -6
- package/dist/index.d.cts +27 -2
- package/dist/index.d.mts +27 -2
- package/dist/index.mjs +78 -6
- package/dist/store.d.cts +2 -2
- package/dist/store.d.mts +2 -2
- package/package.json +1 -1
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:
|
|
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,
|
|
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:
|
|
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,
|
|
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
|
-
/**
|
|
804
|
-
|
|
805
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
800
|
-
|
|
801
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
+
"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",
|