@openclaw/voice-call 2026.8.1-beta.2 → 2026.8.1

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.
Files changed (141) hide show
  1. package/README.md +4 -3
  2. package/dist/doctor-contract-api.js +10 -5
  3. package/dist/{guarded-json-api-DEDwgf4J.js → guarded-json-api-CQXCe4aR.js} +1 -1
  4. package/dist/index.js +390 -338
  5. package/dist/{mock-BCq0q36l.js → mock-Dv7KxYGt.js} +3 -1
  6. package/dist/{plivo-DbI17uAc.js → plivo-Ct5BT5LQ.js} +2 -2
  7. package/dist/{realtime-handler-uXR0QgVa.js → realtime-handler-CSK1A1Gg.js} +270 -144
  8. package/dist/{response-generator-D-hkzo5Y.js → response-generator-xbT37lYx.js} +59 -12
  9. package/dist/{runtime-entry-F13eJ4pp.js → runtime-entry-q02qkX8F.js} +435 -231
  10. package/dist/runtime-entry.js +1 -1
  11. package/dist/{store-path-51_oC_xn.js → store-path-Cf6k3F2U.js} +3 -10
  12. package/dist/{telnyx-sIS_WgnY.js → telnyx-C9Yizvox.js} +12 -8
  13. package/dist/{twilio-CbeVrVAl.js → twilio-BLjVv246.js} +40 -27
  14. package/node_modules/typebox/build/format/date.d.mts +1 -1
  15. package/node_modules/typebox/build/format/date.mjs +1 -1
  16. package/node_modules/typebox/build/format/date_time.d.mts +2 -2
  17. package/node_modules/typebox/build/format/date_time.mjs +3 -3
  18. package/node_modules/typebox/build/format/email.d.mts +1 -1
  19. package/node_modules/typebox/build/format/email.mjs +2 -2
  20. package/node_modules/typebox/build/format/hostname.mjs +2 -10
  21. package/node_modules/typebox/build/format/idn_email.d.mts +1 -1
  22. package/node_modules/typebox/build/format/idn_email.mjs +3 -3
  23. package/node_modules/typebox/build/format/idn_hostname.d.mts +1 -0
  24. package/node_modules/typebox/build/format/idn_hostname.mjs +3 -11
  25. package/node_modules/typebox/build/format/idna/format/bidi.d.mts +3 -0
  26. package/node_modules/typebox/build/format/idna/format/bidi.mjs +98 -0
  27. package/node_modules/typebox/build/format/idna/format/puny.d.mts +3 -0
  28. package/node_modules/typebox/build/format/idna/format/puny.mjs +144 -0
  29. package/node_modules/typebox/build/format/idna/hostname.d.mts +1 -0
  30. package/node_modules/typebox/build/format/idna/hostname.mjs +16 -0
  31. package/node_modules/typebox/build/format/idna/idn-hostname.d.mts +1 -0
  32. package/node_modules/typebox/build/format/idna/idn-hostname.mjs +23 -0
  33. package/node_modules/typebox/build/format/idna/index.d.mts +2 -0
  34. package/node_modules/typebox/build/format/idna/index.mjs +2 -0
  35. package/node_modules/typebox/build/format/idna/label/ascii.d.mts +1 -0
  36. package/node_modules/typebox/build/format/idna/label/ascii.mjs +9 -0
  37. package/node_modules/typebox/build/format/idna/label/puny.d.mts +1 -0
  38. package/node_modules/typebox/build/format/idna/label/puny.mjs +28 -0
  39. package/node_modules/typebox/build/format/idna/label/unicode.d.mts +1 -0
  40. package/node_modules/typebox/build/format/idna/label/unicode.mjs +106 -0
  41. package/node_modules/typebox/build/format/idna/pattern/pattern.d.mts +30 -0
  42. package/node_modules/typebox/build/format/idna/pattern/pattern.mjs +70 -0
  43. package/node_modules/typebox/build/format/ipv4.d.mts +0 -1
  44. package/node_modules/typebox/build/format/ipv4.mjs +2 -32
  45. package/node_modules/typebox/build/format/ipv6.mjs +2 -61
  46. package/node_modules/typebox/build/format/iri.d.mts +2 -2
  47. package/node_modules/typebox/build/format/iri.mjs +26 -8
  48. package/node_modules/typebox/build/format/iri_reference.d.mts +2 -2
  49. package/node_modules/typebox/build/format/iri_reference.mjs +9 -55
  50. package/node_modules/typebox/build/format/regex.d.mts +1 -2
  51. package/node_modules/typebox/build/format/regex.mjs +2 -6
  52. package/node_modules/typebox/build/format/time.d.mts +2 -2
  53. package/node_modules/typebox/build/format/time.mjs +19 -14
  54. package/node_modules/typebox/build/format/uri.d.mts +1 -1
  55. package/node_modules/typebox/build/format/uri.mjs +3 -130
  56. package/node_modules/typebox/build/format/uri_reference.d.mts +1 -1
  57. package/node_modules/typebox/build/format/uri_reference.mjs +2 -3
  58. package/node_modules/typebox/build/format/uri_template.d.mts +2 -3
  59. package/node_modules/typebox/build/format/uri_template.mjs +4 -5
  60. package/node_modules/typebox/build/format/url.d.mts +3 -3
  61. package/node_modules/typebox/build/format/url.mjs +4 -5
  62. package/node_modules/typebox/build/guard/emit.d.mts +3 -1
  63. package/node_modules/typebox/build/guard/emit.mjs +12 -5
  64. package/node_modules/typebox/build/guard/guard.d.mts +8 -2
  65. package/node_modules/typebox/build/guard/guard.mjs +25 -4
  66. package/node_modules/typebox/build/guard/string.d.mts +2 -2
  67. package/node_modules/typebox/build/guard/string.mjs +7 -7
  68. package/node_modules/typebox/build/schema/engine/_context.mjs +3 -3
  69. package/node_modules/typebox/build/schema/engine/_functions.mjs +1 -2
  70. package/node_modules/typebox/build/schema/engine/_regexp.d.mts +2 -0
  71. package/node_modules/typebox/build/schema/engine/_regexp.mjs +4 -0
  72. package/node_modules/typebox/build/schema/engine/_stack.mjs +12 -13
  73. package/node_modules/typebox/build/schema/engine/additionalItems.mjs +17 -7
  74. package/node_modules/typebox/build/schema/engine/additionalProperties.mjs +4 -3
  75. package/node_modules/typebox/build/schema/engine/contains.mjs +18 -6
  76. package/node_modules/typebox/build/schema/engine/enum.mjs +1 -1
  77. package/node_modules/typebox/build/schema/engine/items.mjs +26 -6
  78. package/node_modules/typebox/build/schema/engine/maxContains.mjs +3 -3
  79. package/node_modules/typebox/build/schema/engine/minContains.mjs +14 -4
  80. package/node_modules/typebox/build/schema/engine/not.mjs +4 -2
  81. package/node_modules/typebox/build/schema/engine/oneOf.mjs +7 -6
  82. package/node_modules/typebox/build/schema/engine/pattern.mjs +3 -2
  83. package/node_modules/typebox/build/schema/engine/patternProperties.mjs +4 -3
  84. package/node_modules/typebox/build/schema/engine/ref.mjs +1 -7
  85. package/node_modules/typebox/build/schema/engine/schema.mjs +13 -4
  86. package/node_modules/typebox/build/schema/engine/type.mjs +1 -1
  87. package/node_modules/typebox/build/schema/resolve/resolve.d.mts +4 -1
  88. package/node_modules/typebox/build/schema/resolve/resolve.mjs +202 -1
  89. package/node_modules/typebox/build/schema/static/propertyNames.d.mts +5 -0
  90. package/node_modules/typebox/build/schema/static/propertyNames.mjs +3 -0
  91. package/node_modules/typebox/build/schema/static/schema.d.mts +3 -0
  92. package/node_modules/typebox/build/schema/types/index.d.mts +0 -2
  93. package/node_modules/typebox/build/schema/types/index.mjs +0 -2
  94. package/node_modules/typebox/build/system/memory/clone.mjs +14 -11
  95. package/node_modules/typebox/build/system/memory/discard.mjs +6 -5
  96. package/node_modules/typebox/build/system/settings/settings.d.mts +9 -0
  97. package/node_modules/typebox/build/system/settings/settings.mjs +2 -0
  98. package/node_modules/typebox/build/type/engine/call/distribute_arguments.d.mts +6 -1
  99. package/node_modules/typebox/build/type/engine/call/distribute_arguments.mjs +16 -3
  100. package/node_modules/typebox/build/type/engine/call/instantiate.mjs +34 -4
  101. package/node_modules/typebox/build/type/engine/evaluate/broaden.d.mts +5 -5
  102. package/node_modules/typebox/build/type/engine/evaluate/broaden.mjs +20 -26
  103. package/node_modules/typebox/build/type/engine/evaluate/compare.d.mts +6 -10
  104. package/node_modules/typebox/build/type/engine/evaluate/compare.mjs +9 -13
  105. package/node_modules/typebox/build/type/engine/evaluate/composite.d.mts +7 -4
  106. package/node_modules/typebox/build/type/engine/evaluate/composite.mjs +12 -19
  107. package/node_modules/typebox/build/type/engine/evaluate/distribute.d.mts +4 -20
  108. package/node_modules/typebox/build/type/engine/evaluate/distribute.mjs +8 -20
  109. package/node_modules/typebox/build/type/engine/evaluate/evaluate.d.mts +4 -4
  110. package/node_modules/typebox/build/type/engine/evaluate/evaluate.mjs +11 -8
  111. package/node_modules/typebox/build/type/engine/evaluate/flatten.d.mts +1 -1
  112. package/node_modules/typebox/build/type/engine/evaluate/flatten.mjs +3 -4
  113. package/node_modules/typebox/build/type/engine/evaluate/narrow.d.mts +17 -2
  114. package/node_modules/typebox/build/type/engine/evaluate/narrow.mjs +26 -6
  115. package/node_modules/typebox/build/type/engine/exclude/operation.mjs +3 -4
  116. package/node_modules/typebox/build/type/engine/extract/operation.d.mts +1 -1
  117. package/node_modules/typebox/build/type/engine/extract/operation.mjs +3 -4
  118. package/node_modules/typebox/build/type/engine/priority/priority.d.mts +2 -2
  119. package/node_modules/typebox/build/type/engine/priority/priority.mjs +3 -4
  120. package/node_modules/typebox/build/type/script/mapping.d.mts +32 -29
  121. package/node_modules/typebox/build/type/script/mapping.mjs +18 -25
  122. package/node_modules/typebox/build/type/script/parser.d.mts +19 -25
  123. package/node_modules/typebox/build/type/script/parser.mjs +27 -23
  124. package/node_modules/typebox/build/value/errors/errors.d.mts +2 -2
  125. package/node_modules/typebox/build/value/errors/errors.mjs +1 -1
  126. package/node_modules/typebox/package.json +27 -27
  127. package/node_modules/typebox/readme.md +79 -82
  128. package/node_modules/ws/lib/permessage-deflate.js +3 -1
  129. package/node_modules/ws/package.json +1 -1
  130. package/openclaw.plugin.json +20 -2
  131. package/package.json +6 -6
  132. package/node_modules/typebox/build/format/_idna.d.mts +0 -2
  133. package/node_modules/typebox/build/format/_idna.mjs +0 -246
  134. package/node_modules/typebox/build/format/_puny.d.mts +0 -1
  135. package/node_modules/typebox/build/format/_puny.mjs +0 -77
  136. package/node_modules/typebox/build/schema/resolve/ref.d.mts +0 -3
  137. package/node_modules/typebox/build/schema/resolve/ref.mjs +0 -146
  138. package/node_modules/typebox/build/schema/types/contentEncoding.d.mts +0 -9
  139. package/node_modules/typebox/build/schema/types/contentEncoding.mjs +0 -13
  140. package/node_modules/typebox/build/schema/types/contentMediaType.d.mts +0 -9
  141. package/node_modules/typebox/build/schema/types/contentMediaType.mjs +0 -13
@@ -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-q02qkX8F.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
- import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, buildRealtimeVoiceAgentConsultWorkingResponse, calculateMulawRms, createRealtimeVoiceSessionHarness, createSpeechThresholdGate, readRealtimeVoiceConsultQuestion, readSpeakableRealtimeVoiceToolResult } from "openclaw/plugin-sdk/realtime-voice";
5
+ import { REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME, REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ, 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,18 @@ 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 = {};
824
+ let provisionalCloseReason;
825
+ let sessionClosed = false;
779
826
  const userTranscriptAdoption = this.beginUserTranscriptOwnerAdoption(callId);
780
827
  const userTranscriptOwner = userTranscriptAdoption.owner;
781
828
  const bridgeParams = {
782
- provider: this.realtimeProvider,
829
+ provider: realtimeProvider,
783
830
  cfg: this.coreConfig,
784
- providerConfig: this.providerConfig,
831
+ agentId,
832
+ providerConfig,
833
+ audioFormat: REALTIME_VOICE_AUDIO_FORMAT_G711_ULAW_8KHZ,
785
834
  interruptResponseOnInputAudio,
786
835
  instructions,
787
836
  tools: this.config.tools,
@@ -810,7 +859,7 @@ var RealtimeCallHandler = class {
810
859
  },
811
860
  onTranscript: (role, text, isFinal) => {
812
861
  const owner = nativeConsultOwner.current;
813
- if (!this.getUserTranscriptState(callId, userTranscriptOwner) || owner && !this.isActiveBridgeOwner(callId, owner)) return;
862
+ if (provisionalCloseReason || !this.getUserTranscriptState(callId, userTranscriptOwner) || owner && !this.isActiveBridgeOwner(callId, owner)) return;
814
863
  const turnId = harness.ensureTurn();
815
864
  const eventType = role === "assistant" ? isFinal ? "output.text.done" : "output.text.delta" : isFinal ? "transcript.done" : "transcript.delta";
816
865
  const payload = role === "assistant" ? { text } : {
@@ -967,61 +1016,57 @@ var RealtimeCallHandler = class {
967
1016
  });
968
1017
  },
969
1018
  onClose: (reason) => {
970
- const owner = nativeConsultOwner.current;
971
- const ownsCallState = owner ? this.isActiveBridgeOwner(callId, owner) : false;
972
- if (owner) {
973
- this.clearActiveBridgeMappings(callId, callSid, owner);
974
- this.cancelConsultSession(callId, owner);
975
- }
976
- if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
977
1019
  harness.finishOutputAudio(reason);
978
1020
  harness.emit({
979
1021
  type: "session.closed",
980
1022
  payload: { reason },
981
1023
  final: true
982
1024
  });
983
- if (reason !== "error") return;
984
- if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Bridge disconnected");
985
- if (owner && !ownsCallState || !owner && hadPredecessorOnAdmission && this.activeBridgesByCallId.has(callId)) return;
986
- emitCallEnd("error");
1025
+ const owner = nativeConsultOwner.current;
1026
+ if (!owner) {
1027
+ provisionalCloseReason ??= reason;
1028
+ return;
1029
+ }
1030
+ const ownsCallState = this.isActiveBridgeOwner(callId, owner);
1031
+ this.clearActiveBridgeMappings(callId, callSid, owner);
1032
+ this.cancelConsultSession(callId, owner);
1033
+ if (ownsCallState) this.clearUserTranscriptState(callId, userTranscriptOwner);
1034
+ if (reason === "completed" && (!ownsCallState || sessionClosed)) return;
1035
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
1036
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Bridge disconnected");
1037
+ if (ownsCallState) emitCallEnd(reason);
987
1038
  }
988
1039
  };
989
- let session;
1040
+ let candidate;
990
1041
  try {
991
- session = harness.createBridge(bridgeParams);
1042
+ candidate = harness.createBridge(bridgeParams);
992
1043
  } catch (error) {
1044
+ console.error("[voice-call] Failed to create realtime bridge:", error);
1045
+ }
1046
+ if (!candidate || provisionalCloseReason) {
993
1047
  this.rollbackUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1048
+ try {
1049
+ candidate?.close();
1050
+ } catch (error) {
1051
+ console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(error)}`);
1052
+ }
994
1053
  harness.close();
995
1054
  audioPacer.close();
996
- if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd("error");
997
- if (ws.readyState === WebSocket$1.OPEN) ws.close(1011, "Failed to create realtime bridge");
998
- console.error("[voice-call] Failed to create realtime bridge:", error);
1055
+ const reason = provisionalCloseReason ?? "error";
1056
+ if (!hadPredecessorOnAdmission || !this.activeBridgesByCallId.has(callId)) emitCallEnd(reason);
1057
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(reason === "error" ? 1011 : 1e3, "Failed to create realtime bridge");
999
1058
  return null;
1000
1059
  }
1060
+ const session = candidate;
1001
1061
  this.commitUserTranscriptOwnerAdoption(callId, userTranscriptAdoption);
1002
1062
  nativeConsultOwner.current = session;
1003
1063
  providerHandlesInputAudioBargeIn = session.bridge.handlesInputAudioBargeIn ?? providerHandlesInputAudioBargeIn;
1004
- const closeTelephony = (reason) => {
1005
- try {
1006
- session.close();
1007
- } finally {
1008
- emitCallEnd(reason);
1009
- }
1010
- };
1011
1064
  const previousConsultSession = this.consultSessionsByCallId.get(callId);
1012
1065
  if (previousConsultSession && previousConsultSession.owner !== session) this.cancelConsultSession(callId, previousConsultSession.owner);
1013
1066
  this.consultSessionsByCallId.set(callId, {
1014
1067
  owner: session,
1015
1068
  coordinator: harness.forcedConsults
1016
1069
  });
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
1070
  const sendAudioToSession = session.sendAudio.bind(session);
1026
1071
  session.sendAudio = (audio) => {
1027
1072
  if (speechDetector.accept({
@@ -1037,7 +1082,6 @@ var RealtimeCallHandler = class {
1037
1082
  sendAudioToSession(audio);
1038
1083
  };
1039
1084
  const closeSession = session.close.bind(session);
1040
- let sessionClosed = false;
1041
1085
  session.close = () => {
1042
1086
  if (sessionClosed) return;
1043
1087
  sessionClosed = true;
@@ -1051,9 +1095,65 @@ var RealtimeCallHandler = class {
1051
1095
  audioPacer.close();
1052
1096
  }
1053
1097
  };
1098
+ let livenessTimer;
1099
+ const clearLivenessTimer = () => {
1100
+ if (livenessTimer) {
1101
+ clearTimeout(livenessTimer);
1102
+ livenessTimer = void 0;
1103
+ }
1104
+ };
1105
+ let bindingClosed = false;
1106
+ const closeBinding = (binding, cause) => {
1107
+ if (bindingClosed) return callEndPromise ?? Promise.resolve();
1108
+ bindingClosed = true;
1109
+ clearLivenessTimer();
1110
+ const ownsCall = this.activeTelephonyBindingsByCallId.get(callId) === binding;
1111
+ let termination = Promise.resolve();
1112
+ try {
1113
+ session.close();
1114
+ } catch (error) {
1115
+ console.warn(`[voice-call] Failed to close realtime bridge ${callSid}: ${formatErrorMessage(error)}`);
1116
+ } finally {
1117
+ this.clearActiveTelephonyBinding(callId, binding);
1118
+ if (cause === "disconnect") this.streamDisconnectLifecycle.disconnect(callSid, streamSid);
1119
+ else this.streamDisconnectLifecycle.retire(callSid, streamSid);
1120
+ if (ownsCall && cause && cause !== "disconnect") termination = emitCallEnd(cause);
1121
+ }
1122
+ return termination;
1123
+ };
1124
+ const telephonyBinding = {
1125
+ bridge: session,
1126
+ close: (cause) => closeBinding(telephonyBinding, cause),
1127
+ endCall: () => {
1128
+ closeBinding(telephonyBinding);
1129
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Call ended");
1130
+ },
1131
+ noteMediaActivity: () => {
1132
+ if (bindingClosed || this.activeTelephonyBindingsByCallId.get(callId) !== telephonyBinding) return;
1133
+ clearLivenessTimer();
1134
+ livenessTimer = setTimeout(() => {
1135
+ console.warn(`[voice-call] Realtime media inactive callId=${callId} providerCallId=${callSid} timeoutMs=${REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS} graceMs=${REALTIME_DISCONNECT_HANGUP_GRACE_MS}`);
1136
+ livenessTimer = setTimeout(() => {
1137
+ telephonyBinding.close("inactivity");
1138
+ if (ws.readyState === WebSocket$1.OPEN) ws.close(1e3, "Media inactivity");
1139
+ }, REALTIME_DISCONNECT_HANGUP_GRACE_MS);
1140
+ livenessTimer.unref?.();
1141
+ }, REALTIME_MEDIA_INACTIVITY_TIMEOUT_MS);
1142
+ livenessTimer.unref?.();
1143
+ },
1144
+ retire: () => {
1145
+ closeBinding(telephonyBinding);
1146
+ }
1147
+ };
1148
+ this.activeBridgesByCallId.set(callId, session);
1149
+ this.activeBridgesByCallId.set(callSid, session);
1150
+ this.activeTelephonyBindingsByCallId.set(callId, telephonyBinding);
1151
+ telephonyBinding.noteMediaActivity();
1152
+ if (previousTelephonyBinding && previousTelephonyBinding !== telephonyBinding) previousTelephonyBinding.retire();
1054
1153
  session.connect().catch((error) => {
1055
1154
  console.error("[voice-call] Failed to connect realtime bridge:", error);
1056
1155
  const ownsCallState = this.isActiveBridgeOwner(callId, session);
1156
+ this.streamDisconnectLifecycle.retire(callSid, streamSid);
1057
1157
  try {
1058
1158
  session.close();
1059
1159
  } catch (closeError) {
@@ -1063,7 +1163,7 @@ var RealtimeCallHandler = class {
1063
1163
  ws.close(1011, "Failed to connect");
1064
1164
  }
1065
1165
  });
1066
- return session;
1166
+ return telephonyBinding;
1067
1167
  }
1068
1168
  beginUserTranscriptOwnerAdoption(callId) {
1069
1169
  const adoption = {
@@ -1187,9 +1287,11 @@ var RealtimeCallHandler = class {
1187
1287
  for (const key of [callId, callSid]) {
1188
1288
  if (this.activeBridgesByCallId.get(key) !== owner) continue;
1189
1289
  this.activeBridgesByCallId.delete(key);
1190
- this.activeTelephonyClosersByCallId.delete(key);
1191
1290
  }
1192
1291
  }
1292
+ clearActiveTelephonyBinding(callId, binding) {
1293
+ if (this.activeTelephonyBindingsByCallId.get(callId) === binding) this.activeTelephonyBindingsByCallId.delete(callId);
1294
+ }
1193
1295
  resolveUserTranscriptContext(callId, owner) {
1194
1296
  const state = this.getUserTranscriptState(callId, owner);
1195
1297
  return state?.partial ?? state?.recentFinal;
@@ -1228,14 +1330,6 @@ var RealtimeCallHandler = class {
1228
1330
  });
1229
1331
  }
1230
1332
  }
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
1333
  scheduleForcedAgentConsult(params) {
1240
1334
  if (this.config.consultPolicy !== "always" || this.activeBridgesByCallId.get(params.callId) !== params.session) return;
1241
1335
  const question = params.transcript.trim();
@@ -1312,7 +1406,7 @@ var RealtimeCallHandler = class {
1312
1406
  }, FORCED_CONSULT_NATIVE_DEDUPE_MS).unref?.();
1313
1407
  }
1314
1408
  }
1315
- registerCallInManager(callSid, callerMeta = {}) {
1409
+ prepareCallInManager(callSid, callerMeta = {}) {
1316
1410
  const baseFields = {
1317
1411
  providerCallId: callSid,
1318
1412
  timestamp: Date.now(),
@@ -1324,18 +1418,10 @@ var RealtimeCallHandler = class {
1324
1418
  if (!callRecord) return null;
1325
1419
  const initialGreeting = this.extractInitialGreeting(callRecord);
1326
1420
  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
1421
  return {
1336
- callId: callRecord.callId,
1337
- instructions,
1338
- initialGreetingInstructions: buildGreetingInstructions(instructions, initialGreeting)
1422
+ callRecord,
1423
+ baseFields,
1424
+ initialGreeting
1339
1425
  };
1340
1426
  }
1341
1427
  resolveRealtimeCall(callSid, callerMeta, baseFields) {
@@ -1354,17 +1440,57 @@ var RealtimeCallHandler = class {
1354
1440
  extractInitialGreeting(call) {
1355
1441
  return typeof call.metadata?.initialMessage === "string" ? call.metadata.initialMessage : void 0;
1356
1442
  }
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
1443
+ async executeEndCallTool(params) {
1444
+ const binding = this.activeTelephonyBindingsByCallId.get(params.callId);
1445
+ if (!binding || binding.bridge !== params.bridge || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
1446
+ let result;
1447
+ try {
1448
+ result = await this.manager.endCall(params.callId);
1449
+ } catch (error) {
1450
+ result = {
1451
+ success: false,
1452
+ error: formatErrorMessage(error)
1453
+ };
1454
+ }
1455
+ if (this.activeTelephonyBindingsByCallId.get(params.callId) !== binding || !this.isActiveBridgeOwner(params.callId, params.bridge)) return;
1456
+ if (!result.success) {
1457
+ 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.` };
1458
+ await params.bridge.submitToolResult(params.bridgeCallId, toolResult);
1459
+ params.harness.emit({
1460
+ type: "tool.error",
1461
+ turnId: params.turnId,
1462
+ callId: params.bridgeCallId,
1463
+ payload: {
1464
+ name: REALTIME_VOICE_END_CALL_TOOL_NAME,
1465
+ result: toolResult
1466
+ },
1467
+ final: true
1468
+ });
1469
+ return;
1470
+ }
1471
+ params.harness.emit({
1472
+ type: "tool.result",
1473
+ turnId: params.turnId,
1474
+ callId: params.bridgeCallId,
1475
+ payload: {
1476
+ name: REALTIME_VOICE_END_CALL_TOOL_NAME,
1477
+ result: { success: true }
1478
+ },
1479
+ final: true
1365
1480
  });
1481
+ binding.endCall();
1366
1482
  }
1367
1483
  async executeToolCall(bridge, callId, bridgeCallId, name, args, turnId, harness, userTranscriptOwner) {
1484
+ if (name === "openclaw_end_call") {
1485
+ await this.executeEndCallTool({
1486
+ bridge,
1487
+ callId,
1488
+ bridgeCallId,
1489
+ turnId,
1490
+ harness
1491
+ });
1492
+ return;
1493
+ }
1368
1494
  const handler = this.toolHandlers.get(name);
1369
1495
  const startedAt = Date.now();
1370
1496
  const hasResultError = (result) => {