fix: normalize music generation timeouts

This commit is contained in:
Peter Steinberger
2026-05-02 08:52:19 +01:00
parent 0b0c8e3af4
commit 65fc962d7b
6 changed files with 239 additions and 12 deletions

View File

@@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai
- Providers/OpenAI: resolve `keychain:<service>:<account>` `OPENAI_API_KEY` refs before creating OpenAI Realtime browser sessions or voice bridges, with a bounded cached Keychain lookup. Fixes #72120. Thanks @ctbritt.
- Discord/gateway: reconnect when the gateway socket closes while waiting for the shared IDENTIFY concurrency window, instead of silently skipping IDENTIFY and leaving the bot online but unresponsive. Fixes #74617. Thanks @zeeskdr-ai.
- Voice Call: add `sessionScope: "per-call"` for fresh per-call agent memory while preserving the default per-phone caller history. Fixes #45280. Thanks @pondcountry.
- Music generation: raise too-small tool timeouts to the provider-safe 10-second floor and collapse cascading abort fallback errors into a clearer root-cause summary. Thanks @shakkernerd.
- Telegram/startup: use the existing `getMe` request guard for the gateway bot probe instead of a fixed 2.5-second budget, and honor higher `timeoutSeconds` configs for slow Telegram API paths. Fixes #75783. Thanks @tankotan.
- Telegram/models: make model picker confirmations say selections are session-scoped and do not change the agent's persistent default. Fixes #75965. Thanks @sd1114820.
- Control UI/slash commands: keep fallback command metadata on a browser-safe registry path, so provider thinking runtime imports cannot blank the Web UI with `process is not defined`. Fixes #75987. Thanks @novkien.

View File

@@ -157,7 +157,7 @@ Direct generation example:
Output format hint when the provider supports it.
</ParamField>
<ParamField path="filename" type="string">Output filename hint.</ParamField>
<ParamField path="timeoutMs" type="number">Optional provider request timeout in milliseconds.</ParamField>
<ParamField path="timeoutMs" type="number">Optional provider request timeout in milliseconds. Values below 10000ms are raised to 10000ms and reported in the tool result.</ParamField>
<Note>
Not all providers support all parameters. OpenClaw still validates hard

View File

@@ -129,9 +129,11 @@ describe("createMusicGenerateTool", () => {
vi.unstubAllEnvs();
});
it("returns null when no music-generation config or auth-backed provider is available", () => {
it("returns null when generation tools are disabled", () => {
vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([]);
expect(createMusicGenerateTool({ config: asConfig({}) })).toBeNull();
expect(
createMusicGenerateTool({ config: asConfig({ plugins: { enabled: false } }) }),
).toBeNull();
});
it("registers when music-generation config is present", () => {
@@ -251,6 +253,63 @@ describe("createMusicGenerateTool", () => {
expect(taskExecutorMocks.completeTaskRunByRunId).not.toHaveBeenCalled();
});
it("raises too-small music timeouts to the provider-safe minimum", async () => {
const generateSpy = vi.spyOn(musicGenerationRuntime, "generateMusic").mockResolvedValue({
provider: "google",
model: "lyria-3-clip-preview",
attempts: [],
ignoredOverrides: [],
tracks: [
{
buffer: Buffer.from("music-bytes"),
mimeType: "audio/mpeg",
fileName: "night-drive.mp3",
},
],
});
vi.spyOn(mediaStore, "saveMediaBuffer").mockResolvedValueOnce({
path: "/tmp/generated-night-drive.mp3",
id: "generated-night-drive.mp3",
size: 11,
contentType: "audio/mpeg",
});
const tool = createMusicGenerateTool({
config: asConfig({
agents: {
defaults: {
musicGenerationModel: { primary: "google/lyria-3-clip-preview" },
},
},
}),
});
if (!tool) {
throw new Error("expected music_generate tool");
}
const result = await tool.execute("call-1", {
prompt: "night-drive synthwave",
timeoutMs: 1000,
});
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(generateSpy).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 10_000,
}),
);
expect(text).toContain("Timeout normalized: requested 1000ms; used 10000ms.");
expect(result.details).toMatchObject({
timeoutMs: 10_000,
requestedTimeoutMs: 1000,
timeoutNormalization: {
requested: 1000,
applied: 10_000,
minimum: 10_000,
},
});
});
it("starts background generation and wakes the session with MEDIA lines", async () => {
taskExecutorMocks.createRunningTaskRun.mockReturnValue({
taskId: "task-123",
@@ -313,11 +372,13 @@ describe("createMusicGenerateTool", () => {
const result = await tool.execute("call-1", {
prompt: "night-drive synthwave",
instrumental: true,
timeoutMs: 1000,
});
const text = (result.content?.[0] as { text: string } | undefined)?.text ?? "";
expect(text).toContain("Background task started for music generation (task-123).");
expect(text).toContain("Do not call music_generate again for this request.");
expect(text).toContain("Timeout normalized: requested 1000ms; used 10000ms.");
expect(result.details).toMatchObject({
async: true,
status: "started",
@@ -325,9 +386,21 @@ describe("createMusicGenerateTool", () => {
taskId: "task-123",
},
instrumental: true,
timeoutMs: 10_000,
requestedTimeoutMs: 1000,
timeoutNormalization: {
requested: 1000,
applied: 10_000,
minimum: 10_000,
},
});
expect(typeof scheduledWork).toBe("function");
await scheduledWork?.();
expect(musicGenerationRuntime.generateMusic).toHaveBeenCalledWith(
expect.objectContaining({
timeoutMs: 10_000,
}),
);
expect(taskExecutorMocks.recordTaskRunProgressByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: expect.stringMatching(/^tool:music_generate:/),

View File

@@ -70,6 +70,7 @@ const log = createSubsystemLogger("agents/tools/music-generate");
const MAX_INPUT_IMAGES = 10;
const SUPPORTED_OUTPUT_FORMATS = new Set<MusicGenerationOutputFormat>(["mp3", "wav"]);
const DEFAULT_REFERENCE_FETCH_TIMEOUT_MS = 30_000;
const MIN_MUSIC_GENERATION_TIMEOUT_MS = 10_000;
const MusicGenerateToolSchema = Type.Object({
action: Type.Optional(
@@ -112,7 +113,8 @@ const MusicGenerateToolSchema = Type.Object({
),
timeoutMs: Type.Optional(
Type.Number({
description: "Optional provider request timeout in milliseconds.",
description:
"Optional provider request timeout in milliseconds. Values below 10000ms are raised to 10000ms.",
minimum: 1,
}),
),
@@ -231,6 +233,42 @@ type MusicGenerateSandboxConfig = {
type MusicGenerateBackgroundScheduler = (work: () => Promise<void>) => void;
type MusicGenerationTimeoutNormalization = {
requested: number;
applied: number;
minimum: number;
};
function normalizeMusicGenerationTimeoutMs(timeoutMs: number | undefined): {
timeoutMs?: number;
normalization?: MusicGenerationTimeoutNormalization;
message?: string;
} {
if (timeoutMs === undefined) {
return {};
}
if (timeoutMs >= MIN_MUSIC_GENERATION_TIMEOUT_MS) {
return { timeoutMs };
}
const normalization = {
requested: timeoutMs,
applied: MIN_MUSIC_GENERATION_TIMEOUT_MS,
minimum: MIN_MUSIC_GENERATION_TIMEOUT_MS,
};
const message = `Timeout normalized: requested ${timeoutMs}ms; used ${MIN_MUSIC_GENERATION_TIMEOUT_MS}ms.`;
log.warn("music_generate timeoutMs is below provider minimum; using minimum", {
requestedTimeoutMs: timeoutMs,
appliedTimeoutMs: MIN_MUSIC_GENERATION_TIMEOUT_MS,
minimumTimeoutMs: MIN_MUSIC_GENERATION_TIMEOUT_MS,
});
return {
timeoutMs: MIN_MUSIC_GENERATION_TIMEOUT_MS,
normalization,
message,
};
}
function defaultScheduleMusicGenerateBackgroundWork(work: () => Promise<void>) {
queueMicrotask(() => {
void work().catch((error) => {
@@ -369,6 +407,7 @@ async function executeMusicGenerationJob(params: {
loadedReferenceImages: LoadedReferenceImage[];
taskHandle?: MusicGenerationTaskHandle | null;
timeoutMs?: number;
timeoutNormalization?: MusicGenerationTimeoutNormalization;
}): Promise<ExecutedMusicGeneration> {
if (params.taskHandle) {
recordMusicGenerationTaskProgress({
@@ -432,6 +471,11 @@ async function executeMusicGenerationJob(params: {
const lines = [
`Generated ${savedTracks.length} track${savedTracks.length === 1 ? "" : "s"} with ${result.provider}/${result.model}.`,
...(warning ? [`Warning: ${warning}`] : []),
...(params.timeoutNormalization
? [
`Timeout normalized: requested ${params.timeoutNormalization.requested}ms; used ${params.timeoutNormalization.applied}ms.`,
]
: []),
typeof requestedDurationSeconds === "number" &&
typeof appliedDurationSeconds === "number" &&
requestedDurationSeconds !== appliedDurationSeconds
@@ -472,6 +516,12 @@ async function executeMusicGenerationJob(params: {
...(!ignoredOverrideKeys.has("format") && params.format ? { format: params.format } : {}),
...(params.filename ? { filename: params.filename } : {}),
...(params.timeoutMs !== undefined ? { timeoutMs: params.timeoutMs } : {}),
...(params.timeoutNormalization
? {
requestedTimeoutMs: params.timeoutNormalization.requested,
timeoutNormalization: params.timeoutNormalization,
}
: {}),
...buildMediaReferenceDetails({
entries: params.loadedReferenceImages,
singleKey: "image",
@@ -570,7 +620,9 @@ export function createMusicGenerateTool(options?: {
});
const format = normalizeOutputFormat(readStringParam(args, "format"));
const filename = readStringParam(args, "filename");
const timeoutMs = readGenerationTimeoutMs(args);
const requestedTimeoutMs = readGenerationTimeoutMs(args);
const timeout = normalizeMusicGenerationTimeoutMs(requestedTimeoutMs);
const timeoutMs = timeout.timeoutMs;
const imageInputs = normalizeReferenceImageInputs(args);
const selectedProvider = resolveSelectedMusicGenerationProvider({
config: effectiveCfg,
@@ -623,6 +675,7 @@ export function createMusicGenerateTool(options?: {
loadedReferenceImages,
taskHandle,
timeoutMs,
timeoutNormalization: timeout.normalization,
}),
});
completeMusicGenerationTaskRun({
@@ -668,7 +721,12 @@ export function createMusicGenerateTool(options?: {
content: [
{
type: "text",
text: `Background task started for music generation (${taskHandle?.taskId ?? "unknown"}). Do not call music_generate again for this request. Wait for the completion event; I'll post the finished music here when it's ready.`,
text: [
`Background task started for music generation (${taskHandle?.taskId ?? "unknown"}). Do not call music_generate again for this request. Wait for the completion event; I'll post the finished music here when it's ready.`,
timeout.message,
]
.filter((entry): entry is string => Boolean(entry))
.join("\n"),
},
],
details: {
@@ -688,6 +746,13 @@ export function createMusicGenerateTool(options?: {
...(format ? { format } : {}),
...(filename ? { filename } : {}),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
...(timeout.normalization
? {
requestedTimeoutMs: timeout.normalization.requested,
timeoutNormalization: timeout.normalization,
warning: timeout.message,
}
: {}),
},
};
}
@@ -706,6 +771,7 @@ export function createMusicGenerateTool(options?: {
loadedReferenceImages,
taskHandle,
timeoutMs,
timeoutNormalization: timeout.normalization,
});
completeMusicGenerationTaskRun({
handle: taskHandle,

View File

@@ -7,6 +7,7 @@ import {
resolveClosestAspectRatio,
resolveClosestResolution,
resolveClosestSize,
throwCapabilityGenerationFailure,
} from "./runtime-shared.js";
function parseModelRef(raw?: string) {
@@ -220,3 +221,56 @@ describe("media-generation runtime shared normalization", () => {
expect(normalizeDurationToClosestMax(6, 8)).toBe(6);
});
});
describe("media-generation runtime shared failure summaries", () => {
it("collapses abort cascades behind the non-abort failure", () => {
expect(() =>
throwCapabilityGenerationFailure({
capabilityLabel: "music generation",
attempts: [
{
provider: "google",
model: "lyria-3-clip-preview",
error: "Manually set deadline 1s is too short. Minimum allowed deadline is 10s.",
},
{
provider: "minimax",
model: "music-2.6",
error: "This operation was aborted",
},
{
provider: "minimax-portal",
model: "music-2.6",
error: "This operation was aborted",
},
],
lastError: new Error("This operation was aborted"),
}),
).toThrow(
"All music generation models failed (3): google/lyria-3-clip-preview: Manually set deadline 1s is too short. Minimum allowed deadline is 10s. | 2 fallback(s) aborted after the request was cancelled or timed out: minimax/music-2.6, minimax-portal/music-2.6",
);
});
it("summarizes all-aborted attempts once", () => {
expect(() =>
throwCapabilityGenerationFailure({
capabilityLabel: "music generation",
attempts: [
{
provider: "minimax",
model: "music-2.6",
error: "This operation was aborted",
},
{
provider: "minimax-portal",
model: "music-2.6",
error: "This operation was aborted",
},
],
lastError: new Error("This operation was aborted"),
}),
).toThrow(
"All music generation models failed (2): 2 fallback(s) aborted after the request was cancelled or timed out: minimax/music-2.6, minimax-portal/music-2.6",
);
});
});

View File

@@ -493,12 +493,7 @@ export function throwCapabilityGenerationFailure(params: {
if (params.attempts.length <= 1 && params.lastError) {
throw params.lastError;
}
const summary =
params.attempts.length > 0
? params.attempts
.map((attempt) => `${attempt.provider}/${attempt.model}: ${attempt.error}`)
.join(" | ")
: "unknown";
const summary = formatCapabilityFailureAttempts(params.attempts);
throw new Error(
`All ${params.capabilityLabel} models failed (${params.attempts.length}): ${summary}`,
{
@@ -507,6 +502,44 @@ export function throwCapabilityGenerationFailure(params: {
);
}
function formatCapabilityFailureAttempts(attempts: FallbackAttempt[]): string {
if (attempts.length === 0) {
return "unknown";
}
const abortedAttempts = attempts.filter(isAbortLikeFallbackAttempt);
if (abortedAttempts.length === 0) {
return attempts.map(formatCapabilityFailureAttempt).join(" | ");
}
if (abortedAttempts.length === attempts.length) {
return `${abortedAttempts.length} fallback(s) aborted after the request was cancelled or timed out: ${abortedAttempts.map(formatCapabilityAttemptRef).join(", ")}`;
}
const primaryFailures = attempts.filter((attempt) => !isAbortLikeFallbackAttempt(attempt));
return [
primaryFailures.map(formatCapabilityFailureAttempt).join(" | "),
`${abortedAttempts.length} fallback(s) aborted after the request was cancelled or timed out: ${abortedAttempts.map(formatCapabilityAttemptRef).join(", ")}`,
].join(" | ");
}
function formatCapabilityFailureAttempt(attempt: FallbackAttempt): string {
return `${formatCapabilityAttemptRef(attempt)}: ${attempt.error}`;
}
function formatCapabilityAttemptRef(attempt: FallbackAttempt): string {
return `${attempt.provider}/${attempt.model}`;
}
function isAbortLikeFallbackAttempt(attempt: FallbackAttempt): boolean {
const message = attempt.error.trim().toLowerCase();
return (
message === "this operation was aborted" ||
message === "operation was aborted" ||
message.includes("operation was aborted") ||
message.includes("request was aborted")
);
}
export function buildNoCapabilityModelConfiguredMessage(params: {
capabilityLabel: string;
modelConfigKey: string;