@openclaw/voice-call 2026.8.1-beta.2 → 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.
@@ -79,10 +79,12 @@ var MockProvider = class {
79
79
  }
80
80
  case "call.speech": {
81
81
  const payload = evt;
82
+ const transcript = payload.transcript ?? "";
83
+ if (!transcript.trim()) return null;
82
84
  return {
83
85
  ...base,
84
86
  type: evt.type,
85
- transcript: payload.transcript ?? "",
87
+ transcript,
86
88
  isFinal: payload.isFinal ?? true,
87
89
  confidence: payload.confidence
88
90
  };
@@ -1,5 +1,5 @@
1
- import { _ as escapeXml, s as getHeader } from "./runtime-entry-F13eJ4pp.js";
2
- import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DEDwgf4J.js";
1
+ import { _ as escapeXml, s as getHeader } from "./runtime-entry-DK8kPCGC.js";
2
+ import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-j4OEG0fR.js";
3
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
4
4
  import crypto from "node:crypto";
5
5
  //#region extensions/voice-call/src/providers/plivo.ts
@@ -1,17 +1,18 @@
1
- import { o as canonicalizeVoiceCallMediaBase64 } from "./runtime-entry-F13eJ4pp.js";
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 { asNullableRecord, asOptionalObjectRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
3
4
  import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
4
5
  import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
5
6
  import { normalizeWebhookPath } from "openclaw/plugin-sdk/webhook-ingress";
6
- import { randomUUID } from "node:crypto";
7
7
  import { isFutureDateTimestampMs, resolveExpiresAtMsFromDurationMs } from "openclaw/plugin-sdk/number-runtime";
8
+ import { randomUUID } from "node:crypto";
8
9
  import { sliceUtf16Safe, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
9
10
  import "node:http";
10
11
  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 TELEPHONY_CHUNK_MS = 20;
15
+ const LEAD_MS = 160;
15
16
  const DEFAULT_MAX_QUEUED_AUDIO_BYTES = TELEPHONY_SAMPLE_RATE * 120;
16
17
  const QUEUE_COMPACT_HEAD_THRESHOLD = 256;
17
18
  /** Paces outgoing mulaw audio frames at telephony cadence. */
@@ -23,6 +24,7 @@ var RealtimeAudioPacer = class {
23
24
  this.timer = null;
24
25
  this.queuedAudioBytes = 0;
25
26
  this.closed = false;
27
+ this.streamClockMs = null;
26
28
  }
27
29
  /** Queue mulaw audio and split it into 20ms-ish telephony chunks. */
28
30
  sendAudio(muLaw) {
@@ -37,7 +39,7 @@ var RealtimeAudioPacer = class {
37
39
  this.queue.push({
38
40
  type: "audio",
39
41
  chunk,
40
- durationMs: Math.max(1, Math.round(chunk.length / TELEPHONY_SAMPLE_RATE * 1e3))
42
+ durationMs: chunk.length / 8
41
43
  });
42
44
  this.queuedAudioBytes += chunk.length;
43
45
  }
@@ -59,6 +61,7 @@ var RealtimeAudioPacer = class {
59
61
  this.clearTimer();
60
62
  this.resetQueue();
61
63
  this.queuedAudioBytes = 0;
64
+ this.streamClockMs = null;
62
65
  this.params.send(this.params.serializer.clear());
63
66
  return clearedAudioBytes;
64
67
  }
@@ -72,6 +75,7 @@ var RealtimeAudioPacer = class {
72
75
  this.clearTimer();
73
76
  this.resetQueue();
74
77
  this.queuedAudioBytes = 0;
78
+ this.streamClockMs = null;
75
79
  }
76
80
  /** Clear the scheduled pump timer. */
77
81
  clearTimer() {
@@ -110,25 +114,34 @@ var RealtimeAudioPacer = class {
110
114
  this.queue.length = 0;
111
115
  this.queueHead = 0;
112
116
  }
113
- /** Send one queued item and schedule the next send based on audio duration. */
117
+ /** Fill the provider playout cushion, then wake at the next timeline boundary. */
114
118
  pump() {
115
119
  this.timer = null;
116
120
  if (this.closed) return;
117
- const item = this.takeNextItem();
118
- if (!item) return;
119
- let delayMs = 0;
120
- let sent;
121
- if (item.type === "audio") {
122
- this.queuedAudioBytes = Math.max(0, this.queuedAudioBytes - item.chunk.length);
123
- sent = this.params.send(this.params.serializer.media(item.chunk.toString("base64")));
124
- delayMs = item.durationMs || TELEPHONY_CHUNK_MS;
125
- } else sent = this.params.send(this.params.serializer.mark(item.name));
126
- if (!sent) {
127
- this.resetQueue();
128
- this.queuedAudioBytes = 0;
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;
129
135
  return;
130
136
  }
131
- if (this.pendingQueueSize > 0) this.timer = setTimeout(() => this.pump(), delayMs);
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;
132
145
  }
133
146
  };
134
147
  //#endregion
@@ -144,15 +157,13 @@ function parseTimestampMs(value) {
144
157
  /** Parse a JSON object frame, returning null for invalid or non-object payloads. */
145
158
  function tryParseJson(rawMessage) {
146
159
  try {
147
- const parsed = JSON.parse(rawMessage);
148
- if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
160
+ return asNullableRecord(JSON.parse(rawMessage));
149
161
  } catch {}
150
162
  return null;
151
163
  }
152
164
  /** Read an object-valued field from a parsed frame. */
153
165
  function readRecordField(record, field) {
154
- const value = record[field];
155
- return typeof value === "object" && value !== null ? value : void 0;
166
+ return asOptionalObjectRecord(record[field]);
156
167
  }
157
168
  /** Parse a common provider media frame. */
158
169
  function parseMediaFrame(msg) {
@@ -297,6 +308,8 @@ const STREAM_TOKEN_TTL_MS = 3e4;
297
308
  const DEFAULT_HOST = "localhost:8443";
298
309
  const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024;
299
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;
300
313
  const FORCED_CONSULT_FALLBACK_DELAY_MS = 200;
301
314
  const FORCED_CONSULT_NATIVE_DEDUPE_MS = 2e3;
302
315
  const FORCED_CONSULT_RESULT_MAX_CHARS = 1800;
@@ -429,25 +442,24 @@ function appendRecentTalkEventMetadata(call, event) {
429
442
  call.metadata = metadata;
430
443
  }
431
444
  var RealtimeCallHandler = class {
432
- constructor(config, manager, provider, realtimeProvider, providerConfig, servePath, coreConfig, resolveInstructions) {
445
+ constructor(config, manager, resolveCallRegistration, servePath, streamDisconnectLifecycle, coreConfig) {
433
446
  this.config = config;
434
447
  this.manager = manager;
435
- this.provider = provider;
436
- this.realtimeProvider = realtimeProvider;
437
- this.providerConfig = providerConfig;
448
+ this.resolveCallRegistration = resolveCallRegistration;
438
449
  this.servePath = servePath;
450
+ this.streamDisconnectLifecycle = streamDisconnectLifecycle;
439
451
  this.coreConfig = coreConfig;
440
- this.resolveInstructions = resolveInstructions;
441
452
  this.toolHandlers = /* @__PURE__ */ new Map();
442
453
  this.pendingStreamTokens = /* @__PURE__ */ new Map();
443
454
  this.activeSockets = /* @__PURE__ */ new Set();
444
455
  this.serverClosingSockets = /* @__PURE__ */ new WeakSet();
445
456
  this.activeBridgesByCallId = /* @__PURE__ */ new Map();
446
- this.activeTelephonyClosersByCallId = /* @__PURE__ */ new Map();
457
+ this.activeTelephonyBindingsByCallId = /* @__PURE__ */ new Map();
447
458
  this.userTranscriptStatesByCallId = /* @__PURE__ */ new Map();
448
459
  this.forcedConsultsByCallId = /* @__PURE__ */ new Map();
449
460
  this.consultSessionsByCallId = /* @__PURE__ */ new Map();
450
461
  this.nativeConsultsInFlightByCallId = /* @__PURE__ */ new Map();
462
+ this.terminationAttempts = /* @__PURE__ */ new Set();
451
463
  this.closePromise = null;
452
464
  this.closing = false;
453
465
  this.publicOrigin = null;
@@ -457,10 +469,7 @@ var RealtimeCallHandler = class {
457
469
  try {
458
470
  const parsed = new URL(url);
459
471
  this.publicOrigin = parsed.host;
460
- const normalizedServePath = normalizeWebhookPath(this.servePath);
461
- const normalizedPublicPath = normalizeWebhookPath(parsed.pathname);
462
- const idx = normalizedPublicPath.indexOf(normalizedServePath);
463
- this.publicPathPrefix = idx > 0 ? normalizedPublicPath.slice(0, idx) : "";
472
+ this.publicPathPrefix = resolveVoiceCallPublicPathPrefix(parsed.pathname, this.servePath);
464
473
  } catch {
465
474
  this.publicOrigin = null;
466
475
  this.publicPathPrefix = "";
@@ -513,10 +522,10 @@ var RealtimeCallHandler = class {
513
522
  maxPayload: MAX_REALTIME_MESSAGE_BYTES
514
523
  }).handleUpgrade(request, socket, head, (ws) => {
515
524
  this.activeSockets.add(ws);
516
- let bridge = null;
525
+ let telephonyBinding = null;
517
526
  let initialized = false;
518
527
  let activeCallSid = "unknown";
519
- let stopReceived = false;
528
+ let activeStreamSid = "unknown";
520
529
  let lastMediaTimestamp;
521
530
  let lastMediaGapWarnAt = 0;
522
531
  ws.on("message", (data) => {
@@ -527,15 +536,18 @@ var RealtimeCallHandler = class {
527
536
  if (initialized) return;
528
537
  initialized = true;
529
538
  activeCallSid = frame.providerCallId;
530
- const nextBridge = this.handleCall(frame.streamId, frame.providerCallId, ws, callerMeta, adapter);
531
- if (!nextBridge) return;
532
- bridge = nextBridge;
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);
533
544
  return;
534
545
  }
535
- if (!bridge) return;
546
+ if (!telephonyBinding) return;
536
547
  if (frame.kind === "media") {
537
548
  const audio = Buffer.from(frame.payloadBase64, "base64");
538
- bridge.sendAudio(audio);
549
+ telephonyBinding.noteMediaActivity();
550
+ telephonyBinding.bridge.sendAudio(audio);
539
551
  if (frame.timestampMs !== void 0) {
540
552
  if (lastMediaTimestamp !== void 0) {
541
553
  const gapMs = frame.timestampMs - lastMediaTimestamp;
@@ -546,33 +558,31 @@ var RealtimeCallHandler = class {
546
558
  }
547
559
  }
548
560
  lastMediaTimestamp = frame.timestampMs;
549
- bridge.setMediaTimestamp(frame.timestampMs);
561
+ telephonyBinding.bridge.setMediaTimestamp(frame.timestampMs);
550
562
  }
551
563
  return;
552
564
  }
553
565
  if (frame.kind === "mark") {
554
- bridge.acknowledgeMark();
566
+ telephonyBinding.bridge.acknowledgeMark();
555
567
  return;
556
568
  }
557
569
  if (frame.kind === "error") {
558
570
  console.error(`[voice-call] realtime WS error frame providerCallId=${activeCallSid} code=${frame.code ?? "?"} title=${frame.title ?? ""} detail=${frame.detail ?? ""}`);
559
571
  return;
560
572
  }
561
- if (frame.kind === "stop") {
562
- stopReceived = true;
563
- this.closeTelephonyBridge(activeCallSid, bridge, "completed");
564
- }
573
+ if (frame.kind === "stop") telephonyBinding.close("disconnect");
565
574
  } catch (error) {
566
575
  console.error("[voice-call] realtime WS parse failed:", error);
567
576
  }
568
577
  });
569
- ws.on("close", (code) => {
578
+ ws.on("close", () => {
570
579
  this.activeSockets.delete(ws);
571
- const reason = this.serverClosingSockets.has(ws) || stopReceived || code === 1e3 || code === 1005 ? "completed" : "error";
572
- this.closeTelephonyBridge(activeCallSid, bridge, reason);
580
+ const reason = this.serverClosingSockets.has(ws) ? "shutdown" : "disconnect";
581
+ if (telephonyBinding) telephonyBinding.close(reason);
573
582
  });
574
583
  ws.on("error", (error) => {
575
584
  console.error("[voice-call] realtime WS error:", error);
585
+ ws.terminate();
576
586
  });
577
587
  if (this.closing) {
578
588
  this.serverClosingSockets.add(ws);
@@ -593,7 +603,8 @@ var RealtimeCallHandler = class {
593
603
  this.serverClosingSockets.add(ws);
594
604
  ws.once("close", () => resolve());
595
605
  ws.terminate();
596
- }))]).then(() => {
606
+ }))]).then(async () => {
607
+ await Promise.all(this.terminationAttempts);
597
608
  this.pendingStreamTokens.clear();
598
609
  }).finally(() => {
599
610
  this.closing = false;
@@ -635,13 +646,24 @@ var RealtimeCallHandler = class {
635
646
  }
636
647
  issueStreamToken(meta = {}) {
637
648
  const token = randomUUID();
638
- const now = Date.now();
639
- const expiry = resolveExpiresAtMsFromDurationMs(STREAM_TOKEN_TTL_MS, { nowMs: now });
640
- if (expiry !== void 0) this.pendingStreamTokens.set(token, {
641
- expiry,
642
- ...meta
643
- });
644
- for (const [candidate, entry] of this.pendingStreamTokens) if (!isFutureDateTimestampMs(entry.expiry, { nowMs: now })) this.pendingStreamTokens.delete(candidate);
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
+ }
645
667
  return token;
646
668
  }
647
669
  consumeStreamToken(token) {
@@ -658,20 +680,58 @@ var RealtimeCallHandler = class {
658
680
  };
659
681
  }
660
682
  handleCall(streamSid, callSid, ws, callerMeta, adapter) {
661
- const registration = this.registerCallInManager(callSid, callerMeta);
662
- if (!registration) {
683
+ const preparedCall = this.prepareCallInManager(callSid, callerMeta);
684
+ if (!preparedCall) {
663
685
  ws.close(1008, "Caller rejected by policy");
664
686
  return null;
665
687
  }
666
- const { callId, instructions, initialGreetingInstructions } = registration;
667
- const callRecord = this.manager.getCallByProviderCallId(callSid);
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);
668
728
  const harness = createRealtimeVoiceSessionHarness({
669
729
  talk: {
670
730
  sessionId: `voice-call:${callId}:realtime`,
671
731
  mode: "realtime",
672
732
  transport: "gateway-relay",
673
733
  brain: "agent-consult",
674
- provider: this.realtimeProvider.id
734
+ provider: realtimeProvider.id
675
735
  },
676
736
  talkPayloads: {
677
737
  turnStarted: () => ({
@@ -697,7 +757,7 @@ var RealtimeCallHandler = class {
697
757
  },
698
758
  onTalkEvent: (event) => appendRecentTalkEventMetadata(callRecord, event)
699
759
  });
700
- let providerHandlesInputAudioBargeIn = this.realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
760
+ let providerHandlesInputAudioBargeIn = realtimeProvider.capabilities?.handlesInputAudioBargeIn === true;
701
761
  const cancelOutputAudioForBargeIn = (source, interruptProvider, clearedAudioBytes = 0) => {
702
762
  const outputAudioActive = harness.talk.outputAudioActive;
703
763
  const pendingTelephonyAudio = audioPacer.hasPendingAudio();
@@ -727,20 +787,6 @@ var RealtimeCallHandler = class {
727
787
  }
728
788
  });
729
789
  console.log(`[voice-call] Realtime bridge starting for call ${callId} (providerCallId=${callSid}, initialGreeting=${initialGreetingInstructions ? "queued" : "absent"})`);
730
- let callEndEmitted = false;
731
- const emitCallEnd = (reason) => {
732
- if (callEndEmitted) return;
733
- callEndEmitted = true;
734
- this.endCallInManager(callSid, callId, reason);
735
- if (reason !== "error") return;
736
- Promise.resolve(this.provider.hangupCall({
737
- callId,
738
- providerCallId: callSid,
739
- reason
740
- })).catch((error) => {
741
- console.warn(`[voice-call] Failed to hang up realtime call ${callSid}: ${formatErrorMessage(error)}`);
742
- });
743
- };
744
790
  const sendString = (message) => {
745
791
  if (ws.readyState !== WebSocket$1.OPEN) return false;
746
792
  if (ws.bufferedAmount > MAX_REALTIME_WS_BUFFERED_BYTES) {
@@ -773,15 +819,15 @@ var RealtimeCallHandler = class {
773
819
  speechFrames: BARGE_IN_REQUIRED_LOUD_CHUNKS,
774
820
  silenceFrames: 12
775
821
  });
776
- const interruptResponseOnInputAudio = typeof this.providerConfig.interruptResponseOnInputAudio === "boolean" ? this.providerConfig.interruptResponseOnInputAudio : void 0;
777
- const hadPredecessorOnAdmission = this.activeBridgesByCallId.has(callId);
822
+ const interruptResponseOnInputAudio = typeof providerConfig.interruptResponseOnInputAudio === "boolean" ? providerConfig.interruptResponseOnInputAudio : void 0;
778
823
  const nativeConsultOwner = {};
779
824
  const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
780
825
  const userTranscriptOwner = userTranscriptAdoption.owner;
781
826
  const bridgeParams = {
782
- provider: this.realtimeProvider,
827
+ provider: realtimeProvider,
783
828
  cfg: this.coreConfig,
784
- providerConfig: this.providerConfig,
829
+ agentId,
830
+ providerConfig,
785
831
  interruptResponseOnInputAudio,
786
832
  instructions,
787
833
  tools: this.config.tools,
@@ -981,6 +1027,7 @@ var RealtimeCallHandler = class {
981
1027
  final: true
982
1028
  });
983
1029
  if (reason !== "error") return;
1030
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
984
1031
  if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Bridge disconnected");
985
1032
  if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
986
1033
  emitCallEnd("error");
@@ -1001,27 +1048,12 @@ var RealtimeCallHandler = class {
1001
1048
  this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1002
1049
  nativeConsultOwner.current = session;
1003
1050
  providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
1004
- const closeTelephony = (reason) => {
1005
- try {
1006
- session.close();
1007
- } finally {
1008
- emitCallEnd(reason);
1009
- }
1010
- };
1011
1051
  const previousConsultSession = this.consultSessionsByCallId.get(callId);
1012
1052
  if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
1013
1053
  this.consultSessionsByCallId.set(callId, {
1014
1054
  owner: session,
1015
1055
  coordinator: harness.forcedConsults
1016
1056
  });
1017
- this.activeBridgesByCallId.set(callId, session);
1018
- this.activeBridgesByCallId.set(callSid, session);
1019
- const telephonyCloser = {
1020
- owner: session,
1021
- close: closeTelephony
1022
- };
1023
- this.activeTelephonyClosersByCallId.set(callId, telephonyCloser);
1024
- this.activeTelephonyClosersByCallId.set(callSid, telephonyCloser);
1025
1057
  const sendAudioToSession = session.sendAudio.bind(session);
1026
1058
  session.sendAudio = (audio) => {
1027
1059
  if (speechDetector.accept({
@@ -1051,9 +1083,65 @@ var RealtimeCallHandler = class {
1051
1083
  audioPacer.close();
1052
1084
  }
1053
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();
1054
1141
  session.connect().catch((error) => {
1055
1142
  console.error("[voice-call] Failed to connect realtime bridge:", error);
1056
1143
  const ownsCallState = this.isActiveBridgeOwner(callId, session);
1144
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
1057
1145
  try {
1058
1146
  session.close();
1059
1147
  } catch (closeError) {
@@ -1063,7 +1151,7 @@ var RealtimeCallHandler = class {
1063
1151
  ws.close(1011, "Failed to connect");
1064
1152
  }
1065
1153
  });
1066
- return session;
1154
+ return telephonyBinding;
1067
1155
  }
1068
1156
  beginUserTranscriptOwnerAdoption(callId) {
1069
1157
  const adoption = {
@@ -1187,9 +1275,11 @@ var RealtimeCallHandler = class {
1187
1275
  for (const key of [callId, callSid]) {
1188
1276
  if (this.activeBridgesByCallId.get(key) !== owner) continue;
1189
1277
  this.activeBridgesByCallId.delete(key);
1190
- this.activeTelephonyClosersByCallId.delete(key);
1191
1278
  }
1192
1279
  }
1280
+ clearActiveTelephonyBinding(callId, binding) {
1281
+ if (this.activeTelephonyBindingsByCallId.get(callId) === binding) this.activeTelephonyBindingsByCallId.delete(callId);
1282
+ }
1193
1283
  resolveUserTranscriptContext(callId, owner) {
1194
1284
  const state = this.getUserTranscriptState(callId, owner);
1195
1285
  return state?.partial ?? state?.recentFinal;
@@ -1228,14 +1318,6 @@ var RealtimeCallHandler = class {
1228
1318
  });
1229
1319
  }
1230
1320
  }
1231
- closeTelephonyBridge(callIdOrSid, bridge, reason) {
1232
- const closer = this.activeTelephonyClosersByCallId.get(callIdOrSid);
1233
- if (closer && closer.owner === bridge) {
1234
- closer.close(reason);
1235
- return;
1236
- }
1237
- bridge?.close();
1238
- }
1239
1321
  scheduleForcedAgentConsult(params) {
1240
1322
  if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
1241
1323
  const question = params.transcript.trim();
@@ -1312,7 +1394,7 @@ var RealtimeCallHandler = class {
1312
1394
  }, FORCED_CONSULT_NATIVE_DEDUPE_MS).unref?.();
1313
1395
  }
1314
1396
  }
1315
- registerCallInManager(callSid, callerMeta = {}) {
1397
+ prepareCallInManager(callSid, callerMeta = {}) {
1316
1398
  const baseFields = {
1317
1399
  providerCallId: callSid,
1318
1400
  timestamp: Date.now(),
@@ -1324,18 +1406,10 @@ var RealtimeCallHandler = class {
1324
1406
  if (!callRecord) return null;
1325
1407
  const initialGreeting = this.extractInitialGreeting(callRecord);
1326
1408
  console.log(`[voice-call] Realtime call ${callRecord.callId} initial greeting ${initialGreeting ? "queued" : "absent"}`);
1327
- if (callRecord.metadata) delete callRecord.metadata.initialMessage;
1328
- this.manager.processEvent({
1329
- id: `realtime-answered-${callSid}`,
1330
- callId: callRecord.callId,
1331
- type: "call.answered",
1332
- ...baseFields
1333
- });
1334
- const instructions = this.resolveInstructions?.(callRecord) ?? this.config.instructions;
1335
1409
  return {
1336
- callId: callRecord.callId,
1337
- instructions,
1338
- initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting)
1410
+ callRecord,
1411
+ baseFields,
1412
+ initialGreeting
1339
1413
  };
1340
1414
  }
1341
1415
  resolveRealtimeCall(callSid, callerMeta, baseFields) {
@@ -1354,17 +1428,57 @@ var RealtimeCallHandler = class {
1354
1428
  extractInitialGreeting(call) {
1355
1429
  return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
1356
1430
  }
1357
- endCallInManager(callSid, callId, reason) {
1358
- this.manager.processEvent({
1359
- id: `realtime-ended-${callSid}-${Date.now()}`,
1360
- type: "call.ended",
1361
- callId,
1362
- providerCallId: callSid,
1363
- timestamp: Date.now(),
1364
- reason
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
1365
1468
  });
1469
+ binding.endCall();
1366
1470
  }
1367
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
+ }
1368
1482
  const handler = this.toolHandlers.get(name);
1369
1483
  const startedAt = Date.now();
1370
1484
  const hasResultError = (result) => {