mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 08:10:44 +00:00
fix: normalize music generation timeouts
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:/),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user