@openclaw/voice-call 2026.7.2-beta.7 → 2026.8.1-beta.3
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 +4 -3
- package/dist/doctor-contract-api.js +11 -8
- package/dist/{guarded-json-api-DS9sZa3C.js → guarded-json-api-j4OEG0fR.js} +24 -46
- package/dist/index.js +644 -913
- package/dist/{mock-YsvBTX-7.js → mock-Dv7KxYGt.js} +5 -4
- package/dist/{plivo-vZ--Wqxv.js → plivo-BbVnahnQ.js} +45 -8
- package/dist/{realtime-handler--i_OERv6.js → realtime-handler-BaMEKbmJ.js} +579 -209
- package/dist/{response-generator-D-7ApVfT.js → response-generator-tjyX4tDq.js} +70 -15
- package/dist/{runtime-entry-D0UJiLfi.js → runtime-entry-DK8kPCGC.js} +687 -355
- package/dist/runtime-entry.js +1 -1
- package/dist/setup-api.js +17 -24
- package/dist/{store-path-nYL-yM0S.js → store-path-51_oC_xn.js} +70 -13
- package/dist/{telnyx-Dx6cfLvV.js → telnyx-DO8dFQcb.js} +14 -9
- package/dist/{twilio-D_D8KysU.js → twilio-JDt_1nlz.js} +58 -51
- package/dist/{webhook-replay-aaP79dI8.js → webhook-replay-FQrEGVSM.js} +20 -6
- package/openclaw.plugin.json +69 -6
- package/package.json +4 -4
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-
|
|
1
|
+
import { T as REALTIME_VOICE_END_CALL_TOOL_NAME, o as canonicalizeVoiceCallMediaBase64, x as resolveVoiceCallPublicPathPrefix } from "./runtime-entry-DK8kPCGC.js";
|
|
2
2
|
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { asNullableRecord, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
|
4
4
|
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
|
5
5
|
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
|
|
6
6
|
import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
|
|
7
|
+
import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
|
|
7
8
|
import { randomUUID } from "node:crypto";
|
|
8
9
|
import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
|
9
10
|
import "node:http";
|
|
@@ -11,16 +12,19 @@ import WebSocket$1, { WebSocketServer } from "ws";
|
|
|
11
12
|
//#region extensions/voice-call/src/webhook/realtime-audio-pacer.ts
|
|
12
13
|
const TELEPHONY_SAMPLE_RATE = 8e3;
|
|
13
14
|
const TELEPHONY_CHUNK_BYTES = 160;
|
|
14
|
-
const
|
|
15
|
+
const LEAD_MS = 160;
|
|
15
16
|
const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
|
|
17
|
+
const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
|
|
16
18
|
/** Paces outgoing mulaw audio frames at telephony cadence. */
|
|
17
19
|
var RealtimeAudioPacer = class {
|
|
18
20
|
constructor(params) {
|
|
19
21
|
this.params = params;
|
|
20
22
|
this.queue = [];
|
|
23
|
+
this.queueHead = 0;
|
|
21
24
|
this.timer = null;
|
|
22
25
|
this.queuedAudioBytes = 0;
|
|
23
26
|
this.closed = false;
|
|
27
|
+
this.streamClockMs = null;
|
|
24
28
|
}
|
|
25
29
|
/** Queue mulaw audio and split it into 20ms-ish telephony chunks. */
|
|
26
30
|
sendAudio(muLaw) {
|
|
@@ -35,7 +39,7 @@ var RealtimeAudioPacer = class {
|
|
|
35
39
|
this.queue.push({
|
|
36
40
|
type: "audio",
|
|
37
41
|
chunk,
|
|
38
|
-
durationMs:
|
|
42
|
+
durationMs: chunk.length / 8
|
|
39
43
|
});
|
|
40
44
|
this.queuedAudioBytes += chunk.length;
|
|
41
45
|
}
|
|
@@ -55,8 +59,9 @@ var RealtimeAudioPacer = class {
|
|
|
55
59
|
if (this.closed) return 0;
|
|
56
60
|
const clearedAudioBytes = this.queuedAudioBytes;
|
|
57
61
|
this.clearTimer();
|
|
58
|
-
this.
|
|
62
|
+
this.resetQueue();
|
|
59
63
|
this.queuedAudioBytes = 0;
|
|
64
|
+
this.streamClockMs = null;
|
|
60
65
|
this.params.send(this.params.serializer.clear());
|
|
61
66
|
return clearedAudioBytes;
|
|
62
67
|
}
|
|
@@ -68,8 +73,9 @@ var RealtimeAudioPacer = class {
|
|
|
68
73
|
close() {
|
|
69
74
|
this.closed = true;
|
|
70
75
|
this.clearTimer();
|
|
71
|
-
this.
|
|
76
|
+
this.resetQueue();
|
|
72
77
|
this.queuedAudioBytes = 0;
|
|
78
|
+
this.streamClockMs = null;
|
|
73
79
|
}
|
|
74
80
|
/** Clear the scheduled pump timer. */
|
|
75
81
|
clearTimer() {
|
|
@@ -86,25 +92,56 @@ var RealtimeAudioPacer = class {
|
|
|
86
92
|
this.close();
|
|
87
93
|
this.params.onBackpressure?.();
|
|
88
94
|
}
|
|
89
|
-
|
|
95
|
+
get pendingQueueSize() {
|
|
96
|
+
return Math.max(0, this.queue.length - this.queueHead);
|
|
97
|
+
}
|
|
98
|
+
/** Take one queued item without shifting the remaining paced-audio backlog. */
|
|
99
|
+
takeNextItem() {
|
|
100
|
+
if (this.queueHead >= this.queue.length) {
|
|
101
|
+
this.resetQueue();
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const item = this.queue[this.queueHead];
|
|
105
|
+
this.queueHead += 1;
|
|
106
|
+
if (this.queueHead >= this.queue.length) this.resetQueue();
|
|
107
|
+
else if (this.queueHead > QUEUE_COMPACT_HEAD_THRESHOLD && this.queueHead * 2 > this.queue.length) {
|
|
108
|
+
this.queue.splice(0, this.queueHead);
|
|
109
|
+
this.queueHead = 0;
|
|
110
|
+
}
|
|
111
|
+
return item;
|
|
112
|
+
}
|
|
113
|
+
resetQueue() {
|
|
114
|
+
this.queue.length = 0;
|
|
115
|
+
this.queueHead = 0;
|
|
116
|
+
}
|
|
117
|
+
/** Fill the provider playout cushion, then wake at the next timeline boundary. */
|
|
90
118
|
pump() {
|
|
91
119
|
this.timer = null;
|
|
92
120
|
if (this.closed) return;
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
121
|
+
const now = performance.now();
|
|
122
|
+
this.streamClockMs ??= now;
|
|
123
|
+
while (this.pendingQueueSize > 0 && this.streamClockMs < now + LEAD_MS) {
|
|
124
|
+
const item = this.takeNextItem();
|
|
125
|
+
if (!item) break;
|
|
126
|
+
if (!(item.type === "audio" ? this.sendAudioItem(item) : this.params.send(this.params.serializer.mark(item.name)))) {
|
|
127
|
+
this.resetQueue();
|
|
128
|
+
this.queuedAudioBytes = 0;
|
|
129
|
+
this.streamClockMs = null;
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (this.pendingQueueSize === 0) {
|
|
134
|
+
this.streamClockMs = null;
|
|
105
135
|
return;
|
|
106
136
|
}
|
|
107
|
-
|
|
137
|
+
const delayMs = Math.max(1, this.streamClockMs - LEAD_MS - performance.now());
|
|
138
|
+
this.timer = setTimeout(() => this.pump(), delayMs);
|
|
139
|
+
}
|
|
140
|
+
sendAudioItem(item) {
|
|
141
|
+
this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
|
|
142
|
+
const sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
|
|
143
|
+
this.streamClockMs = (this.streamClockMs ?? performance.now()) + item.durationMs;
|
|
144
|
+
return sent;
|
|
108
145
|
}
|
|
109
146
|
};
|
|
110
147
|
//#endregion
|
|
@@ -120,15 +157,13 @@ function parseTimestampMs(value) {
|
|
|
120
157
|
/** Parse a JSON object frame, returning null for invalid or non-object payloads. */
|
|
121
158
|
function tryParseJson(rawMessage) {
|
|
122
159
|
try {
|
|
123
|
-
|
|
124
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
160
|
+
return asNullableRecord(JSON.parse(rawMessage));
|
|
125
161
|
} catch {}
|
|
126
162
|
return null;
|
|
127
163
|
}
|
|
128
164
|
/** Read an object-valued field from a parsed frame. */
|
|
129
165
|
function readRecordField(record, field) {
|
|
130
|
-
|
|
131
|
-
return typeof value === "object" && value !== null ? value : void 0;
|
|
166
|
+
return asOptionalObjectRecord(record[field]);
|
|
132
167
|
}
|
|
133
168
|
/** Parse a common provider media frame. */
|
|
134
169
|
function parseMediaFrame(msg) {
|
|
@@ -273,6 +308,8 @@ const STREAM_TOKEN_TTL_MS = 3e4;
|
|
|
273
308
|
const DEFAULT_HOST = "localhost:8443";
|
|
274
309
|
const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
|
|
275
310
|
const MAX_REALTIME_WS_BUFFERED_BYTES = 1024 * 1024;
|
|
311
|
+
const REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS = 3e4;
|
|
312
|
+
const REALTIME_DISCONNECT_HANGUP_GRACE_MS = 2e3;
|
|
276
313
|
const FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
|
|
277
314
|
const FORCED_CONSULT_NATIVE_DEDUPE_MS = 2e3;
|
|
278
315
|
const FORCED_CONSULT_RESULT_MAX_CHARS = 1800;
|
|
@@ -377,6 +414,12 @@ function buildForcedConsultSpeechPrompt(result) {
|
|
|
377
414
|
trimmed.length <= FORCED_CONSULT_RESULT_MAX_CHARS ? trimmed : `${truncateUtf16Safe(trimmed, FORCED_CONSULT_RESULT_MAX_CHARS - 16).trimEnd()} [truncated]`
|
|
378
415
|
].join("\n");
|
|
379
416
|
}
|
|
417
|
+
async function waitForNativeConsult(state) {
|
|
418
|
+
return await Promise.race([state.promise.then((result) => ({
|
|
419
|
+
kind: "completed",
|
|
420
|
+
result
|
|
421
|
+
})), state.cancellation.then(() => ({ kind: "cancelled" }))]);
|
|
422
|
+
}
|
|
380
423
|
function appendRecentTalkEventMetadata(call, event) {
|
|
381
424
|
if (!call) return;
|
|
382
425
|
const metadata = call.metadata ?? {};
|
|
@@ -399,26 +442,26 @@ function appendRecentTalkEventMetadata(call, event) {
|
|
|
399
442
|
call.metadata = metadata;
|
|
400
443
|
}
|
|
401
444
|
var RealtimeCallHandler = class {
|
|
402
|
-
constructor(config, manager,
|
|
445
|
+
constructor(config, manager, resolveCallRegistration, servePath, streamDisconnectLifecycle, coreConfig) {
|
|
403
446
|
this.config = config;
|
|
404
447
|
this.manager = manager;
|
|
405
|
-
this.
|
|
406
|
-
this.realtimeProvider = realtimeProvider;
|
|
407
|
-
this.providerConfig = providerConfig;
|
|
448
|
+
this.resolveCallRegistration = resolveCallRegistration;
|
|
408
449
|
this.servePath = servePath;
|
|
450
|
+
this.streamDisconnectLifecycle = streamDisconnectLifecycle;
|
|
409
451
|
this.coreConfig = coreConfig;
|
|
410
|
-
this.resolveInstructions = resolveInstructions;
|
|
411
452
|
this.toolHandlers = /* @__PURE__ */ new Map();
|
|
412
453
|
this.pendingStreamTokens = /* @__PURE__ */ new Map();
|
|
454
|
+
this.activeSockets = /* @__PURE__ */ new Set();
|
|
455
|
+
this.serverClosingSockets = /* @__PURE__ */ new WeakSet();
|
|
413
456
|
this.activeBridgesByCallId = /* @__PURE__ */ new Map();
|
|
414
|
-
this.
|
|
415
|
-
this.
|
|
416
|
-
this.rawPartialUserTranscriptsByCallId = /* @__PURE__ */ new Map();
|
|
417
|
-
this.partialUserTranscriptUpdatedAtByCallId = /* @__PURE__ */ new Map();
|
|
418
|
-
this.recentFinalUserTranscriptsByCallId = /* @__PURE__ */ new Map();
|
|
419
|
-
this.recentFinalUserTranscriptTimersByCallId = /* @__PURE__ */ new Map();
|
|
457
|
+
this.activeTelephonyBindingsByCallId = /* @__PURE__ */ new Map();
|
|
458
|
+
this.userTranscriptStatesByCallId = /* @__PURE__ */ new Map();
|
|
420
459
|
this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
|
|
460
|
+
this.consultSessionsByCallId = /* @__PURE__ */ new Map();
|
|
421
461
|
this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
|
|
462
|
+
this.terminationAttempts = /* @__PURE__ */ new Set();
|
|
463
|
+
this.closePromise = null;
|
|
464
|
+
this.closing = false;
|
|
422
465
|
this.publicOrigin = null;
|
|
423
466
|
this.publicPathPrefix = "";
|
|
424
467
|
}
|
|
@@ -426,10 +469,7 @@ var RealtimeCallHandler = class {
|
|
|
426
469
|
try {
|
|
427
470
|
const parsed = new URL(url);
|
|
428
471
|
this.publicOrigin = parsed.host;
|
|
429
|
-
|
|
430
|
-
const normalizedPublicPath = normalizeWebhookPath(parsed.pathname);
|
|
431
|
-
const idx = normalizedPublicPath.indexOf(normalizedServePath);
|
|
432
|
-
this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
|
|
472
|
+
this.publicPathPrefix = resolveVoiceCallPublicPathPrefix(parsed.pathname, this.servePath);
|
|
433
473
|
} catch {
|
|
434
474
|
this.publicOrigin = null;
|
|
435
475
|
this.publicPathPrefix = "";
|
|
@@ -464,6 +504,11 @@ var RealtimeCallHandler = class {
|
|
|
464
504
|
}
|
|
465
505
|
}
|
|
466
506
|
handleWebSocketUpgrade(request, socket, head) {
|
|
507
|
+
if (this.closing) {
|
|
508
|
+
socket.write("HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n");
|
|
509
|
+
socket.destroy();
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
467
512
|
const token = new URL(request.url ?? "/", "wss://localhost").pathname.split("/").pop() ?? null;
|
|
468
513
|
const callerMeta = token ? this.consumeStreamToken(token) : null;
|
|
469
514
|
if (!callerMeta) {
|
|
@@ -476,10 +521,11 @@ var RealtimeCallHandler = class {
|
|
|
476
521
|
noServer: true,
|
|
477
522
|
maxPayload: MAX_REALTIME_MESSAGE_BYTES
|
|
478
523
|
}).handleUpgrade(request, socket, head, (ws) => {
|
|
479
|
-
|
|
524
|
+
this.activeSockets.add(ws);
|
|
525
|
+
let telephonyBinding = null;
|
|
480
526
|
let initialized = false;
|
|
481
527
|
let activeCallSid = "unknown";
|
|
482
|
-
let
|
|
528
|
+
let activeStreamSid = "unknown";
|
|
483
529
|
let lastMediaTimestamp;
|
|
484
530
|
let lastMediaGapWarnAt = 0;
|
|
485
531
|
ws.on("message", (data) => {
|
|
@@ -490,15 +536,18 @@ var RealtimeCallHandler = class {
|
|
|
490
536
|
if (initialized) return;
|
|
491
537
|
initialized = true;
|
|
492
538
|
activeCallSid = frame.providerCallId;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
539
|
+
activeStreamSid = frame.streamId;
|
|
540
|
+
const nextBinding = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
|
|
541
|
+
if (!nextBinding) return;
|
|
542
|
+
telephonyBinding = nextBinding;
|
|
543
|
+
this.streamDisconnectLifecycle.connect(activeCallSid, activeStreamSid);
|
|
496
544
|
return;
|
|
497
545
|
}
|
|
498
|
-
if (!
|
|
546
|
+
if (!telephonyBinding) return;
|
|
499
547
|
if (frame.kind === "media") {
|
|
500
548
|
const audio = Buffer.from(frame.payloadBase64, "base64");
|
|
501
|
-
|
|
549
|
+
telephonyBinding.noteMediaActivity();
|
|
550
|
+
telephonyBinding.bridge.sendAudio(audio);
|
|
502
551
|
if (frame.timestampMs !== void 0) {
|
|
503
552
|
if (lastMediaTimestamp !== void 0) {
|
|
504
553
|
const gapMs = frame.timestampMs - lastMediaTimestamp;
|
|
@@ -509,35 +558,60 @@ var RealtimeCallHandler = class {
|
|
|
509
558
|
}
|
|
510
559
|
}
|
|
511
560
|
lastMediaTimestamp = frame.timestampMs;
|
|
512
|
-
bridge.setMediaTimestamp(frame.timestampMs);
|
|
561
|
+
telephonyBinding.bridge.setMediaTimestamp(frame.timestampMs);
|
|
513
562
|
}
|
|
514
563
|
return;
|
|
515
564
|
}
|
|
516
565
|
if (frame.kind === "mark") {
|
|
517
|
-
bridge.acknowledgeMark();
|
|
566
|
+
telephonyBinding.bridge.acknowledgeMark();
|
|
518
567
|
return;
|
|
519
568
|
}
|
|
520
569
|
if (frame.kind === "error") {
|
|
521
570
|
console.error(`[voice-call] realtime WS error frame providerCallId=${activeCallSid} code=${frame.code ?? "?"} title=${frame.title ?? ""} detail=${frame.detail ?? ""}`);
|
|
522
571
|
return;
|
|
523
572
|
}
|
|
524
|
-
if (frame.kind === "stop")
|
|
525
|
-
stopReceived = true;
|
|
526
|
-
this.closeTelephonyBridge(activeCallSid, bridge, "completed");
|
|
527
|
-
}
|
|
573
|
+
if (frame.kind === "stop") telephonyBinding.close("disconnect");
|
|
528
574
|
} catch (error) {
|
|
529
575
|
console.error("[voice-call] realtime WS parse failed:", error);
|
|
530
576
|
}
|
|
531
577
|
});
|
|
532
|
-
ws.on("close", (
|
|
533
|
-
|
|
534
|
-
this.
|
|
578
|
+
ws.on("close", () => {
|
|
579
|
+
this.activeSockets.delete(ws);
|
|
580
|
+
const reason = this.serverClosingSockets.has(ws) ? "shutdown" : "disconnect";
|
|
581
|
+
if (telephonyBinding) telephonyBinding.close(reason);
|
|
535
582
|
});
|
|
536
583
|
ws.on("error", (error) => {
|
|
537
584
|
console.error("[voice-call] realtime WS error:", error);
|
|
585
|
+
ws.terminate();
|
|
538
586
|
});
|
|
587
|
+
if (this.closing) {
|
|
588
|
+
this.serverClosingSockets.add(ws);
|
|
589
|
+
ws.terminate();
|
|
590
|
+
}
|
|
539
591
|
});
|
|
540
592
|
}
|
|
593
|
+
close(shutdownBarrier = Promise.resolve()) {
|
|
594
|
+
if (this.closePromise) return this.closePromise;
|
|
595
|
+
this.closing = true;
|
|
596
|
+
this.pendingStreamTokens.clear();
|
|
597
|
+
const sockets = [...this.activeSockets];
|
|
598
|
+
this.closePromise = Promise.all([shutdownBarrier, ...sockets.map((ws) => new Promise((resolve) => {
|
|
599
|
+
if (ws.readyState === WebSocket$1.CLOSED) {
|
|
600
|
+
resolve();
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
this.serverClosingSockets.add(ws);
|
|
604
|
+
ws.once("close", () => resolve());
|
|
605
|
+
ws.terminate();
|
|
606
|
+
}))]).then(async () => {
|
|
607
|
+
await Promise.all(this.terminationAttempts);
|
|
608
|
+
this.pendingStreamTokens.clear();
|
|
609
|
+
}).finally(() => {
|
|
610
|
+
this.closing = false;
|
|
611
|
+
this.closePromise = null;
|
|
612
|
+
});
|
|
613
|
+
return this.closePromise;
|
|
614
|
+
}
|
|
541
615
|
registerToolHandler(name, fn) {
|
|
542
616
|
this.toolHandlers.set(name, fn);
|
|
543
617
|
}
|
|
@@ -572,13 +646,24 @@ var RealtimeCallHandler = class {
|
|
|
572
646
|
}
|
|
573
647
|
issueStreamToken(meta = {}) {
|
|
574
648
|
const token = randomUUID();
|
|
575
|
-
const
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
649
|
+
const expiry = resolveExpiresAtMsFromDurationMs(STREAM_TOKEN_TTL_MS, { nowMs: Date.now() });
|
|
650
|
+
if (expiry !== void 0) {
|
|
651
|
+
this.pendingStreamTokens.set(token, {
|
|
652
|
+
expiry,
|
|
653
|
+
...meta
|
|
654
|
+
});
|
|
655
|
+
const host = this.publicOrigin || DEFAULT_HOST;
|
|
656
|
+
const streamPathPattern = this.getStreamPathPattern();
|
|
657
|
+
setTimeout(() => {
|
|
658
|
+
if (!this.pendingStreamTokens.has(token)) return;
|
|
659
|
+
this.pendingStreamTokens.delete(token);
|
|
660
|
+
if (this.closing) return;
|
|
661
|
+
const call = meta.callId ? ` for call ${meta.callId}` : "";
|
|
662
|
+
const endpoints = [meta.from ? `from ${meta.from}` : "", meta.to ? `to ${meta.to}` : ""].filter(Boolean).join(" ");
|
|
663
|
+
const participants = endpoints ? ` (${endpoints})` : "";
|
|
664
|
+
console.warn(`[voice-call] Realtime stream WebSocket never connected within ${STREAM_TOKEN_TTL_MS / 1e3}s${call}${participants} — the provider could not reach wss://${host}${streamPathPattern}/<token>. Verify the stream path is exposed (tailscale serve/funnel --set-path).`);
|
|
665
|
+
}, STREAM_TOKEN_TTL_MS).unref?.();
|
|
666
|
+
}
|
|
582
667
|
return token;
|
|
583
668
|
}
|
|
584
669
|
consumeStreamToken(token) {
|
|
@@ -595,20 +680,58 @@ var RealtimeCallHandler = class {
|
|
|
595
680
|
};
|
|
596
681
|
}
|
|
597
682
|
handleCall(streamSid, callSid, ws, callerMeta, adapter) {
|
|
598
|
-
const
|
|
599
|
-
if (!
|
|
683
|
+
const preparedCall = this.prepareCallInManager(callSid, callerMeta);
|
|
684
|
+
if (!preparedCall) {
|
|
600
685
|
ws.close(1008, "Caller rejected by policy");
|
|
601
686
|
return null;
|
|
602
687
|
}
|
|
603
|
-
const {
|
|
604
|
-
const
|
|
688
|
+
const { callRecord } = preparedCall;
|
|
689
|
+
const callId = callRecord.callId;
|
|
690
|
+
const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
|
|
691
|
+
const previousTelephonyBinding = this.activeTelephonyBindingsByCallId.get(callId);
|
|
692
|
+
let callEndPromise;
|
|
693
|
+
const emitCallEnd = (cause) => {
|
|
694
|
+
if (callEndPromise) return callEndPromise;
|
|
695
|
+
const reason = cause === "error" ? "error" : cause === "inactivity" ? "timeout" : "completed";
|
|
696
|
+
const attempt = this.manager.endCall(callId, { reason }).then((result) => {
|
|
697
|
+
if (!result.success) {
|
|
698
|
+
console.warn(`[voice-call] Failed to end realtime call callId=${callId} providerCallId=${callSid} reason=${reason}: ${result.error ?? "unknown error"}; call remains active`);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
console.log(`[voice-call] Realtime call ended callId=${callId} providerCallId=${callSid} reason=${cause}`);
|
|
702
|
+
});
|
|
703
|
+
callEndPromise = attempt;
|
|
704
|
+
this.terminationAttempts.add(attempt);
|
|
705
|
+
const release = () => this.terminationAttempts.delete(attempt);
|
|
706
|
+
attempt.then(release, release);
|
|
707
|
+
return attempt;
|
|
708
|
+
};
|
|
709
|
+
let registration;
|
|
710
|
+
try {
|
|
711
|
+
registration = this.resolveCallRegistration(callRecord);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
console.error(`[voice-call] Failed to resolve realtime call registration callId=${callId} providerCallId=${callSid}: ${formatErrorMessage(error)}`);
|
|
714
|
+
if (!hadPredecessorOnAdmission) emitCallEnd("error");
|
|
715
|
+
ws.close(1011, "Check realtime configuration for routed agent");
|
|
716
|
+
return null;
|
|
717
|
+
}
|
|
718
|
+
const { baseFields, initialGreeting } = preparedCall;
|
|
719
|
+
if (callRecord.metadata) delete callRecord.metadata.initialMessage;
|
|
720
|
+
this.manager.processEvent({
|
|
721
|
+
id: `realtime-answered-${callSid}`,
|
|
722
|
+
callId,
|
|
723
|
+
type: "call.answered",
|
|
724
|
+
...baseFields
|
|
725
|
+
});
|
|
726
|
+
const { agentId, instructions, provider: realtimeProvider, providerConfig } = registration;
|
|
727
|
+
const initialGreetingInstructions = buildGreetingInstructions(instructions, initialGreeting);
|
|
605
728
|
const harness = createRealtimeVoiceSessionHarness({
|
|
606
729
|
talk: {
|
|
607
730
|
sessionId: `voice-call:${callId}:realtime`,
|
|
608
731
|
mode: "realtime",
|
|
609
732
|
transport: "gateway-relay",
|
|
610
733
|
brain: "agent-consult",
|
|
611
|
-
provider:
|
|
734
|
+
provider: realtimeProvider.id
|
|
612
735
|
},
|
|
613
736
|
talkPayloads: {
|
|
614
737
|
turnStarted: () => ({
|
|
@@ -634,7 +757,7 @@ var RealtimeCallHandler = class {
|
|
|
634
757
|
},
|
|
635
758
|
onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
|
|
636
759
|
});
|
|
637
|
-
let providerHandlesInputAudioBargeIn =
|
|
760
|
+
let providerHandlesInputAudioBargeIn = realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
|
|
638
761
|
const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
|
|
639
762
|
const outputAudioActive = harness.talk.outputAudioActive;
|
|
640
763
|
const pendingTelephonyAudio = audioPacer.hasPendingAudio();
|
|
@@ -664,12 +787,6 @@ var RealtimeCallHandler = class {
|
|
|
664
787
|
}
|
|
665
788
|
});
|
|
666
789
|
console.log(`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`);
|
|
667
|
-
let callEndEmitted = false;
|
|
668
|
-
const emitCallEnd = (reason) => {
|
|
669
|
-
if (callEndEmitted) return;
|
|
670
|
-
callEndEmitted = true;
|
|
671
|
-
this.endCallInManager(callSid, callId, reason);
|
|
672
|
-
};
|
|
673
790
|
const sendString = (message) => {
|
|
674
791
|
if (ws.readyState !== WebSocket$1.OPEN) return false;
|
|
675
792
|
if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
|
|
@@ -702,11 +819,15 @@ var RealtimeCallHandler = class {
|
|
|
702
819
|
speechFrames: BARGE_IN_REQUIRED_LOUD_CHUNKS,
|
|
703
820
|
silenceFrames: 12
|
|
704
821
|
});
|
|
705
|
-
const interruptResponseOnInputAudio = typeof
|
|
706
|
-
const
|
|
707
|
-
|
|
822
|
+
const interruptResponseOnInputAudio = typeof providerConfig.interruptResponseOnInputAudio === "boolean" ? providerConfig.interruptResponseOnInputAudio : void 0;
|
|
823
|
+
const nativeConsultOwner = {};
|
|
824
|
+
const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
|
|
825
|
+
const userTranscriptOwner = userTranscriptAdoption.owner;
|
|
826
|
+
const bridgeParams = {
|
|
827
|
+
provider: realtimeProvider,
|
|
708
828
|
cfg: this.coreConfig,
|
|
709
|
-
|
|
829
|
+
agentId,
|
|
830
|
+
providerConfig,
|
|
710
831
|
interruptResponseOnInputAudio,
|
|
711
832
|
instructions,
|
|
712
833
|
tools: this.config.tools,
|
|
@@ -734,6 +855,8 @@ var RealtimeCallHandler = class {
|
|
|
734
855
|
}
|
|
735
856
|
},
|
|
736
857
|
onTranscript: (role, text, isFinal) => {
|
|
858
|
+
const owner = nativeConsultOwner.current;
|
|
859
|
+
if (!this.getUserTranscriptState(callId, userTranscriptOwner) || owner && !this.isActiveBridgeOwner(callId, owner)) return;
|
|
737
860
|
const turnId = harness.ensureTurn();
|
|
738
861
|
const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
|
|
739
862
|
const payload = role === "assistant" ? { text } : {
|
|
@@ -757,19 +880,22 @@ var RealtimeCallHandler = class {
|
|
|
757
880
|
});
|
|
758
881
|
if (!isFinal) {
|
|
759
882
|
if (role === "user" && text.trim()) {
|
|
760
|
-
const transcript = this.recordPartialUserTranscript(callId, text);
|
|
883
|
+
const transcript = this.recordPartialUserTranscript(callId, userTranscriptOwner, text);
|
|
884
|
+
if (!transcript) return;
|
|
761
885
|
console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=false chars=${text.trim().length} aggregateChars=${transcript.length}`);
|
|
762
886
|
}
|
|
763
887
|
return;
|
|
764
888
|
}
|
|
765
889
|
if (role === "user") {
|
|
890
|
+
const state = this.getUserTranscriptState(callId, userTranscriptOwner);
|
|
891
|
+
if (!state) return;
|
|
766
892
|
const transcript = resolveFinalTranscriptText({
|
|
767
|
-
partial:
|
|
768
|
-
rawPartial:
|
|
893
|
+
partial: state.partial,
|
|
894
|
+
rawPartial: state.rawPartial,
|
|
769
895
|
final: text
|
|
770
896
|
});
|
|
771
|
-
this.clearPartialUserTranscript(callId);
|
|
772
|
-
this.setRecentFinalUserTranscript(callId, transcript);
|
|
897
|
+
this.clearPartialUserTranscript(callId, userTranscriptOwner);
|
|
898
|
+
this.setRecentFinalUserTranscript(callId, userTranscriptOwner, transcript);
|
|
773
899
|
console.log(`[voice-call] realtime input transcript callId=${callId} providerCallId=${callSid} final=true chars=${text.trim().length} aggregateChars=${transcript.length}`);
|
|
774
900
|
const event = {
|
|
775
901
|
id: `realtime-speech-${callSid}-${Date.now()}`,
|
|
@@ -787,6 +913,7 @@ var RealtimeCallHandler = class {
|
|
|
787
913
|
callId,
|
|
788
914
|
callSid,
|
|
789
915
|
transcript,
|
|
916
|
+
userTranscriptOwner,
|
|
790
917
|
clearAudio: () => {
|
|
791
918
|
const clearedBytes = audioPacer.clearAudio();
|
|
792
919
|
console.log(`[voice-call] realtime forced consult cleared outbound audio callId=${callId} providerCallId=${callSid} queuedBytes=${clearedBytes}`);
|
|
@@ -816,9 +943,30 @@ var RealtimeCallHandler = class {
|
|
|
816
943
|
}
|
|
817
944
|
});
|
|
818
945
|
console.log(`[voice-call] realtime tool call received callId=${callId} providerCallId=${callSid} tool=${toolEvent.name}`);
|
|
819
|
-
return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness);
|
|
946
|
+
return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, harness, userTranscriptOwner);
|
|
820
947
|
},
|
|
821
948
|
onEvent: (event) => {
|
|
949
|
+
if (event.direction === "client" && event.type === "session.continuity.reset") {
|
|
950
|
+
const turnId = harness.talk.activeTurnId;
|
|
951
|
+
const owner = nativeConsultOwner.current;
|
|
952
|
+
if (owner && this.isActiveBridgeOwner(callId, owner)) {
|
|
953
|
+
this.resetUserTranscriptState(callId, userTranscriptOwner);
|
|
954
|
+
this.resetConsultSessionForContinuity(callId, owner);
|
|
955
|
+
}
|
|
956
|
+
harness.flushOutput(() => {
|
|
957
|
+
audioPacer.clearAudio();
|
|
958
|
+
harness.finishOutputAudio(event.type);
|
|
959
|
+
});
|
|
960
|
+
if (turnId) harness.talk.cancelTurn({
|
|
961
|
+
turnId,
|
|
962
|
+
payload: {
|
|
963
|
+
callId,
|
|
964
|
+
providerCallId: callSid,
|
|
965
|
+
reason: event.type
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
822
970
|
if (event.type === "input_audio_buffer.speech_started") {
|
|
823
971
|
harness.ensureTurn();
|
|
824
972
|
return;
|
|
@@ -838,17 +986,15 @@ var RealtimeCallHandler = class {
|
|
|
838
986
|
});
|
|
839
987
|
return;
|
|
840
988
|
}
|
|
841
|
-
if (event.type === "response.done") {
|
|
842
|
-
harness.finishOutputAudio("response.done");
|
|
843
|
-
harness.endTurn("response.done");
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
989
|
if (event.type === "error") harness.emit({
|
|
847
990
|
type: "session.error",
|
|
848
991
|
payload: { message: event.detail ?? "Realtime provider error" },
|
|
849
992
|
final: true
|
|
850
993
|
});
|
|
851
994
|
},
|
|
995
|
+
onResponseDone: (outcome) => {
|
|
996
|
+
if (outcome.status === "failed" || outcome.status === "incomplete") console.warn(`[voice-call] realtime response ${outcome.status}: ${outcome.message}`);
|
|
997
|
+
},
|
|
852
998
|
onReady: () => {
|
|
853
999
|
harness.emit({
|
|
854
1000
|
type: "session.ready",
|
|
@@ -867,11 +1013,13 @@ var RealtimeCallHandler = class {
|
|
|
867
1013
|
});
|
|
868
1014
|
},
|
|
869
1015
|
onClose: (reason) => {
|
|
870
|
-
|
|
871
|
-
this.
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1016
|
+
const owner = nativeConsultOwner.current;
|
|
1017
|
+
const ownsCallState = owner ? this.isActiveBridgeOwner(callId, owner) : false;
|
|
1018
|
+
if (owner) {
|
|
1019
|
+
this.clearActiveBridgeMappings(callId, callSid, owner);
|
|
1020
|
+
this.cancelConsultSession(callId, owner);
|
|
1021
|
+
}
|
|
1022
|
+
if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
|
|
875
1023
|
harness.finishOutputAudio(reason);
|
|
876
1024
|
harness.emit({
|
|
877
1025
|
type: "session.closed",
|
|
@@ -879,29 +1027,33 @@ var RealtimeCallHandler = class {
|
|
|
879
1027
|
final: true
|
|
880
1028
|
});
|
|
881
1029
|
if (reason !== "error") return;
|
|
882
|
-
|
|
1030
|
+
this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
883
1031
|
if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Bridge disconnected");
|
|
884
|
-
this.
|
|
885
|
-
|
|
886
|
-
providerCallId: callSid,
|
|
887
|
-
reason: "error"
|
|
888
|
-
}).catch((error) => {
|
|
889
|
-
console.warn(`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(error)}`);
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
});
|
|
893
|
-
providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
|
|
894
|
-
const closeTelephony = (reason) => {
|
|
895
|
-
try {
|
|
896
|
-
session.close();
|
|
897
|
-
} finally {
|
|
898
|
-
emitCallEnd(reason);
|
|
1032
|
+
if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
|
|
1033
|
+
emitCallEnd("error");
|
|
899
1034
|
}
|
|
900
1035
|
};
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
1036
|
+
let session;
|
|
1037
|
+
try {
|
|
1038
|
+
session = harness.createBridge(bridgeParams);
|
|
1039
|
+
} catch (error) {
|
|
1040
|
+
this.rollbackUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
|
|
1041
|
+
harness.close();
|
|
1042
|
+
audioPacer.close();
|
|
1043
|
+
if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd("error");
|
|
1044
|
+
if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Failed to create realtime bridge");
|
|
1045
|
+
console.error("[voice-call] Failed to create realtime bridge:", error);
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
|
|
1049
|
+
nativeConsultOwner.current = session;
|
|
1050
|
+
providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
|
|
1051
|
+
const previousConsultSession = this.consultSessionsByCallId.get(callId);
|
|
1052
|
+
if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
|
|
1053
|
+
this.consultSessionsByCallId.set(callId, {
|
|
1054
|
+
owner: session,
|
|
1055
|
+
coordinator: harness.forcedConsults
|
|
1056
|
+
});
|
|
905
1057
|
const sendAudioToSession = session.sendAudio.bind(session);
|
|
906
1058
|
session.sendAudio = (audio) => {
|
|
907
1059
|
if (speechDetector.accept({
|
|
@@ -924,86 +1076,239 @@ var RealtimeCallHandler = class {
|
|
|
924
1076
|
try {
|
|
925
1077
|
closeSession();
|
|
926
1078
|
} finally {
|
|
927
|
-
this.
|
|
928
|
-
this.
|
|
929
|
-
this.
|
|
930
|
-
this.activeTelephonyClosersByCallId.delete(callSid);
|
|
931
|
-
this.clearUserTranscriptState(callId);
|
|
932
|
-
this.forcedConsultsByCallId.delete(callId);
|
|
1079
|
+
this.clearActiveBridgeMappings(callId, callSid, session);
|
|
1080
|
+
this.cancelConsultSession(callId, session);
|
|
1081
|
+
this.clearUserTranscriptState(callId, userTranscriptOwner);
|
|
933
1082
|
harness.close();
|
|
934
1083
|
audioPacer.close();
|
|
935
1084
|
}
|
|
936
1085
|
};
|
|
1086
|
+
let livenessTimer;
|
|
1087
|
+
const clearLivenessTimer = () => {
|
|
1088
|
+
if (livenessTimer) {
|
|
1089
|
+
clearTimeout(livenessTimer);
|
|
1090
|
+
livenessTimer = void 0;
|
|
1091
|
+
}
|
|
1092
|
+
};
|
|
1093
|
+
let bindingClosed = false;
|
|
1094
|
+
const closeBinding = (binding, cause) => {
|
|
1095
|
+
if (bindingClosed) return callEndPromise ?? Promise.resolve();
|
|
1096
|
+
bindingClosed = true;
|
|
1097
|
+
clearLivenessTimer();
|
|
1098
|
+
const ownsCall = this.activeTelephonyBindingsByCallId.get(callId) === binding;
|
|
1099
|
+
let termination = Promise.resolve();
|
|
1100
|
+
try {
|
|
1101
|
+
session.close();
|
|
1102
|
+
} catch (error) {
|
|
1103
|
+
console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(error)}`);
|
|
1104
|
+
} finally {
|
|
1105
|
+
this.clearActiveTelephonyBinding(callId, binding);
|
|
1106
|
+
if (cause === "disconnect") this.streamDisconnectLifecycle.disconnect(callSid, streamSid);
|
|
1107
|
+
else this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
1108
|
+
if (ownsCall && cause && cause !== "disconnect") termination = emitCallEnd(cause);
|
|
1109
|
+
}
|
|
1110
|
+
return termination;
|
|
1111
|
+
};
|
|
1112
|
+
const telephonyBinding = {
|
|
1113
|
+
bridge: session,
|
|
1114
|
+
close: (cause) => closeBinding(telephonyBinding, cause),
|
|
1115
|
+
endCall: () => {
|
|
1116
|
+
closeBinding(telephonyBinding);
|
|
1117
|
+
if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Call ended");
|
|
1118
|
+
},
|
|
1119
|
+
noteMediaActivity: () => {
|
|
1120
|
+
if (bindingClosed || this.activeTelephonyBindingsByCallId.get(callId) !== telephonyBinding) return;
|
|
1121
|
+
clearLivenessTimer();
|
|
1122
|
+
livenessTimer = setTimeout(() => {
|
|
1123
|
+
console.warn(`[voice-call] Realtime media inactive callId=${callId} providerCallId=${callSid} timeoutMs=${REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS} graceMs=${REALTIME_DISCONNECT_HANGUP_GRACE_MS}`);
|
|
1124
|
+
livenessTimer = setTimeout(() => {
|
|
1125
|
+
telephonyBinding.close("inactivity");
|
|
1126
|
+
if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Media inactivity");
|
|
1127
|
+
}, REALTIME_DISCONNECT_HANGUP_GRACE_MS);
|
|
1128
|
+
livenessTimer.unref?.();
|
|
1129
|
+
}, REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS);
|
|
1130
|
+
livenessTimer.unref?.();
|
|
1131
|
+
},
|
|
1132
|
+
retire: () => {
|
|
1133
|
+
closeBinding(telephonyBinding);
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
this.activeBridgesByCallId.set(callId, session);
|
|
1137
|
+
this.activeBridgesByCallId.set(callSid, session);
|
|
1138
|
+
this.activeTelephonyBindingsByCallId.set(callId, telephonyBinding);
|
|
1139
|
+
telephonyBinding.noteMediaActivity();
|
|
1140
|
+
if (previousTelephonyBinding && previousTelephonyBinding !== telephonyBinding) previousTelephonyBinding.retire();
|
|
937
1141
|
session.connect().catch((error) => {
|
|
938
1142
|
console.error("[voice-call] Failed to connect realtime bridge:", error);
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
1143
|
+
const ownsCallState = this.isActiveBridgeOwner(callId, session);
|
|
1144
|
+
this.streamDisconnectLifecycle.retire(callSid, streamSid);
|
|
1145
|
+
try {
|
|
1146
|
+
session.close();
|
|
1147
|
+
} catch (closeError) {
|
|
1148
|
+
console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(closeError)}`);
|
|
1149
|
+
} finally {
|
|
1150
|
+
if (ownsCallState) emitCallEnd("error");
|
|
1151
|
+
ws.close(1011, "Failed to connect");
|
|
1152
|
+
}
|
|
942
1153
|
});
|
|
943
|
-
return
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
this.
|
|
1154
|
+
return telephonyBinding;
|
|
1155
|
+
}
|
|
1156
|
+
beginUserTranscriptOwnerAdoption(callId) {
|
|
1157
|
+
const adoption = {
|
|
1158
|
+
owner: {},
|
|
1159
|
+
previous: this.userTranscriptStatesByCallId.get(callId)
|
|
1160
|
+
};
|
|
1161
|
+
this.userTranscriptStatesByCallId.set(callId, adoption.owner);
|
|
1162
|
+
return adoption;
|
|
1163
|
+
}
|
|
1164
|
+
commitUserTranscriptOwnerAdoption(callId, adoption) {
|
|
1165
|
+
if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
|
|
1166
|
+
if (adoption.previous?.recentFinalTimer) {
|
|
1167
|
+
clearTimeout(adoption.previous.recentFinalTimer);
|
|
1168
|
+
adoption.previous.recentFinalTimer = void 0;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
rollbackUserTranscriptOwnerAdoption(callId, adoption) {
|
|
1172
|
+
if (this.userTranscriptStatesByCallId.get(callId) !== adoption.owner) return;
|
|
1173
|
+
if (adoption.owner.recentFinalTimer) {
|
|
1174
|
+
clearTimeout(adoption.owner.recentFinalTimer);
|
|
1175
|
+
adoption.owner.recentFinalTimer = void 0;
|
|
1176
|
+
}
|
|
1177
|
+
if (adoption.previous) {
|
|
1178
|
+
this.userTranscriptStatesByCallId.set(callId, adoption.previous);
|
|
1179
|
+
return;
|
|
1180
|
+
}
|
|
1181
|
+
this.userTranscriptStatesByCallId.delete(callId);
|
|
1182
|
+
}
|
|
1183
|
+
getUserTranscriptState(callId, owner) {
|
|
1184
|
+
const state = this.userTranscriptStatesByCallId.get(callId);
|
|
1185
|
+
return state === owner ? state : void 0;
|
|
1186
|
+
}
|
|
1187
|
+
recordPartialUserTranscript(callId, owner, text) {
|
|
1188
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1189
|
+
if (!state) return;
|
|
1190
|
+
const next = limitPartialUserTranscript(appendTranscriptText(state.partial, text));
|
|
1191
|
+
const raw = limitPartialUserTranscript(`${state.rawPartial ?? ""}${text}`);
|
|
1192
|
+
state.partial = next;
|
|
1193
|
+
state.rawPartial = raw;
|
|
1194
|
+
state.partialUpdatedAt = Date.now();
|
|
951
1195
|
return next;
|
|
952
1196
|
}
|
|
953
|
-
clearPartialUserTranscript(callId) {
|
|
954
|
-
this.
|
|
955
|
-
|
|
956
|
-
|
|
1197
|
+
clearPartialUserTranscript(callId, owner) {
|
|
1198
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1199
|
+
if (!state) return;
|
|
1200
|
+
state.partial = void 0;
|
|
1201
|
+
state.rawPartial = void 0;
|
|
1202
|
+
state.partialUpdatedAt = void 0;
|
|
957
1203
|
}
|
|
958
|
-
setRecentFinalUserTranscript(callId, text) {
|
|
959
|
-
this.
|
|
960
|
-
|
|
1204
|
+
setRecentFinalUserTranscript(callId, owner, text) {
|
|
1205
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1206
|
+
if (!state) return;
|
|
1207
|
+
this.clearRecentFinalUserTranscript(callId, owner);
|
|
1208
|
+
state.recentFinal = text;
|
|
961
1209
|
const timer = setTimeout(() => {
|
|
962
|
-
if (this.
|
|
963
|
-
|
|
1210
|
+
if (this.userTranscriptStatesByCallId.get(callId) !== state) return;
|
|
1211
|
+
if (state.recentFinal === text) state.recentFinal = void 0;
|
|
1212
|
+
if (state.recentFinalTimer === timer) state.recentFinalTimer = void 0;
|
|
964
1213
|
}, RECENT_FINAL_USER_TRANSCRIPT_TTL_MS);
|
|
965
1214
|
timer.unref?.();
|
|
966
|
-
|
|
1215
|
+
state.recentFinalTimer = timer;
|
|
1216
|
+
}
|
|
1217
|
+
clearRecentFinalUserTranscript(callId, owner) {
|
|
1218
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1219
|
+
if (!state) return;
|
|
1220
|
+
if (state.recentFinalTimer) {
|
|
1221
|
+
clearTimeout(state.recentFinalTimer);
|
|
1222
|
+
state.recentFinalTimer = void 0;
|
|
1223
|
+
}
|
|
1224
|
+
state.recentFinal = void 0;
|
|
1225
|
+
}
|
|
1226
|
+
resetUserTranscriptState(callId, owner) {
|
|
1227
|
+
this.clearPartialUserTranscript(callId, owner);
|
|
1228
|
+
this.clearRecentFinalUserTranscript(callId, owner);
|
|
1229
|
+
}
|
|
1230
|
+
clearUserTranscriptState(callId, owner) {
|
|
1231
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1232
|
+
if (!state) return;
|
|
1233
|
+
if (state.recentFinalTimer) clearTimeout(state.recentFinalTimer);
|
|
1234
|
+
if (this.userTranscriptStatesByCallId.get(callId) === state) this.userTranscriptStatesByCallId.delete(callId);
|
|
967
1235
|
}
|
|
968
|
-
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
971
|
-
|
|
972
|
-
|
|
1236
|
+
cancelNativeConsult(callId, owner) {
|
|
1237
|
+
const state = this.nativeConsultsInFlightByCallId.get(callId);
|
|
1238
|
+
if (!state || state.owner !== owner) return;
|
|
1239
|
+
state.cancelled = true;
|
|
1240
|
+
this.nativeConsultsInFlightByCallId.delete(callId);
|
|
1241
|
+
state.cancel();
|
|
1242
|
+
}
|
|
1243
|
+
cancelForcedConsult(callId, owner) {
|
|
1244
|
+
const state = this.forcedConsultsByCallId.get(callId);
|
|
1245
|
+
if (!state || state.owner !== owner) return;
|
|
1246
|
+
state.cancelled = true;
|
|
1247
|
+
state.sendSpeechPrompt = false;
|
|
1248
|
+
state.cancel();
|
|
1249
|
+
this.forcedConsultsByCallId.delete(callId);
|
|
1250
|
+
}
|
|
1251
|
+
resetConsultSession(callId, owner) {
|
|
1252
|
+
const session = this.consultSessionsByCallId.get(callId);
|
|
1253
|
+
if (!session || session.owner !== owner) return false;
|
|
1254
|
+
session.coordinator.clearPending();
|
|
1255
|
+
this.cancelForcedConsult(callId, owner);
|
|
1256
|
+
this.cancelNativeConsult(callId, owner);
|
|
1257
|
+
return true;
|
|
1258
|
+
}
|
|
1259
|
+
resetConsultSessionForContinuity(callId, owner) {
|
|
1260
|
+
const session = this.consultSessionsByCallId.get(callId);
|
|
1261
|
+
if (!session || session.owner !== owner) return false;
|
|
1262
|
+
this.cancelForcedConsult(callId, owner);
|
|
1263
|
+
this.cancelNativeConsult(callId, owner);
|
|
1264
|
+
session.coordinator.clear();
|
|
1265
|
+
return true;
|
|
1266
|
+
}
|
|
1267
|
+
cancelConsultSession(callId, owner) {
|
|
1268
|
+
if (!owner || !this.resetConsultSession(callId, owner)) return;
|
|
1269
|
+
this.consultSessionsByCallId.delete(callId);
|
|
1270
|
+
}
|
|
1271
|
+
isActiveBridgeOwner(callId, owner) {
|
|
1272
|
+
return this.activeBridgesByCallId.get(callId) === owner;
|
|
1273
|
+
}
|
|
1274
|
+
clearActiveBridgeMappings(callId, callSid, owner) {
|
|
1275
|
+
for (const key of [callId, callSid]) {
|
|
1276
|
+
if (this.activeBridgesByCallId.get(key) !== owner) continue;
|
|
1277
|
+
this.activeBridgesByCallId.delete(key);
|
|
973
1278
|
}
|
|
974
|
-
this.recentFinalUserTranscriptsByCallId.delete(callId);
|
|
975
1279
|
}
|
|
976
|
-
|
|
977
|
-
this.
|
|
978
|
-
this.clearRecentFinalUserTranscript(callId);
|
|
1280
|
+
clearActiveTelephonyBinding(callId, binding) {
|
|
1281
|
+
if (this.activeTelephonyBindingsByCallId.get(callId) === binding) this.activeTelephonyBindingsByCallId.delete(callId);
|
|
979
1282
|
}
|
|
980
|
-
resolveUserTranscriptContext(callId) {
|
|
981
|
-
|
|
1283
|
+
resolveUserTranscriptContext(callId, owner) {
|
|
1284
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1285
|
+
return state?.partial ?? state?.recentFinal;
|
|
982
1286
|
}
|
|
983
|
-
consumePartialUserTranscript(callId, consumed) {
|
|
1287
|
+
consumePartialUserTranscript(callId, owner, consumed) {
|
|
984
1288
|
const text = consumed?.trim();
|
|
985
1289
|
if (!text) return;
|
|
986
|
-
const
|
|
1290
|
+
const state = this.getUserTranscriptState(callId, owner);
|
|
1291
|
+
const current = state?.partial;
|
|
987
1292
|
if (!current) return;
|
|
988
1293
|
if (current === text) {
|
|
989
|
-
this.clearPartialUserTranscript(callId);
|
|
1294
|
+
this.clearPartialUserTranscript(callId, owner);
|
|
990
1295
|
return;
|
|
991
1296
|
}
|
|
992
1297
|
if (current.toLowerCase().startsWith(text.toLowerCase())) {
|
|
993
1298
|
const remaining = current.slice(text.length).trimStart();
|
|
994
1299
|
if (remaining) {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
} else this.clearPartialUserTranscript(callId);
|
|
1300
|
+
state.partial = remaining;
|
|
1301
|
+
state.rawPartial = remaining;
|
|
1302
|
+
} else this.clearPartialUserTranscript(callId, owner);
|
|
998
1303
|
}
|
|
999
|
-
const recent =
|
|
1304
|
+
const recent = state.recentFinal;
|
|
1000
1305
|
if (!recent) return;
|
|
1001
|
-
if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId);
|
|
1306
|
+
if (recent === text || recent.toLowerCase().startsWith(text.toLowerCase())) this.clearRecentFinalUserTranscript(callId, owner);
|
|
1002
1307
|
}
|
|
1003
|
-
async waitForConsultTranscriptSettle(callId, startedAt) {
|
|
1308
|
+
async waitForConsultTranscriptSettle(callId, owner, startedAt) {
|
|
1004
1309
|
const deadline = startedAt + CONSULT_TRANSCRIPT_SETTLE_MAX_MS;
|
|
1005
1310
|
while (true) {
|
|
1006
|
-
const updatedAt = this.
|
|
1311
|
+
const updatedAt = this.getUserTranscriptState(callId, owner)?.partialUpdatedAt;
|
|
1007
1312
|
if (!updatedAt) return;
|
|
1008
1313
|
const now = Date.now();
|
|
1009
1314
|
const quietFor = now - updatedAt;
|
|
@@ -1013,16 +1318,8 @@ var RealtimeCallHandler = class {
|
|
|
1013
1318
|
});
|
|
1014
1319
|
}
|
|
1015
1320
|
}
|
|
1016
|
-
closeTelephonyBridge(callIdOrSid, bridge, reason) {
|
|
1017
|
-
const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
|
|
1018
|
-
if (closer) {
|
|
1019
|
-
closer(reason);
|
|
1020
|
-
return;
|
|
1021
|
-
}
|
|
1022
|
-
bridge?.close();
|
|
1023
|
-
}
|
|
1024
1321
|
scheduleForcedAgentConsult(params) {
|
|
1025
|
-
if (this.config.consultPolicy !== "always") return;
|
|
1322
|
+
if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
|
|
1026
1323
|
const question = params.transcript.trim();
|
|
1027
1324
|
if (!question) return;
|
|
1028
1325
|
const handler = this.toolHandlers.get(REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME);
|
|
@@ -1051,13 +1348,24 @@ var RealtimeCallHandler = class {
|
|
|
1051
1348
|
logger.debug(`[voice-call] realtime forced agent consult reason=${FORCED_CONSULT_REASON} consultPolicy=always callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
|
|
1052
1349
|
console.log(`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`);
|
|
1053
1350
|
params.clearAudio();
|
|
1351
|
+
const abortController = new AbortController();
|
|
1054
1352
|
const state = {
|
|
1353
|
+
owner: params.session,
|
|
1055
1354
|
sendSpeechPrompt: true,
|
|
1056
|
-
|
|
1355
|
+
cancelled: false,
|
|
1356
|
+
cancel: () => {
|
|
1357
|
+
abortController.abort(/* @__PURE__ */ new Error("Realtime forced consult owner was cancelled."));
|
|
1358
|
+
coordinator.markCancelled(params.handle);
|
|
1359
|
+
},
|
|
1360
|
+
promise: Promise.resolve().then(() => {
|
|
1361
|
+
abortController.signal.throwIfAborted();
|
|
1362
|
+
return params.handler({ question: params.handle.question }, params.callId, { abortSignal: abortController.signal });
|
|
1363
|
+
})
|
|
1057
1364
|
};
|
|
1058
1365
|
this.forcedConsultsByCallId.set(params.callId, state);
|
|
1059
1366
|
try {
|
|
1060
1367
|
const result = await state.promise;
|
|
1368
|
+
if (state.cancelled || this.forcedConsultsByCallId.get(params.callId) !== state) return;
|
|
1061
1369
|
state.completedAt = Date.now();
|
|
1062
1370
|
coordinator.markDelivered(params.handle);
|
|
1063
1371
|
const text = readSpeakableRealtimeVoiceToolResult(result, {
|
|
@@ -1073,11 +1381,12 @@ var RealtimeCallHandler = class {
|
|
|
1073
1381
|
params.session.sendUserMessage(buildForcedConsultSpeechPrompt(text));
|
|
1074
1382
|
}
|
|
1075
1383
|
console.log(`[voice-call] realtime forced agent consult completed callId=${params.callId} providerCallId=${params.callSid} elapsedMs=${Date.now() - startedAt}`);
|
|
1076
|
-
this.consumePartialUserTranscript(params.callId, params.handle.question);
|
|
1384
|
+
this.consumePartialUserTranscript(params.callId, params.userTranscriptOwner, params.handle.question);
|
|
1077
1385
|
} catch (error) {
|
|
1078
|
-
console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
|
|
1386
|
+
if (!state.cancelled) console.warn(`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`);
|
|
1079
1387
|
} finally {
|
|
1080
|
-
|
|
1388
|
+
if (!state.cancelled) if (this.forcedConsultsByCallId.get(params.callId) !== state) coordinator.remove(params.handle);
|
|
1389
|
+
else setTimeout(() => {
|
|
1081
1390
|
if (this.forcedConsultsByCallId.get(params.callId) === state) {
|
|
1082
1391
|
this.forcedConsultsByCallId.delete(params.callId);
|
|
1083
1392
|
coordinator.remove(params.handle);
|
|
@@ -1085,7 +1394,7 @@ var RealtimeCallHandler = class {
|
|
|
1085
1394
|
}, FORCED_CONSULT_NATIVE_DEDUPE_MS).unref?.();
|
|
1086
1395
|
}
|
|
1087
1396
|
}
|
|
1088
|
-
|
|
1397
|
+
prepareCallInManager(callSid, callerMeta = {}) {
|
|
1089
1398
|
const baseFields = {
|
|
1090
1399
|
providerCallId: callSid,
|
|
1091
1400
|
timestamp: Date.now(),
|
|
@@ -1097,18 +1406,10 @@ var RealtimeCallHandler = class {
|
|
|
1097
1406
|
if (!callRecord) return null;
|
|
1098
1407
|
const initialGreeting = this.extractInitialGreeting(callRecord);
|
|
1099
1408
|
console.log(`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`);
|
|
1100
|
-
if (callRecord.metadata) delete callRecord.metadata.initialMessage;
|
|
1101
|
-
this.manager.processEvent({
|
|
1102
|
-
id: `realtime-answered-${callSid}`,
|
|
1103
|
-
callId: callRecord.callId,
|
|
1104
|
-
type: "call.answered",
|
|
1105
|
-
...baseFields
|
|
1106
|
-
});
|
|
1107
|
-
const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
|
|
1108
1409
|
return {
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1410
|
+
callRecord,
|
|
1411
|
+
baseFields,
|
|
1412
|
+
initialGreeting
|
|
1112
1413
|
};
|
|
1113
1414
|
}
|
|
1114
1415
|
resolveRealtimeCall(callSid, callerMeta, baseFields) {
|
|
@@ -1127,17 +1428,57 @@ var RealtimeCallHandler = class {
|
|
|
1127
1428
|
extractInitialGreeting(call) {
|
|
1128
1429
|
return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
|
|
1129
1430
|
}
|
|
1130
|
-
|
|
1131
|
-
this.
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1431
|
+
async executeEndCallTool(params) {
|
|
1432
|
+
const binding = this.activeTelephonyBindingsByCallId.get(params.callId);
|
|
1433
|
+
if (!binding || binding.bridge !== params.bridge || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
|
|
1434
|
+
let result;
|
|
1435
|
+
try {
|
|
1436
|
+
result = await this.manager.endCall(params.callId);
|
|
1437
|
+
} catch (error) {
|
|
1438
|
+
result = {
|
|
1439
|
+
success: false,
|
|
1440
|
+
error: formatErrorMessage(error)
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
if (this.activeTelephonyBindingsByCallId.get(params.callId) !== binding || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
|
|
1444
|
+
if (!result.success) {
|
|
1445
|
+
const toolResult = { error: `Could not end the current phone call: ${result.error?.trim() || "the telephony provider returned no reason"}. Tell the caller the call could not be ended and they can hang up or ask you to try again.` };
|
|
1446
|
+
await params.bridge.submitToolResult(params.bridgeCallId, toolResult);
|
|
1447
|
+
params.harness.emit({
|
|
1448
|
+
type: "tool.error",
|
|
1449
|
+
turnId: params.turnId,
|
|
1450
|
+
callId: params.bridgeCallId,
|
|
1451
|
+
payload: {
|
|
1452
|
+
name: REALTIME_VOICE_END_CALL_TOOL_NAME,
|
|
1453
|
+
result: toolResult
|
|
1454
|
+
},
|
|
1455
|
+
final: true
|
|
1456
|
+
});
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
params.harness.emit({
|
|
1460
|
+
type: "tool.result",
|
|
1461
|
+
turnId: params.turnId,
|
|
1462
|
+
callId: params.bridgeCallId,
|
|
1463
|
+
payload: {
|
|
1464
|
+
name: REALTIME_VOICE_END_CALL_TOOL_NAME,
|
|
1465
|
+
result: { success: true }
|
|
1466
|
+
},
|
|
1467
|
+
final: true
|
|
1138
1468
|
});
|
|
1469
|
+
binding.endCall();
|
|
1139
1470
|
}
|
|
1140
|
-
async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness) {
|
|
1471
|
+
async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
|
|
1472
|
+
if (name === "openclaw_end_call") {
|
|
1473
|
+
await this.executeEndCallTool({
|
|
1474
|
+
bridge,
|
|
1475
|
+
callId,
|
|
1476
|
+
bridgeCallId,
|
|
1477
|
+
turnId,
|
|
1478
|
+
harness
|
|
1479
|
+
});
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1141
1482
|
const handler = this.toolHandlers.get(name);
|
|
1142
1483
|
const startedAt = Date.now();
|
|
1143
1484
|
const hasResultError = (result) => {
|
|
@@ -1174,13 +1515,15 @@ var RealtimeCallHandler = class {
|
|
|
1174
1515
|
}
|
|
1175
1516
|
};
|
|
1176
1517
|
if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
|
|
1518
|
+
if (this.activeBridgesByCallId.get(callId) !== bridge) return;
|
|
1177
1519
|
const coordinator = harness.forcedConsults;
|
|
1178
1520
|
const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
|
|
1179
1521
|
if (forcedMatch.kind === "none") {
|
|
1180
1522
|
const pending = coordinator.consumePending();
|
|
1181
1523
|
if (pending) coordinator.remove(pending);
|
|
1182
1524
|
}
|
|
1183
|
-
const
|
|
1525
|
+
const forcedConsultState = this.forcedConsultsByCallId.get(callId);
|
|
1526
|
+
const forcedConsult = forcedConsultState?.owner === bridge && !forcedConsultState.cancelled ? forcedConsultState : void 0;
|
|
1184
1527
|
if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
|
|
1185
1528
|
if (forcedConsult) forcedConsult.sendSpeechPrompt = false;
|
|
1186
1529
|
await submitFinalToolResult({
|
|
@@ -1198,24 +1541,50 @@ var RealtimeCallHandler = class {
|
|
|
1198
1541
|
return;
|
|
1199
1542
|
}
|
|
1200
1543
|
forcedConsult.sendSpeechPrompt = false;
|
|
1201
|
-
|
|
1544
|
+
const result = await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) }));
|
|
1545
|
+
if (forcedConsult.cancelled || forcedConsult.owner !== bridge || this.forcedConsultsByCallId.get(callId) !== forcedConsult) return;
|
|
1546
|
+
await submitFinalToolResult(result);
|
|
1202
1547
|
return;
|
|
1203
1548
|
}
|
|
1204
1549
|
const existingNativeConsult = this.nativeConsultsInFlightByCallId.get(callId);
|
|
1205
1550
|
if (existingNativeConsult) {
|
|
1206
1551
|
console.log(`[voice-call] realtime tool call sharing in-flight agent consult callId=${callId} ageMs=${Date.now() - existingNativeConsult.startedAt}`);
|
|
1207
1552
|
await submitWorkingResponse();
|
|
1208
|
-
await
|
|
1553
|
+
const outcome = await waitForNativeConsult(existingNativeConsult);
|
|
1554
|
+
if (outcome.kind === "cancelled") return;
|
|
1555
|
+
await submitFinalToolResult(outcome.result);
|
|
1209
1556
|
return;
|
|
1210
1557
|
}
|
|
1558
|
+
const abortController = new AbortController();
|
|
1559
|
+
let releaseCancellation = () => {};
|
|
1560
|
+
const cancellation = new Promise((resolve) => {
|
|
1561
|
+
releaseCancellation = resolve;
|
|
1562
|
+
});
|
|
1563
|
+
let completeConsult = (_result) => {};
|
|
1211
1564
|
const state = {
|
|
1565
|
+
owner: bridge,
|
|
1212
1566
|
startedAt,
|
|
1213
|
-
promise: Promise
|
|
1567
|
+
promise: new Promise((resolve) => {
|
|
1568
|
+
completeConsult = resolve;
|
|
1569
|
+
}),
|
|
1570
|
+
cancellation,
|
|
1571
|
+
cancelled: false,
|
|
1572
|
+
cancel: () => {
|
|
1573
|
+
abortController.abort(/* @__PURE__ */ new Error("Realtime native consult owner was cancelled."));
|
|
1574
|
+
releaseCancellation();
|
|
1575
|
+
}
|
|
1214
1576
|
};
|
|
1215
|
-
|
|
1577
|
+
this.nativeConsultsInFlightByCallId.set(callId, state);
|
|
1578
|
+
(async () => {
|
|
1216
1579
|
try {
|
|
1217
|
-
await
|
|
1218
|
-
|
|
1580
|
+
await submitWorkingResponse();
|
|
1581
|
+
if (state.cancelled) return;
|
|
1582
|
+
await Promise.race([this.waitForConsultTranscriptSettle(callId, userTranscriptOwner, startedAt), state.cancellation]);
|
|
1583
|
+
if (state.cancelled) return;
|
|
1584
|
+
const context = {
|
|
1585
|
+
partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner),
|
|
1586
|
+
abortSignal: abortController.signal
|
|
1587
|
+
};
|
|
1219
1588
|
state.partialUserTranscript = context.partialUserTranscript;
|
|
1220
1589
|
const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
|
|
1221
1590
|
console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
|
|
@@ -1223,29 +1592,30 @@ var RealtimeCallHandler = class {
|
|
|
1223
1592
|
} catch (error) {
|
|
1224
1593
|
return { error: formatErrorMessage(error) };
|
|
1225
1594
|
}
|
|
1226
|
-
});
|
|
1227
|
-
this.nativeConsultsInFlightByCallId.set(callId, state);
|
|
1595
|
+
})().then(completeConsult);
|
|
1228
1596
|
try {
|
|
1229
|
-
const
|
|
1597
|
+
const outcome = await waitForNativeConsult(state);
|
|
1598
|
+
if (outcome.kind === "cancelled") return;
|
|
1599
|
+
const result = outcome.result;
|
|
1230
1600
|
const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
|
|
1231
1601
|
const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
|
|
1232
1602
|
console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
|
|
1233
1603
|
await submitFinalToolResult(result);
|
|
1234
|
-
if (status === "ok") this.consumePartialUserTranscript(callId, state.partialUserTranscript);
|
|
1604
|
+
if (status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, state.partialUserTranscript);
|
|
1235
1605
|
} finally {
|
|
1236
1606
|
if (this.nativeConsultsInFlightByCallId.get(callId) === state) this.nativeConsultsInFlightByCallId.delete(callId);
|
|
1237
1607
|
}
|
|
1238
1608
|
return;
|
|
1239
1609
|
}
|
|
1240
1610
|
console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
|
|
1241
|
-
const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
|
|
1611
|
+
const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId, userTranscriptOwner) };
|
|
1242
1612
|
const handlerArgs = name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME ? withFallbackConsultQuestion(args, context.partialUserTranscript) : args;
|
|
1243
1613
|
const result = !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context).catch((error) => ({ error: formatErrorMessage(error) }));
|
|
1244
1614
|
const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
|
|
1245
1615
|
const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
|
|
1246
1616
|
console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
|
|
1247
1617
|
await submitFinalToolResult(result);
|
|
1248
|
-
if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, context.partialUserTranscript);
|
|
1618
|
+
if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, userTranscriptOwner, context.partialUserTranscript);
|
|
1249
1619
|
}
|
|
1250
1620
|
};
|
|
1251
1621
|
//#endregion
|