Files
openclaw/extensions/voice-call/src/response-generator.ts
Peter Steinberger f94a7dc183 feat(codex): supervise native Codex sessions (#104045)
* feat(codex): add native session supervision

* fix(codex): harden supervision integration

* fix(codex): preserve locked harness ownership

* fix(codex): fence native session archive

* fix(codex): revalidate archive binding ownership

* feat(codex): integrate supervision runtime

* feat(sessions): preserve harness-owned execution

* feat(sessions): persist harness ownership invariants

* feat(gateway): enforce harness-owned sessions

* feat(setup): enable detected Codex supervision

* feat(mac): expose supervised Codex sessions

* feat(ui): make Codex sessions actionable

* docs(codex): document session supervision

* test(codex): cover integration ownership

* chore(i18n): refresh supervision inventories

* fix(setup): finalize Codex activation atomically

* test(codex): narrow binding store update

* fix(sessions): preserve legacy model locks

* test(macos): serialize Codex catalog fixtures

* fix(sessions): preserve legacy lock admission

* chore(i18n): reconcile supervision metadata

* test(sessions): mark legacy lock fixture

* fix(macos): drain final Codex catalog frame

* docs: leave supervision note to release

* style(macos): satisfy Codex catalog type length

* chore: record session accessor seam owners

* fix(macos): honor configured Codex supervision

* fix(codex): preserve harness-owned model locks

* fix(codex): satisfy supervision lint gates

* chore(i18n): refresh native supervision inventory

* fix(codex): align supervision validation contracts

* fix(codex): close supervision boundary gaps

* fix(codex): preserve supervision activation contracts

* fix(codex): dispose standalone supervision runtime

* fix(codex): pin supervised source connection

* fix(plugins): bind delegated runs to exact session target

* fix(codex): scope supervised sessions to configured agents

* fix(codex): fingerprint effective supervision home

* fix(codex): normalize supervision plugin policy

* fix(codex): keep supervised bindings stable across upgrades

* fix(codex): guard all supervised binding connections

* fix(codex): preserve catalog filters and pending CAS identity

* fix(codex): preserve supervision identity for diagnostics

* fix(codex): bind uncertain commits to supervision connection

* fix(codex): satisfy supervision type boundaries

* fix(macos): reconcile current main validation

* fix(codex): handle absent runtime config in supervision

* fix(doctor): own local audio acceleration check

* fix(codex): satisfy integration lint gates

* fix(codex): satisfy lifecycle safety guards
2026-07-11 00:12:08 -07:00

468 lines
15 KiB
TypeScript

/**
* Voice call response generator - uses the embedded OpenClaw agent for tool support.
* Routes voice responses through the same agent infrastructure as messaging.
*/
import crypto from "node:crypto";
import {
applyModelOverrideToSessionEntry,
ModelSelectionLockedError,
resolvePersistedSessionRuntimeId,
} from "openclaw/plugin-sdk/model-session-runtime";
import {
isRecord,
normalizeLowercaseStringOrEmpty,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveVoiceCallSessionKey, type VoiceCallConfig } from "./config.js";
import type { CoreAgentDeps, CoreConfig } from "./core-bridge.js";
import { resolveCallAgentId } from "./resolve-call-agent-id.js";
import { resolveVoiceResponseModel } from "./response-model.js";
type VoiceResponseParams = {
/** Voice call config */
voiceConfig: VoiceCallConfig;
/** Core OpenClaw config */
coreConfig: CoreConfig;
/** Injected host agent runtime */
agentRuntime: CoreAgentDeps;
/** Call ID for session tracking */
callId: string;
/** Persisted call session key */
sessionKey?: string;
/** Caller's phone number */
from: string;
/** Agent frozen on the call record. */
agentId?: string;
/** Conversation transcript */
transcript: Array<{ speaker: "user" | "bot"; text: string }>;
/** Latest user message */
userMessage: string;
/** Delivers completed reply blocks while post-turn work is still running. */
onEarlyText?: (text: string) => Promise<boolean>;
};
type VoiceResponseResult = {
text: string | null;
/** Whether the complete response was handed to the transport before compaction. */
deliveredEarly: boolean;
error?: string;
};
type VoiceResponsePayload = {
text?: string;
isError?: boolean;
isReasoning?: boolean;
};
function readExplicitToolsAllow(value: unknown): string[] | undefined {
if (!isRecord(value)) {
return undefined;
}
const allow = value.allow;
if (!Array.isArray(allow)) {
return undefined;
}
return allow.filter((entry): entry is string => typeof entry === "string");
}
function resolveVoiceAgentToolsAllow(config: CoreConfig, agentId: string): string[] | undefined {
const agents = isRecord(config.agents) ? config.agents : undefined;
const list = Array.isArray(agents?.list) ? agents.list : [];
const agent = list.find((entry) => isRecord(entry) && entry.id === agentId);
if (!isRecord(agent)) {
return undefined;
}
return readExplicitToolsAllow(isRecord(agent.tools) ? agent.tools : undefined);
}
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: string): string | null {
const normalized = value.replace(/\s+/g, " ").trim();
return normalized.length > 0 ? normalized : null;
}
function tryParseSpokenJson(text: string): string | null {
const candidates: string[] = [];
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) as { spoken?: unknown };
if (typeof parsed?.spoken !== "string") {
continue;
}
return normalizeSpokenText(parsed.spoken) ?? "";
} catch {
// Continue trying other candidates.
}
}
const inlineSpokenMatch = trimmed.match(/"spoken"\s*:\s*"((?:[^"\\]|\\.)*)"/i);
if (!inlineSpokenMatch) {
return null;
}
try {
const decoded = JSON.parse(`"${inlineSpokenMatch[1] ?? ""}"`) as string;
return normalizeSpokenText(decoded) ?? "";
} catch {
return null;
}
}
function isLikelyMetaReasoningParagraph(paragraph: string): boolean {
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: string): string | null {
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: VoiceResponsePayload[]): string | null {
const spokenSegments: string[] = [];
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;
}
async function deliverEarlyText(
callback: (text: string) => Promise<boolean>,
text: string,
): Promise<boolean> {
try {
return await callback(text);
} catch (error) {
console.error("[voice-call] Early TTS delivery failed:", error);
return false;
}
}
function resolveVoiceSandboxSessionKey(agentId: string, sessionKey: string): string {
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.
*/
export async function generateVoiceResponse(
params: VoiceResponseParams,
): Promise<VoiceResponseResult> {
const {
voiceConfig,
callId,
sessionKey,
from,
transcript,
userMessage,
coreConfig,
agentRuntime,
onEarlyText,
} = params;
if (!coreConfig) {
return {
text: null,
deliveredEarly: false,
error: "Core config unavailable for voice response",
};
}
const cfg = coreConfig;
const agentId = resolveCallAgentId({ agentId: params.agentId }, voiceConfig);
const resolvedSessionKey = resolveVoiceCallSessionKey({
config: { ...voiceConfig, agentId },
callId,
phone: from,
explicitSessionKey: sessionKey,
coreSession: coreConfig.session,
});
const toolsAllow = resolveVoiceAgentToolsAllow(cfg, agentId);
// Resolve paths
const storePath = agentRuntime.session.resolveStorePath(cfg.session?.store, { agentId });
try {
return await agentRuntime.session.runWithWorkAdmission(
{ storePath, sessionKey: resolvedSessionKey },
async (abortSignal) => {
const agentDir = agentRuntime.resolveAgentDir(cfg, agentId);
const workspaceDir = agentRuntime.resolveAgentWorkspaceDir(cfg, agentId);
// Ensure workspace exists
await agentRuntime.ensureAgentWorkspace({ dir: workspaceDir });
// Load or create session entry
const now = Date.now();
const existingSessionEntry = agentRuntime.session.getSessionEntry({
storePath,
sessionKey: resolvedSessionKey,
});
// Resolve model from config
const { provider, model } = resolveVoiceResponseModel({ voiceConfig, agentRuntime });
let sessionEntry = existingSessionEntry;
if (sessionEntry?.modelSelectionLocked === true && voiceConfig.responseModel) {
throw new ModelSelectionLockedError();
}
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;
},
})) ?? undefined;
}
if (!sessionEntry?.sessionId) {
return {
text: null,
deliveredEarly: false,
error: "Voice response session could not be initialized",
};
}
const sessionId = sessionEntry.sessionId;
const modelSelectionLocked = sessionEntry.modelSelectionLocked === true;
const persistedRuntimeId = resolvePersistedSessionRuntimeId(sessionEntry);
// Resolve thinking level
const thinkLevel = agentRuntime.resolveThinkingDefault({ cfg, provider, model });
// Resolve agent identity for personalized prompt
const identity = agentRuntime.resolveAgentIdentity(cfg, agentId);
const agentName = identity?.name?.trim() || "assistant";
// Build system prompt with conversation history
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) {
const history = transcript
.map((entry) => `${entry.speaker === "bot" ? "You" : "Caller"}: ${entry.text}`)
.join("\n");
extraSystemPrompt = `${basePrompt}\n\nConversation so far:\n${history}`;
}
extraSystemPrompt = `${extraSystemPrompt}\n\n${VOICE_SPOKEN_OUTPUT_CONTRACT}`;
// Resolve timeout
const timeoutMs =
voiceConfig.responseTimeoutMs ?? agentRuntime.resolveAgentTimeoutMs({ cfg });
const runId = `voice:${callId}:${Date.now()}`;
const blockReplyPayloads: VoiceResponsePayload[] = [];
let latestToolBoundaryMessageIndex: number | undefined;
let blockReplyBoundariesReliable = true;
let deliveredEarly = false;
let lastFlushedText: string | null = null;
const result = await agentRuntime.runEmbeddedAgent({
sessionId,
sessionKey: resolvedSessionKey,
sessionTarget: {
agentId,
sessionId,
sessionKey: resolvedSessionKey,
storePath,
},
sandboxSessionKey: resolveVoiceSandboxSessionKey(agentId, resolvedSessionKey),
agentId,
messageProvider: "voice",
workspaceDir,
config: cfg,
prompt: userMessage,
provider,
model,
modelSelectionLocked,
...(persistedRuntimeId
? {
agentHarnessId: persistedRuntimeId,
agentHarnessRuntimeOverride: persistedRuntimeId,
}
: {}),
thinkLevel,
verboseLevel: "off",
timeoutMs,
runId,
lane: "voice",
extraSystemPrompt,
agentDir,
toolsAllow,
abortSignal,
blockReplyBreak: "text_end",
onBlockReply: (payload, context) => {
if (latestToolBoundaryMessageIndex !== undefined) {
const messageIndex = context?.assistantMessageIndex;
if (messageIndex === undefined) {
blockReplyBoundariesReliable = false;
return;
}
if (messageIndex <= latestToolBoundaryMessageIndex) {
return;
}
}
blockReplyPayloads.push(payload);
},
onBlockReplyFlush: async (context) => {
if (context.reason === "tool_start") {
// Deferred replies can arrive after this callback. Retain the
// assistant index at the actual tool boundary to reject them.
blockReplyPayloads.length = 0;
latestToolBoundaryMessageIndex = context.assistantMessageIndex;
blockReplyBoundariesReliable = true;
return;
}
if (context.reason !== "pre_compaction") {
return;
}
const pendingPayloads = blockReplyPayloads.splice(0);
const boundariesReliable = blockReplyBoundariesReliable;
latestToolBoundaryMessageIndex = undefined;
blockReplyBoundariesReliable = true;
if (!context.attemptAccepted) {
return;
}
// Call-control APIs acknowledge a playback request, not playback
// completion. Never let a later retry flush replace in-flight audio.
if (deliveredEarly || !onEarlyText || !boundariesReliable) {
return;
}
const text = extractSpokenTextFromPayloads(pendingPayloads);
if (!text) {
return;
}
lastFlushedText = text;
deliveredEarly = await deliverEarlyText(onEarlyText, text);
},
});
const text =
extractSpokenTextFromPayloads((result.payloads ?? []) as VoiceResponsePayload[]) ??
lastFlushedText ??
extractSpokenTextFromPayloads(blockReplyPayloads);
if (!text && result.meta?.aborted) {
return { text: null, deliveredEarly: false, error: "Response generation was aborted" };
}
return { text, deliveredEarly };
},
);
} catch (err) {
if (err instanceof ModelSelectionLockedError) {
return { text: null, deliveredEarly: false, error: err.message };
}
console.error(`[voice-call] Response generation failed:`, err);
return { text: null, deliveredEarly: false, error: String(err) };
}
}