@openclaw/voice-call 2026.7.1-beta.6 → 2026.7.2-beta.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 (31) hide show
  1. package/README.md +1 -2
  2. package/dist/doctor-contract-api.js +27 -10
  3. package/dist/{guarded-json-api-CLVDU_h1.js → guarded-json-api-DoLAoIRF.js} +32 -41
  4. package/dist/index.js +9 -15
  5. package/dist/{plivo-D5wI4-pi.js → plivo-BICQA_yK.js} +2 -2
  6. package/dist/{realtime-handler-Bz-MNWtD.js → realtime-handler-DJk-eYyf.js} +31 -20
  7. package/dist/{response-generator-D3YO-WtN.js → response-generator-hmAdGp5h.js} +20 -4
  8. package/dist/{runtime-entry-DwMgbq2p.js → runtime-entry-DhTLZ2uB.js} +811 -263
  9. package/dist/runtime-entry.js +1 -1
  10. package/dist/setup-api.js +86 -2
  11. package/dist/{store-DtewuuOb.js → store-C1pl3avP.js} +2 -2
  12. package/dist/{telnyx-DksS6q0n.js → telnyx-B9tF5jeJ.js} +1 -1
  13. package/dist/{twilio-BYcsdUkj.js → twilio-U_myvowz.js} +7 -10
  14. package/dist/twilio-region-4fkgz3UG.js +25 -0
  15. package/npm-shrinkwrap.json +3 -13
  16. package/openclaw.plugin.json +1 -0
  17. package/package.json +4 -6
  18. package/dist/config-CDSeuGp3.js +0 -674
  19. package/dist/config-compat-D8fcjOHp.js +0 -156
  20. package/node_modules/commander/LICENSE +0 -22
  21. package/node_modules/commander/Readme.md +0 -1172
  22. package/node_modules/commander/index.js +0 -21
  23. package/node_modules/commander/lib/argument.js +0 -147
  24. package/node_modules/commander/lib/command.js +0 -2790
  25. package/node_modules/commander/lib/error.js +0 -36
  26. package/node_modules/commander/lib/help.js +0 -731
  27. package/node_modules/commander/lib/option.js +0 -377
  28. package/node_modules/commander/lib/suggestSimilar.js +0 -99
  29. package/node_modules/commander/package-support.json +0 -19
  30. package/node_modules/commander/package.json +0 -64
  31. package/node_modules/commander/typings/index.d.ts +0 -1113
package/README.md CHANGED
@@ -101,7 +101,7 @@ Notes:
101
101
  - Twilio defaults to US1. For a non-US Region, set `twilio.region` to `ie1` or `au1` and use credentials created in that Region; see [Twilio's regional REST API guide](https://www.twilio.com/docs/global-infrastructure/using-the-twilio-rest-api-in-a-non-us-region).
102
102
  - `mock` is a local dev provider (no network calls).
103
103
  - Telnyx requires `telnyx.publicKey` (or `TELNYX_PUBLIC_KEY`) unless `skipSignatureVerification` is true.
104
- - If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
104
+ - Runtime accepts canonical config only. If older configs still use `provider: "log"`, `twilio.from`, or legacy `streaming.*` OpenAI keys, run `openclaw doctor --fix` to rewrite them.
105
105
  - advanced webhook, streaming, and tunnel notes: `https://docs.openclaw.ai/plugins/voice-call`
106
106
  - `responseModel` is optional. When unset, voice responses use the runtime default model.
107
107
  - `sessionScope` defaults to `per-phone`, preserving caller memory across calls. Use `per-call` for reception, booking, IVR, and bridge flows where each carrier call should start fresh.
@@ -163,4 +163,3 @@ Actions:
163
163
  - Outbound conversation calls suppress barge-in only while the initial greeting is actively speaking, then re-enable normal interruption.
164
164
  - Twilio stream disconnect auto-end uses a short grace window so quick reconnects do not end the call.
165
165
  - Realtime provider selection is generic. Configure `streaming.provider` / `realtime.provider` and put provider-owned options under `providers.<id>`.
166
- - Runtime fallback still accepts the old voice-call keys for now, but migration is a doctor step and the compat shim is scheduled to go away in a future release.
@@ -1,9 +1,9 @@
1
- import { a as MAX_CALL_RECORD_EVENTS, d as parseVoiceCallRecordLine, i as CALL_RECORD_EVENT_META_MAX_ENTRIES, m as resolveVoiceCallLegacyCallLogPath, n as CALL_RECORD_EVENTS_NAMESPACE, o as RAW_CALL_RECORD_CHUNK_BYTES, p as prepareVoiceCallRecordForStorage, r as CALL_RECORD_EVENT_CHUNKS_NAMESPACE, s as buildVoiceCallLegacyJsonlEventKey, t as CALL_RECORD_CHUNK_MAX_ENTRIES } from "./store-DtewuuOb.js";
1
+ import { a as MAX_CALL_RECORD_EVENTS, d as parseVoiceCallRecordLine, i as CALL_RECORD_EVENT_META_MAX_ENTRIES, m as resolveVoiceCallLegacyCallLogPath, n as CALL_RECORD_EVENTS_NAMESPACE, o as RAW_CALL_RECORD_CHUNK_BYTES, p as prepareVoiceCallRecordForStorage, r as CALL_RECORD_EVENT_CHUNKS_NAMESPACE, s as buildVoiceCallLegacyJsonlEventKey, t as CALL_RECORD_CHUNK_MAX_ENTRIES } from "./store-C1pl3avP.js";
2
2
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import fs from "node:fs/promises";
6
- import { archiveLegacyStateSource } from "openclaw/plugin-sdk/runtime-doctor";
6
+ import { archiveLegacyStateSource, detectOpenClawStateDatabaseSchemaMigrations, repairOpenClawStateDatabaseSchema } from "openclaw/plugin-sdk/runtime-doctor";
7
7
  //#region extensions/voice-call/doctor-contract-api.ts
8
8
  /** Resolve home from doctor env with OS fallback. */
9
9
  function resolveHome(env) {
@@ -52,6 +52,15 @@ function resolveVoiceCallStorePath(params) {
52
52
  if (configuredStore) return resolveUserPath(configuredStore, params.env);
53
53
  return path.join(resolveHome(params.env), ".openclaw", "voice-calls");
54
54
  }
55
+ function resolveVoiceCallStateDatabaseEnv(params) {
56
+ return {
57
+ ...params.env,
58
+ OPENCLAW_STATE_DIR: resolveVoiceCallStorePath(params)
59
+ };
60
+ }
61
+ function describeVoiceCallSchemaMigration(migration) {
62
+ return migration.kind === "agent-databases-composite-primary-key" ? "agent database registry primary key -> agent_id,path" : "audit event ledger -> versioned message lifecycle schema";
63
+ }
55
64
  /** Return true when a path exists and is a file. */
56
65
  /** Build the plugin state key for one migrated event chunk. */
57
66
  function buildChunkKey(eventKey, index) {
@@ -170,24 +179,32 @@ const stateMigrations = [{
170
179
  label: "Voice Call call log",
171
180
  async detectLegacyState(params) {
172
181
  const { entries } = await readLegacyCallRecords(resolveVoiceCallLegacyCallLogPath(resolveVoiceCallStorePath(params)));
173
- if (entries.length === 0) return null;
174
- return { preview: [`- Voice Call call log: ${entries.length} ${entries.length === 1 ? "record" : "records"} -> plugin state (${CALL_RECORD_EVENTS_NAMESPACE})`] };
182
+ const schemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({ env: resolveVoiceCallStateDatabaseEnv(params) });
183
+ if (entries.length === 0 && schemaMigrations.length === 0) return null;
184
+ return { preview: [...schemaMigrations.map((migration) => `- Voice Call SQLite schema: ${describeVoiceCallSchemaMigration(migration)}`), ...entries.length > 0 ? [`- Voice Call call log: ${entries.length} ${entries.length === 1 ? "record" : "records"} -> plugin state (${CALL_RECORD_EVENTS_NAMESPACE})`] : []] };
175
185
  },
176
186
  async migrateLegacyState(params) {
177
187
  const changes = [];
178
188
  const warnings = [];
179
- const storePath = resolveVoiceCallStorePath(params);
180
- const filePath = resolveVoiceCallLegacyCallLogPath(storePath);
189
+ const filePath = resolveVoiceCallLegacyCallLogPath(resolveVoiceCallStorePath(params));
181
190
  const { entries, warnings: readWarnings } = await readLegacyCallRecords(filePath);
182
191
  warnings.push(...readWarnings);
192
+ const stateDatabaseEnv = resolveVoiceCallStateDatabaseEnv(params);
193
+ const schemaMigrations = detectOpenClawStateDatabaseSchemaMigrations({ env: stateDatabaseEnv });
194
+ if (schemaMigrations.length > 0) {
195
+ const repaired = repairOpenClawStateDatabaseSchema({ env: stateDatabaseEnv });
196
+ warnings.push(...repaired.warnings);
197
+ if (repaired.warnings.length > 0) return {
198
+ changes,
199
+ warnings
200
+ };
201
+ changes.push(...schemaMigrations.map((migration) => `Migrated Voice Call SQLite ${describeVoiceCallSchemaMigration(migration)}`));
202
+ }
183
203
  if (entries.length === 0) return {
184
204
  changes,
185
205
  warnings
186
206
  };
187
- const env = {
188
- ...params.env,
189
- OPENCLAW_STATE_DIR: storePath
190
- };
207
+ const env = stateDatabaseEnv;
191
208
  const imported = await importLegacyCallRecords({
192
209
  entries,
193
210
  eventStore: params.context.openPluginStateKeyedStore({
@@ -1,12 +1,13 @@
1
1
  import { fetchWithSsrFGuard } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { n as normalizeProxyIp, o as getHeader } from "./runtime-entry-DwMgbq2p.js";
3
+ import { n as normalizeProxyIp, o as getHeader } from "./runtime-entry-DhTLZ2uB.js";
4
4
  import { n as markWebhookReplay, t as createWebhookReplayCache } from "./webhook-replay-aaP79dI8.js";
5
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
6
6
  import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
7
7
  import { normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
8
8
  import crypto from "node:crypto";
9
9
  import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
10
+ import { readResponseTextPrefix, readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
10
11
  //#region extensions/voice-call/src/webhook-security.ts
11
12
  const twilioReplayCache = createWebhookReplayCache();
12
13
  const plivoReplayCache = createWebhookReplayCache();
@@ -53,16 +54,14 @@ function isValidHostname(hostname) {
53
54
  */
54
55
  function extractHostname(hostHeader) {
55
56
  if (!hostHeader) return null;
56
- let hostname;
57
57
  if (hostHeader.startsWith("[")) {
58
58
  const endBracket = hostHeader.indexOf("]");
59
59
  if (endBracket === -1) return null;
60
- hostname = hostHeader.slice(1, endBracket);
61
- return normalizeLowercaseStringOrEmpty(hostname);
60
+ return normalizeLowercaseStringOrEmpty(hostHeader.slice(1, endBracket));
62
61
  }
63
62
  if (hostHeader.includes("@")) return null;
64
- hostname = hostHeader.split(":")[0];
65
- if (!isValidHostname(hostname)) return null;
63
+ const hostname = hostHeader.split(":").at(0);
64
+ if (!hostname || !isValidHostname(hostname)) return null;
66
65
  return normalizeLowercaseStringOrEmpty(hostname);
67
66
  }
68
67
  function extractHostnameFromHeader(headerValue) {
@@ -162,6 +161,18 @@ function buildTwilioVerificationUrl(ctx, publicUrl, urlOptions) {
162
161
  return publicUrl;
163
162
  }
164
163
  }
164
+ function redactTwilioVerificationUrlForDiagnostics(url) {
165
+ try {
166
+ const parsed = new URL(url);
167
+ parsed.username = parsed.username ? "***" : "";
168
+ parsed.password = parsed.password ? "***" : "";
169
+ parsed.hash = parsed.hash ? "#***" : "";
170
+ for (const key of Array.from(parsed.searchParams.keys())) parsed.searchParams.set(key, "***");
171
+ return parsed.toString();
172
+ } catch {
173
+ return "<invalid verification URL>";
174
+ }
175
+ }
165
176
  function stripPortFromUrl(url) {
166
177
  try {
167
178
  const parsed = new URL(url);
@@ -347,8 +358,7 @@ function verifyTwilioWebhook(ctx, authToken, options) {
347
358
  const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
348
359
  return {
349
360
  ok: false,
350
- reason: `Invalid signature for URL: ${verificationUrl}`,
351
- verificationUrl,
361
+ reason: `Invalid signature for URL: ${redactTwilioVerificationUrlForDiagnostics(verificationUrl)}`,
352
362
  isNgrokFreeTier
353
363
  };
354
364
  }
@@ -383,16 +393,18 @@ function toParamMapFromSearchParams(sp) {
383
393
  }
384
394
  function sortedQueryString(params) {
385
395
  const parts = [];
386
- for (const key of Object.keys(params).toSorted()) {
387
- const values = [...params[key]].toSorted();
396
+ const entries = Object.entries(params).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
397
+ for (const [key, entryValues] of entries) {
398
+ const values = [...entryValues].toSorted();
388
399
  for (const value of values) parts.push(`${key}=${value}`);
389
400
  }
390
401
  return parts.join("&");
391
402
  }
392
403
  function sortedParamsString(params) {
393
404
  const parts = [];
394
- for (const key of Object.keys(params).toSorted()) {
395
- const values = [...params[key]].toSorted();
405
+ const entries = Object.entries(params).toSorted(([left], [right]) => left < right ? -1 : left > right ? 1 : 0);
406
+ for (const [key, entryValues] of entries) {
407
+ const values = [...entryValues].toSorted();
396
408
  for (const value of values) parts.push(`${key}${value}`);
397
409
  }
398
410
  return parts.join("");
@@ -533,35 +545,12 @@ function appendTruncatedSuffix(text) {
533
545
  return `${text.trimEnd()}${TRUNCATED_SUFFIX}`;
534
546
  }
535
547
  async function readProviderResponseTextWithLimit(params) {
536
- if (!params.response.body) return "";
537
- const reader = params.response.body.getReader();
538
- const decoder = new TextDecoder();
539
- let totalBytes = 0;
540
- let text = "";
541
- try {
542
- while (true) {
543
- const { done, value } = await reader.read();
544
- if (done) return text + decoder.decode();
545
- if (!value?.byteLength) continue;
546
- const remainingBytes = params.maxBytes - totalBytes;
547
- if (value.byteLength > remainingBytes) {
548
- if (params.truncateOnLimit) {
549
- const clipped = remainingBytes > 0 ? value.slice(0, remainingBytes) : void 0;
550
- if (clipped) text += decoder.decode(clipped, { stream: true });
551
- await reader.cancel().catch(() => void 0);
552
- return appendTruncatedSuffix(text + decoder.decode());
553
- }
554
- await reader.cancel().catch(() => void 0);
555
- throw new Error(`provider response body too large: ${totalBytes + value.byteLength} bytes (limit: ${params.maxBytes} bytes)`);
556
- }
557
- text += decoder.decode(value, { stream: true });
558
- totalBytes += value.byteLength;
559
- }
560
- } finally {
561
- try {
562
- reader.releaseLock();
563
- } catch {}
548
+ if (params.truncateOnLimit) {
549
+ const prefix = await readResponseTextPrefix(params.response, params.maxBytes);
550
+ return prefix.truncated ? appendTruncatedSuffix(prefix.text) : prefix.text;
564
551
  }
552
+ const body = await readResponseWithLimit(params.response, params.maxBytes, { onOverflow: ({ size, maxBytes }) => /* @__PURE__ */ new Error(`provider response body too large: ${size} bytes (limit: ${maxBytes} bytes)`) });
553
+ return new TextDecoder().decode(body);
565
554
  }
566
555
  async function readProviderJsonResponseText(response) {
567
556
  return await readProviderResponseTextWithLimit({
@@ -578,6 +567,7 @@ async function readProviderErrorResponseSnippet(response) {
578
567
  }
579
568
  //#endregion
580
569
  //#region extensions/voice-call/src/providers/shared/guarded-json-api.ts
570
+ const VOICE_CALL_PROVIDER_API_TIMEOUT_MS = 3e4;
581
571
  /** Send a provider JSON request through the SSRF guard and parse bounded JSON responses. */
582
572
  async function guardedJsonApiRequest(params) {
583
573
  const { response, release } = await fetchWithSsrFGuard({
@@ -588,7 +578,8 @@ async function guardedJsonApiRequest(params) {
588
578
  body: params.body ? JSON.stringify(params.body) : void 0
589
579
  },
590
580
  policy: { allowedHostnames: params.allowedHostnames },
591
- auditContext: params.auditContext
581
+ auditContext: params.auditContext,
582
+ timeoutMs: VOICE_CALL_PROVIDER_API_TIMEOUT_MS
592
583
  });
593
584
  try {
594
585
  if (!response.ok) {
package/dist/index.js CHANGED
@@ -1,14 +1,12 @@
1
1
  import { definePluginEntry, sleep } from "./runtime-api.js";
2
2
  import "./api.js";
3
- import { c as validateProviderConfig, i as resolveVoiceCallConfig } from "./config-CDSeuGp3.js";
4
- import { c as cleanupTailscaleExposureRoute, h as resolveUserPath, l as getTailscaleSelfInfo, s as resolveWebhookExposureStatus, t as createVoiceCallRuntime, u as setupTailscaleExposureRoute } from "./runtime-entry-DwMgbq2p.js";
5
- import { h as setVoiceCallStateRuntime, l as getCallHistoryFromStore } from "./store-DtewuuOb.js";
6
- import { i as parseVoiceCallPluginConfig, r as normalizeVoiceCallLegacyConfigInput, t as formatVoiceCallLegacyConfigWarnings } from "./config-compat-D8fcjOHp.js";
3
+ import { c as cleanupTailscaleExposureRoute, h as resolveUserPath, l as getTailscaleSelfInfo, s as resolveWebhookExposureStatus, t as createVoiceCallRuntime, u as setupTailscaleExposureRoute, v as VoiceCallConfigSchema, x as validateProviderConfig, y as resolveVoiceCallConfig } from "./runtime-entry-DhTLZ2uB.js";
4
+ import { h as setVoiceCallStateRuntime, l as getCallHistoryFromStore } from "./store-C1pl3avP.js";
7
5
  import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
8
6
  import { ErrorCodes, callGatewayFromCli, errorShape } from "openclaw/plugin-sdk/gateway-runtime";
9
7
  import { MAX_TCP_PORT, MAX_TIMER_TIMEOUT_MS, addTimerTimeoutGraceMs, clampTimerTimeoutMs, parseStrictNonNegativeInteger, resolveTimerTimeoutMs, timestampMsToIsoString } from "openclaw/plugin-sdk/number-runtime";
10
8
  import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
11
- import { isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
9
+ import { asOptionalRecord, isRecord, normalizeOptionalLowercaseString, normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
12
10
  import { jsonResult } from "openclaw/plugin-sdk/tool-results";
13
11
  import { Type } from "typebox";
14
12
  import fs from "node:fs";
@@ -658,12 +656,12 @@ const VOICE_CALL_WRITE_METHOD_SCOPE = { scope: "operator.write" };
658
656
  const VOICE_CALL_READ_METHOD_SCOPE = { scope: "operator.read" };
659
657
  const voiceCallConfigSchema = {
660
658
  parse(value) {
661
- const normalized = normalizeVoiceCallLegacyConfigInput(value);
662
- const enabled = typeof normalized.enabled === "boolean" ? normalized.enabled : true;
663
- return parseVoiceCallPluginConfig({
664
- ...normalized,
659
+ const config = asOptionalRecord(value) ?? {};
660
+ const enabled = typeof config.enabled === "boolean" ? config.enabled : true;
661
+ return VoiceCallConfigSchema.parse({
662
+ ...config,
665
663
  enabled,
666
- provider: normalized.provider ?? (enabled ? "mock" : void 0)
664
+ provider: config.provider ?? (enabled ? "mock" : void 0)
667
665
  });
668
666
  },
669
667
  uiHints: {
@@ -743,6 +741,7 @@ const voiceCallConfigSchema = {
743
741
  },
744
742
  "streaming.enabled": {
745
743
  label: "Enable Streaming",
744
+ help: "Classic streaming transcription currently requires the Twilio call provider.",
746
745
  advanced: true
747
746
  },
748
747
  "streaming.provider": {
@@ -952,11 +951,6 @@ var voice_call_default = definePluginEntry({
952
951
  register(api) {
953
952
  const config = resolveVoiceCallConfig(voiceCallConfigSchema.parse(api.pluginConfig));
954
953
  const validation = validateProviderConfig(config);
955
- if (api.pluginConfig && typeof api.pluginConfig === "object") for (const warning of formatVoiceCallLegacyConfigWarnings({
956
- value: api.pluginConfig,
957
- configPathPrefix: "plugins.entries.voice-call.config",
958
- doctorFixCommand: "openclaw doctor --fix"
959
- })) api.logger.warn(warning);
960
954
  const runtimeState = getVoiceCallRuntimeGlobalState();
961
955
  const continueOperationStore = createVoiceCallContinueOperationStore({
962
956
  config,
@@ -1,5 +1,5 @@
1
- import { g as escapeXml, o as getHeader } from "./runtime-entry-DwMgbq2p.js";
2
- import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-CLVDU_h1.js";
1
+ import { g as escapeXml, o as getHeader } from "./runtime-entry-DhTLZ2uB.js";
2
+ import { a as reconstructWebhookUrl, o as verifyPlivoWebhook, t as guardedJsonApiRequest } from "./guarded-json-api-DoLAoIRF.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
@@ -884,7 +884,7 @@ var RealtimeCallHandler = class {
884
884
  }
885
885
  });
886
886
  console.log(`[voice-call] realtime tool call received callId=${callId} providerCallId=${callSid} tool=${toolEvent.name}`);
887
- this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, emitTalkEvent);
887
+ return this.executeToolCall(sessionLocal, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, turnId, emitTalkEvent);
888
888
  },
889
889
  onEvent: (event) => {
890
890
  if (event.type === "input_audio_buffer.speech_started") {
@@ -1234,12 +1234,13 @@ var RealtimeCallHandler = class {
1234
1234
  final: true
1235
1235
  });
1236
1236
  };
1237
- const submitFinalToolResult = (result) => {
1238
- bridge.submitToolResult(bridgeCallId, result);
1237
+ const submitFinalToolResult = async (result) => {
1238
+ await bridge.submitToolResult(bridgeCallId, result);
1239
1239
  emitFinalToolEvent(result);
1240
1240
  };
1241
- const submitWorkingResponse = () => {
1241
+ const submitWorkingResponse = async () => {
1242
1242
  if (handler && name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && bridge.bridge.supportsToolResultContinuation && !this.config.fastContext.enabled) {
1243
+ await bridge.submitToolResult(bridgeCallId, buildRealtimeVoiceAgentConsultWorkingResponse("caller"), { willContinue: true });
1243
1244
  emitTalkEvent?.({
1244
1245
  type: "tool.progress",
1245
1246
  turnId,
@@ -1249,7 +1250,6 @@ var RealtimeCallHandler = class {
1249
1250
  status: "working"
1250
1251
  }
1251
1252
  });
1252
- bridge.submitToolResult(bridgeCallId, buildRealtimeVoiceAgentConsultWorkingResponse("caller"), { willContinue: true });
1253
1253
  }
1254
1254
  };
1255
1255
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
@@ -1260,45 +1260,56 @@ var RealtimeCallHandler = class {
1260
1260
  if (pending) coordinator.remove(pending);
1261
1261
  }
1262
1262
  const forcedConsult = this.forcedConsultsByCallId.get(callId);
1263
+ if (forcedMatch.kind === "already_delivered" && coordinator.isCancelled(forcedMatch.handle)) {
1264
+ if (forcedConsult) forcedConsult.sendSpeechPrompt = false;
1265
+ await submitFinalToolResult({
1266
+ status: "cancelled",
1267
+ message: "OpenClaw cancelled this consult before completion. Do not restart it."
1268
+ });
1269
+ return;
1270
+ }
1263
1271
  if (forcedConsult) {
1264
1272
  if (forcedConsult.completedAt || forcedMatch.kind === "already_delivered") {
1265
- submitFinalToolResult({
1273
+ await submitFinalToolResult({
1266
1274
  status: "already_delivered",
1267
1275
  message: "OpenClaw already delivered this consult result internally. Do not repeat it."
1268
1276
  });
1269
1277
  return;
1270
1278
  }
1271
1279
  forcedConsult.sendSpeechPrompt = false;
1272
- submitFinalToolResult(await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) })));
1280
+ await submitFinalToolResult(await forcedConsult.promise.catch((error) => ({ error: formatErrorMessage(error) })));
1273
1281
  return;
1274
1282
  }
1275
1283
  const existingNativeConsult = this.nativeConsultsInFlightByCallId.get(callId);
1276
1284
  if (existingNativeConsult) {
1277
1285
  console.log(`[voice-call] realtime tool call sharing in-flight agent consult callId=${callId} ageMs=${Date.now() - existingNativeConsult.startedAt}`);
1278
- submitWorkingResponse();
1279
- submitFinalToolResult(await existingNativeConsult.promise);
1286
+ await submitWorkingResponse();
1287
+ await submitFinalToolResult(await existingNativeConsult.promise);
1280
1288
  return;
1281
1289
  }
1282
- submitWorkingResponse();
1283
1290
  const state = {
1284
1291
  startedAt,
1285
1292
  promise: Promise.resolve()
1286
1293
  };
1287
- state.promise = (async () => {
1288
- await this.waitForConsultTranscriptSettle(callId, startedAt);
1289
- const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1290
- state.partialUserTranscript = context.partialUserTranscript;
1291
- const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
1292
- console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
1293
- return !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context);
1294
- })().catch((error) => ({ error: formatErrorMessage(error) }));
1294
+ state.promise = submitWorkingResponse().then(async () => {
1295
+ try {
1296
+ await this.waitForConsultTranscriptSettle(callId, startedAt);
1297
+ const context = { partialUserTranscript: this.resolveUserTranscriptContext(callId) };
1298
+ state.partialUserTranscript = context.partialUserTranscript;
1299
+ const handlerArgs = withFallbackConsultQuestion(args, context.partialUserTranscript);
1300
+ console.log(`[voice-call] realtime tool call executing callId=${callId} tool=${name} hasHandler=${Boolean(handler)}`);
1301
+ return !handler ? { error: `Tool "${name}" not available` } : await handler(handlerArgs, callId, context);
1302
+ } catch (error) {
1303
+ return { error: formatErrorMessage(error) };
1304
+ }
1305
+ });
1295
1306
  this.nativeConsultsInFlightByCallId.set(callId, state);
1296
1307
  try {
1297
1308
  const result = await state.promise;
1298
1309
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1299
1310
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1300
1311
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1301
- submitFinalToolResult(result);
1312
+ await submitFinalToolResult(result);
1302
1313
  if (status === "ok") this.consumePartialUserTranscript(callId, state.partialUserTranscript);
1303
1314
  } finally {
1304
1315
  if (this.nativeConsultsInFlightByCallId.get(callId) === state) this.nativeConsultsInFlightByCallId.delete(callId);
@@ -1312,7 +1323,7 @@ var RealtimeCallHandler = class {
1312
1323
  const status = result && typeof result === "object" && !Array.isArray(result) && "error" in result ? "error" : "ok";
1313
1324
  const error = status === "error" && result && typeof result === "object" && !Array.isArray(result) ? formatErrorMessage(result.error ?? "unknown") : void 0;
1314
1325
  console.log(`[voice-call] realtime tool call completed callId=${callId} tool=${name} status=${status} elapsedMs=${Date.now() - startedAt}${error ? ` error=${error}` : ""}`);
1315
- submitFinalToolResult(result);
1326
+ await submitFinalToolResult(result);
1316
1327
  if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME && status === "ok") this.consumePartialUserTranscript(callId, context.partialUserTranscript);
1317
1328
  }
1318
1329
  };
@@ -1,8 +1,7 @@
1
- import { s as resolveVoiceCallSessionKey } from "./config-CDSeuGp3.js";
2
- import { m as resolveCallAgentId, p as resolveVoiceResponseModel } from "./runtime-entry-DwMgbq2p.js";
1
+ import { b as resolveVoiceCallSessionKey, m as resolveCallAgentId, p as resolveVoiceResponseModel } from "./runtime-entry-DhTLZ2uB.js";
3
2
  import { isRecord, normalizeLowercaseStringOrEmpty, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4
3
  import crypto from "node:crypto";
5
- import { applyModelOverrideToSessionEntry } from "openclaw/plugin-sdk/model-session-runtime";
4
+ import { ModelSelectionLockedError, applyModelOverrideToSessionEntry, resolvePersistedSessionRuntimeId } from "openclaw/plugin-sdk/model-session-runtime";
6
5
  //#region extensions/voice-call/src/response-generator.ts
7
6
  /**
8
7
  * Voice call response generator - uses the embedded OpenClaw agent for tool support.
@@ -67,7 +66,11 @@ function sanitizePlainSpokenText(text) {
67
66
  const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ").trim();
68
67
  if (!withoutCodeFences) return null;
69
68
  const paragraphs = normalizeStringEntries(withoutCodeFences.split(/\n\s*\n+/));
70
- while (paragraphs.length > 1 && isLikelyMetaReasoningParagraph(paragraphs[0])) paragraphs.shift();
69
+ while (paragraphs.length > 1) {
70
+ const firstParagraph = paragraphs.at(0);
71
+ if (!firstParagraph || !isLikelyMetaReasoningParagraph(firstParagraph)) break;
72
+ paragraphs.shift();
73
+ }
71
74
  return normalizeSpokenText(paragraphs.join(" "));
72
75
  }
73
76
  function extractSpokenTextFromPayloads(payloads) {
@@ -142,6 +145,7 @@ async function generateVoiceResponse(params) {
142
145
  agentRuntime
143
146
  });
144
147
  let sessionEntry = existingSessionEntry;
148
+ if (sessionEntry?.modelSelectionLocked === true && voiceConfig.responseModel) throw new ModelSelectionLockedError();
145
149
  if (!sessionEntry?.sessionId || voiceConfig.responseModel) sessionEntry = await agentRuntime.session.patchSessionEntry({
146
150
  storePath,
147
151
  sessionKey: resolvedSessionKey,
@@ -173,6 +177,8 @@ async function generateVoiceResponse(params) {
173
177
  error: "Voice response session could not be initialized"
174
178
  };
175
179
  const sessionId = sessionEntry.sessionId;
180
+ const modelSelectionLocked = sessionEntry.modelSelectionLocked === true;
181
+ const persistedRuntimeId = resolvePersistedSessionRuntimeId(sessionEntry);
176
182
  const thinkLevel = agentRuntime.resolveThinkingDefault({
177
183
  cfg,
178
184
  provider,
@@ -207,6 +213,11 @@ async function generateVoiceResponse(params) {
207
213
  prompt: userMessage,
208
214
  provider,
209
215
  model,
216
+ modelSelectionLocked,
217
+ ...persistedRuntimeId ? {
218
+ agentHarnessId: persistedRuntimeId,
219
+ agentHarnessRuntimeOverride: persistedRuntimeId
220
+ } : {},
210
221
  thinkLevel,
211
222
  verboseLevel: "off",
212
223
  timeoutMs,
@@ -260,6 +271,11 @@ async function generateVoiceResponse(params) {
260
271
  };
261
272
  });
262
273
  } catch (err) {
274
+ if (err instanceof ModelSelectionLockedError) return {
275
+ text: null,
276
+ deliveredEarly: false,
277
+ error: err.message
278
+ };
263
279
  console.error(`[voice-call] Response generation failed:`, err);
264
280
  return {
265
281
  text: null,