Files
openclaw/src/agents/tools/tts-tool.ts
T
pashpashpash 563dca82f4 Add Codex happy path prompt snapshots (#75807)
* Add Codex prompt snapshots

* Fix prompt snapshot scenario catalogs

* Harden prompt snapshot drift check

* Fix CLI compat build export

* fix: keep codex snapshots out of core plugin surface

* fix: harden prompt snapshot ci checks

* fix: accept readonly web search onboarding scopes

* fix: repair plugin sdk package boundary types

* fix: clear prompt snapshot ci regressions

* fix: clear latest main ci checks

* fix: resolve latest main discord helper overlap

* fix: refresh codex dynamic tool snapshots

* fix: align prompt snapshot branch with latest ci

* fix: isolate plugin auto enable tests

* test: refresh prompt dynamic tool snapshots

* fix: stabilize bundled channel auto enable

* fix: clean stale prompt snapshots
2026-05-03 00:59:55 +09:00

110 lines
4.2 KiB
TypeScript

import { Type } from "typebox";
import { getRuntimeConfig } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { textToSpeech } from "../../tts/tts.js";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import type { AnyAgentTool } from "./common.js";
import { ToolInputError, readNumberParam, readStringParam } from "./common.js";
const TtsToolSchema = Type.Object({
text: Type.String({ description: "Text to convert to speech." }),
channel: Type.Optional(
Type.String({ description: "Optional channel id to pick output format." }),
),
timeoutMs: Type.Optional(
Type.Number({
description: "Optional provider request timeout in milliseconds.",
minimum: 1,
}),
),
});
function readTtsTimeoutMs(args: Record<string, unknown>): number | undefined {
const timeoutMs = readNumberParam(args, "timeoutMs", {
integer: true,
strict: true,
});
if (timeoutMs === undefined) {
return undefined;
}
if (timeoutMs <= 0) {
throw new ToolInputError("timeoutMs must be a positive integer in milliseconds.");
}
return timeoutMs;
}
/**
* Defuse reply-directive tokens inside spoken transcripts before they flow
* through tool-result content. When verbose tool output is enabled,
* `emitToolOutput` passes the content through `parseReplyDirectives`
* (`src/media/parse.ts` / `src/utils/directive-tags.ts`), and unfiltered
* `MEDIA:` or `[[audio_as_voice]]`-shaped tokens in the transcript would be
* rewritten into actual media URLs and audio-as-voice flags. Insert a
* zero-width word joiner so the regex patterns stop matching without
* changing the visible text.
*/
function sanitizeTranscriptForToolContent(text: string): string {
return text
.replace(/^([^\S\r\n]*)MEDIA:/gim, "$1\u2060MEDIA:")
.replace(/\[\[/g, "[\u2060[")
.replace(/^([ \t]*)(`{3,})/gm, (_match, indent: string, fence: string) => {
const [first = "", ...rest] = fence;
return `${indent}${first}\u2060${rest.join("")}`;
});
}
export function createTtsTool(opts?: {
config?: OpenClawConfig;
agentChannel?: GatewayMessageChannel;
agentId?: string;
agentAccountId?: string;
}): AnyAgentTool {
return {
label: "TTS",
name: "tts",
displaySummary: "Convert text to speech and return audio.",
description:
"Use only for explicit audio intent (audio, voice, speech, TTS) or active TTS config. Never use for ordinary text replies. " +
"Audio is delivered automatically from the tool result. After a successful call, follow the current conversation's reply instructions and avoid sending a duplicate text/audio response.",
parameters: TtsToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const text = readStringParam(params, "text", { required: true });
const channel = readStringParam(params, "channel");
const timeoutMs = readTtsTimeoutMs(params);
const cfg = opts?.config ?? getRuntimeConfig();
const result = await textToSpeech({
text,
cfg,
channel: channel ?? opts?.agentChannel,
timeoutMs,
agentId: opts?.agentId,
accountId: opts?.agentAccountId,
});
if (result.success && result.audioPath) {
// Preserve the spoken text in the tool result content so the session
// transcript retains what was said across turns. The audio itself is
// still delivered via details.media. Sanitize first so a crafted
// utterance cannot inject reply directives when the tool output is
// rendered in verbose mode.
return {
content: [{ type: "text", text: `(spoken) ${sanitizeTranscriptForToolContent(text)}` }],
details: {
audioPath: result.audioPath,
provider: result.provider,
...(timeoutMs !== undefined ? { timeoutMs } : {}),
media: {
mediaUrl: result.audioPath,
trustedLocalMedia: true,
...(result.audioAsVoice || result.voiceCompatible ? { audioAsVoice: true } : {}),
},
},
};
}
throw new Error(result.error ?? "TTS conversion failed");
},
};
}