diff --git a/CHANGELOG.md b/CHANGELOG.md index b80961bdd53..2e372d2a3dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai - Providers/OpenAI: resolve `keychain::` `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. diff --git a/docs/tools/music-generation.md b/docs/tools/music-generation.md index 10ac1ae7c85..5f5ece2351a 100644 --- a/docs/tools/music-generation.md +++ b/docs/tools/music-generation.md @@ -157,7 +157,7 @@ Direct generation example: Output format hint when the provider supports it. Output filename hint. -Optional provider request timeout in milliseconds. +Optional provider request timeout in milliseconds. Values below 10000ms are raised to 10000ms and reported in the tool result. Not all providers support all parameters. OpenClaw still validates hard diff --git a/src/agents/tools/music-generate-tool.test.ts b/src/agents/tools/music-generate-tool.test.ts index a70d0c1fcb4..9a449066685 100644 --- a/src/agents/tools/music-generate-tool.test.ts +++ b/src/agents/tools/music-generate-tool.test.ts @@ -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:/), diff --git a/src/agents/tools/music-generate-tool.ts b/src/agents/tools/music-generate-tool.ts index d064d50a1c8..4d49fbd0435 100644 --- a/src/agents/tools/music-generate-tool.ts +++ b/src/agents/tools/music-generate-tool.ts @@ -70,6 +70,7 @@ const log = createSubsystemLogger("agents/tools/music-generate"); const MAX_INPUT_IMAGES = 10; const SUPPORTED_OUTPUT_FORMATS = new Set(["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; +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) { queueMicrotask(() => { void work().catch((error) => { @@ -369,6 +407,7 @@ async function executeMusicGenerationJob(params: { loadedReferenceImages: LoadedReferenceImage[]; taskHandle?: MusicGenerationTaskHandle | null; timeoutMs?: number; + timeoutNormalization?: MusicGenerationTimeoutNormalization; }): Promise { 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, diff --git a/src/media-generation/runtime-shared.test.ts b/src/media-generation/runtime-shared.test.ts index 7d5a03aa8da..63d1c52c9c9 100644 --- a/src/media-generation/runtime-shared.test.ts +++ b/src/media-generation/runtime-shared.test.ts @@ -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", + ); + }); +}); diff --git a/src/media-generation/runtime-shared.ts b/src/media-generation/runtime-shared.ts index 41ac8e711b1..5af8fa01f02 100644 --- a/src/media-generation/runtime-shared.ts +++ b/src/media-generation/runtime-shared.ts @@ -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;