UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

212 lines (211 loc) 8.47 kB
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js"; import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js"; import { c as isRecord } from "./utils-CCC-BEJH.js"; import { t as applyModelOverrideToSessionEntry } from "./model-overrides-D5WaLvcp.js"; import "./string-coerce-runtime-CEGJWkQ_.js"; import "./model-session-runtime-XUhYGuhe.js"; import { o as resolveVoiceCallSessionKey } from "./config-BYJnzp4g.js"; import { t as resolveVoiceResponseModel } from "./response-model-DBgnh7Hz.js"; import crypto from "node:crypto"; //#region extensions/voice-call/src/response-generator.ts /** * Voice call response generator - uses the embedded OpenClaw agent for tool support. * Routes voice responses through the same agent infrastructure as messaging. */ function readExplicitToolsAllow(value) { if (!isRecord(value)) return; const allow = value.allow; if (!Array.isArray(allow)) return; return allow.filter((entry) => typeof entry === "string"); } function resolveVoiceAgentToolsAllow(config, agentId) { const agents = isRecord(config.agents) ? config.agents : void 0; const agent = (Array.isArray(agents?.list) ? agents.list : []).find((entry) => isRecord(entry) && entry.id === agentId); if (!isRecord(agent)) return; return readExplicitToolsAllow(isRecord(agent.tools) ? agent.tools : void 0); } const VOICE_SPOKEN_OUTPUT_CONTRACT = [ "Output format requirements:", "- Return only valid JSON in this exact shape: {\"spoken\":\"...\"}", "- Do not include markdown, code fences, planning text, or extra keys.", "- Put exactly what should be spoken to the caller into \"spoken\".", "- If there is nothing to say, return {\"spoken\":\"\"}." ].join("\n"); function normalizeSpokenText(value) { const normalized = value.replace(/\s+/g, " ").trim(); return normalized.length > 0 ? normalized : null; } function tryParseSpokenJson(text) { const candidates = []; const trimmed = text.trim(); if (!trimmed) return null; candidates.push(trimmed); const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i); if (fenced?.[1]) candidates.push(fenced[1]); const firstBrace = trimmed.indexOf("{"); const lastBrace = trimmed.lastIndexOf("}"); if (firstBrace >= 0 && lastBrace > firstBrace) candidates.push(trimmed.slice(firstBrace, lastBrace + 1)); for (const candidate of candidates) try { const parsed = JSON.parse(candidate); if (typeof parsed?.spoken !== "string") continue; return normalizeSpokenText(parsed.spoken) ?? ""; } catch {} const inlineSpokenMatch = trimmed.match(/"spoken"\s*:\s*"((?:[^"\\]|\\.)*)"/i); if (!inlineSpokenMatch) return null; try { return normalizeSpokenText(JSON.parse(`"${inlineSpokenMatch[1] ?? ""}"`)) ?? ""; } catch { return null; } } function isLikelyMetaReasoningParagraph(paragraph) { const lower = normalizeLowercaseStringOrEmpty(paragraph); if (!lower) return false; if (lower.startsWith("thinking process")) return true; if (lower.startsWith("reasoning:") || lower.startsWith("analysis:")) return true; if (lower.startsWith("the user ") && (lower.includes("i should") || lower.includes("i need to") || lower.includes("i will"))) return true; if (lower.includes("this is a natural continuation of the conversation") || lower.includes("keep the conversation flowing")) return true; return false; } function sanitizePlainSpokenText(text) { const withoutCodeFences = text.replace(/```[\s\S]*?```/g, " ").trim(); if (!withoutCodeFences) return null; const paragraphs = normalizeStringEntries(withoutCodeFences.split(/\n\s*\n+/)); while (paragraphs.length > 1 && isLikelyMetaReasoningParagraph(paragraphs[0])) paragraphs.shift(); return normalizeSpokenText(paragraphs.join(" ")); } function extractSpokenTextFromPayloads(payloads) { const spokenSegments = []; for (const payload of payloads) { if (payload.isError || payload.isReasoning) continue; const rawText = payload.text?.trim() ?? ""; if (!rawText) continue; const structured = tryParseSpokenJson(rawText); if (structured !== null) { if (structured.length > 0) spokenSegments.push(structured); continue; } const plain = sanitizePlainSpokenText(rawText); if (plain) spokenSegments.push(plain); } return spokenSegments.length > 0 ? spokenSegments.join(" ").trim() : null; } function resolveVoiceSandboxSessionKey(agentId, sessionKey) { const trimmed = sessionKey.trim(); if (trimmed.toLowerCase().startsWith("agent:")) return trimmed; return `agent:${agentId}:${trimmed}`; } /** * Generate a voice response using the embedded OpenClaw agent with full tool support. * Uses the same agent infrastructure as messaging for consistent behavior. */ async function generateVoiceResponse(params) { const { voiceConfig, callId, sessionKey, from, transcript, userMessage, coreConfig, agentRuntime } = params; if (!coreConfig) return { text: null, error: "Core config unavailable for voice response" }; const cfg = coreConfig; const resolvedSessionKey = resolveVoiceCallSessionKey({ config: voiceConfig, callId, phone: from, explicitSessionKey: sessionKey }); const agentId = voiceConfig.agentId ?? "main"; const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId); const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId }); const agentDir = agentRuntime.resolveAgentDir(cfg, agentId); const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId); await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir }); const now = Date.now(); const existingSessionEntry = agentRuntime.session.getSessionEntry({ storePath, sessionKey: resolvedSessionKey }); const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime }); let sessionEntry = existingSessionEntry; if (!sessionEntry?.sessionId || voiceConfig.responseModel) sessionEntry = await agentRuntime.session.patchSessionEntry({ storePath, sessionKey: resolvedSessionKey, replaceEntry: true, fallbackEntry: sessionEntry ?? { sessionId: crypto.randomUUID(), updatedAt: now }, update: (entry) => { const next = entry.sessionId ? { ...entry } : { ...entry, sessionId: crypto.randomUUID(), updatedAt: now }; if (voiceConfig.responseModel) applyModelOverrideToSessionEntry({ entry: next, selection: { provider, model }, selectionSource: "auto" }); return next; } }) ?? void 0; if (!sessionEntry?.sessionId) return { text: null, error: "Voice response session could not be initialized" }; const sessionId = sessionEntry.sessionId; const sessionFile = agentRuntime.session.resolveSessionFilePath(sessionId, sessionEntry, { agentId }); const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model }); const agentName = agentRuntime.resolveAgentIdentity(cfg, agentId)?.name?.trim() || "assistant"; const basePrompt = voiceConfig.responseSystemPrompt ?? `You are ${agentName}, a helpful voice assistant on a phone call. Keep responses brief and conversational (1-2 sentences max). Be natural and friendly. The caller's phone number is ${from}. You have access to tools - use them when helpful.`; let extraSystemPrompt = basePrompt; if (transcript.length > 0) extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${transcript.map((entry) => `${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`).join("\n")}`; extraSystemPrompt = `${extraSystemPrompt}\n\n${VOICE_SPOKEN_OUTPUT_CONTRACT}`; const timeoutMs = voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg }); const runId = `voice:${callId}:${Date.now()}`; try { const result = await agentRuntime.runEmbeddedAgent({ sessionId, sessionKey: resolvedSessionKey, sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey), agentId, messageProvider: "voice", sessionFile, workspaceDir, config: cfg, prompt: userMessage, provider, model, thinkLevel, verboseLevel: "off", timeoutMs, runId, lane: "voice", extraSystemPrompt, agentDir, toolsAllow }); const text = extractSpokenTextFromPayloads(result.payloads ?? []); if (!text && result.meta?.aborted) return { text: null, error: "Response generation was aborted" }; return { text }; } catch (err) { console.error(`[voice-call] Response generation failed:`, err); return { text: null, error: String(err) }; } } //#endregion export { generateVoiceResponse };