





















@@ -5,10 +5,15 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
55import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
66import {
77buildRealtimeVoiceAgentConsultWorkingResponse,
8+createRealtimeVoiceForcedConsultCoordinator,
89createTalkSessionController,
910createRealtimeVoiceBridgeSession,
1011REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME,
12+readRealtimeVoiceConsultQuestion,
13+readSpeakableRealtimeVoiceToolResult,
1114recordTalkObservabilityEvent,
15+type RealtimeVoiceForcedConsultCoordinator,
16+type RealtimeVoiceForcedConsultHandle,
1217type RealtimeVoiceBridgeSession,
1318type RealtimeVoiceProviderConfig,
1419type RealtimeVoiceProviderPlugin,
@@ -81,21 +86,6 @@ function buildGreetingInstructions(
8186 : `${intro} "${trimmedGreeting}"`;
8287}
838884-function readSpeakableToolResultText(result: unknown): string | undefined {
85-if (typeof result === "string") {
86-return result.trim() || undefined;
87-}
88-if (!result || typeof result !== "object" || Array.isArray(result)) {
89-return undefined;
90-}
91-const text = (result as { text?: unknown }).text;
92-if (typeof text === "string" && text.trim()) {
93-return text.trim();
94-}
95-const output = (result as { output?: unknown }).output;
96-return typeof output === "string" && output.trim() ? output.trim() : undefined;
97-}
98-9989function readConsultArgText(args: unknown, key: string): string | undefined {
10090if (!args || typeof args !== "object" || Array.isArray(args)) {
10191return undefined;
@@ -105,12 +95,7 @@ function readConsultArgText(args: unknown, key: string): string | undefined {
10595}
1069610797function readConsultQuestionText(args: unknown): string | undefined {
108-return (
109-readConsultArgText(args, "question") ??
110-readConsultArgText(args, "prompt") ??
111-readConsultArgText(args, "query") ??
112-readConsultArgText(args, "task")
113-);
98+return readRealtimeVoiceConsultQuestion(args);
11499}
115100116101function normalizeTranscriptText(text: string): string {
@@ -315,10 +300,11 @@ export class RealtimeCallHandler {
315300string,
316301ReturnType<typeof setTimeout>
317302>();
318-private readonly forcedConsultTimersByCallId = new Map<string, ReturnType<typeof setTimeout>>();
319-private readonly forcedConsultInFlightByCallId = new Set<string>();
303+private readonly forcedConsultCoordinatorsByCallId = new Map<
304+string,
305+RealtimeVoiceForcedConsultCoordinator
306+>();
320307private readonly forcedConsultsByCallId = new Map<string, ForcedConsultState>();
321-private readonly lastProviderConsultAtByCallId = new Map<string, number>();
322308private readonly nativeConsultsInFlightByCallId = new Map<string, NativeConsultState>();
323309private publicOrigin: string | null = null;
324310private publicPathPrefix = "";
@@ -1012,14 +998,19 @@ export class RealtimeCallHandler {
1012998}
101399910141000private clearForcedConsultState(callId: string): void {
1015-const timer = this.forcedConsultTimersByCallId.get(callId);
1016-if (timer) {
1017-clearTimeout(timer);
1018-this.forcedConsultTimersByCallId.delete(callId);
1019-}
1020-this.forcedConsultInFlightByCallId.delete(callId);
1001+this.forcedConsultCoordinatorsByCallId.get(callId)?.clear();
1002+this.forcedConsultCoordinatorsByCallId.delete(callId);
10211003this.forcedConsultsByCallId.delete(callId);
1022-this.lastProviderConsultAtByCallId.delete(callId);
1004+}
1005+1006+private forcedConsultCoordinator(callId: string): RealtimeVoiceForcedConsultCoordinator {
1007+const existing = this.forcedConsultCoordinatorsByCallId.get(callId);
1008+if (existing) {
1009+return existing;
1010+}
1011+const created = createRealtimeVoiceForcedConsultCoordinator();
1012+this.forcedConsultCoordinatorsByCallId.set(callId, created);
1013+return created;
10231014}
1024101510251016private closeTelephonyBridge(
@@ -1053,51 +1044,56 @@ export class RealtimeCallHandler {
10531044if (!handler) {
10541045return;
10551046}
1056-const existingTimer = this.forcedConsultTimersByCallId.get(params.callId);
1057-if (existingTimer) {
1058-clearTimeout(existingTimer);
1047+const existingForcedConsult = this.forcedConsultsByCallId.get(params.callId);
1048+if (existingForcedConsult && !existingForcedConsult.completedAt) {
1049+return;
10591050}
1060-const timer = setTimeout(() => {
1061-this.forcedConsultTimersByCallId.delete(params.callId);
1062-if (this.forcedConsultInFlightByCallId.has(params.callId)) {
1063-return;
1064-}
1065-const lastProviderConsultAt = this.lastProviderConsultAtByCallId.get(params.callId) ?? 0;
1066-if (Date.now() - lastProviderConsultAt < 2_000) {
1051+const coordinator = this.forcedConsultCoordinator(params.callId);
1052+if (coordinator.hasRecentNativeConsult(question, { allowUnknownQuestion: true })) {
1053+return;
1054+}
1055+coordinator.clearPending();
1056+const pending = coordinator.prepare(question);
1057+if (!pending) {
1058+return;
1059+}
1060+coordinator.schedule(pending, FORCED_CONSULT_FALLBACK_DELAY_MS, (handle) => {
1061+const activeForcedConsult = this.forcedConsultsByCallId.get(params.callId);
1062+if (activeForcedConsult && !activeForcedConsult.completedAt) {
10671063return;
10681064}
10691065void this.runForcedAgentConsult({
10701066 ...params,
1071-question,
1067+handle,
10721068 handler,
10731069});
1074-}, FORCED_CONSULT_FALLBACK_DELAY_MS);
1075-this.forcedConsultTimersByCallId.set(params.callId, timer);
1070+});
10761071}
1077107210781073private async runForcedAgentConsult(params: {
10791074session: ActiveRealtimeVoiceBridge;
10801075callId: string;
10811076callSid: string;
1082-question: string;
1077+handle: RealtimeVoiceForcedConsultHandle;
10831078clearAudio: () => void;
10841079handler: ToolHandlerFn;
10851080}): Promise<void> {
1086-this.forcedConsultInFlightByCallId.add(params.callId);
1081+const coordinator = this.forcedConsultCoordinator(params.callId);
1082+coordinator.markStarted(params.handle);
10871083const startedAt = Date.now();
10881084logger.debug(
1089-`[voice-call] realtime forced agent consult reason=${FORCED_CONSULT_REASON} consultPolicy=always callId=${params.callId} providerCallId=${params.callSid} chars=${params.question.length}`,
1085+`[voice-call] realtime forced agent consult reason=${FORCED_CONSULT_REASON} consultPolicy=always callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`,
10901086);
10911087console.log(
1092-`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.question.length}`,
1088+`[voice-call] realtime forced agent consult starting callId=${params.callId} providerCallId=${params.callSid} chars=${params.handle.question.length}`,
10931089);
10941090params.clearAudio();
10951091const state: ForcedConsultState = {
10961092sendSpeechPrompt: true,
10971093promise: Promise.resolve().then(() =>
10981094params.handler(
10991095{
1100-question: params.question,
1096+question: params.handle.question,
11011097},
11021098params.callId,
11031099{},
@@ -1108,7 +1104,11 @@ export class RealtimeCallHandler {
11081104try {
11091105const result = await state.promise;
11101106state.completedAt = Date.now();
1111-const text = readSpeakableToolResultText(result);
1107+coordinator.markDelivered(params.handle);
1108+const text = readSpeakableRealtimeVoiceToolResult(result, {
1109+keys: ["text", "output"],
1110+maxChars: FORCED_CONSULT_RESULT_MAX_CHARS,
1111+});
11121112if (!text) {
11131113console.warn(
11141114`[voice-call] realtime forced agent consult returned no speakable text callId=${params.callId} providerCallId=${params.callSid}`,
@@ -1122,16 +1122,16 @@ export class RealtimeCallHandler {
11221122console.log(
11231123`[voice-call] realtime forced agent consult completed callId=${params.callId} providerCallId=${params.callSid} elapsedMs=${Date.now() - startedAt}`,
11241124);
1125-this.consumePartialUserTranscript(params.callId, params.question);
1125+this.consumePartialUserTranscript(params.callId, params.handle.question);
11261126} catch (error) {
11271127console.warn(
11281128`[voice-call] realtime forced agent consult failed callId=${params.callId} providerCallId=${params.callSid} error=${formatErrorMessage(error)}`,
11291129);
11301130} finally {
1131-this.forcedConsultInFlightByCallId.delete(params.callId);
11321131const cleanupTimer = setTimeout(() => {
11331132if (this.forcedConsultsByCallId.get(params.callId) === state) {
11341133this.forcedConsultsByCallId.delete(params.callId);
1134+coordinator.remove(params.handle);
11351135}
11361136}, FORCED_CONSULT_NATIVE_DEDUPE_MS);
11371137cleanupTimer.unref?.();
@@ -1273,15 +1273,17 @@ export class RealtimeCallHandler {
12731273}
12741274};
12751275if (name === REALTIME_VOICE_AGENT_CONSULT_TOOL_NAME) {
1276-this.lastProviderConsultAtByCallId.set(callId, Date.now());
1277-const timer = this.forcedConsultTimersByCallId.get(callId);
1278-if (timer) {
1279-clearTimeout(timer);
1280-this.forcedConsultTimersByCallId.delete(callId);
1276+const coordinator = this.forcedConsultCoordinator(callId);
1277+const forcedMatch = coordinator.recordNativeConsult(args, bridgeCallId);
1278+if (forcedMatch.kind === "none") {
1279+const pending = coordinator.consumePending();
1280+if (pending) {
1281+coordinator.remove(pending);
1282+}
12811283}
12821284const forcedConsult = this.forcedConsultsByCallId.get(callId);
12831285if (forcedConsult) {
1284-if (forcedConsult.completedAt) {
1286+if (forcedConsult.completedAt || forcedMatch.kind === "already_delivered") {
12851287submitFinalToolResult({
12861288status: "already_delivered",
12871289message: "OpenClaw already delivered this consult result internally. Do not repeat it.",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。