diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts new file mode 100644 index 00000000000..a11c6295c01 --- /dev/null +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from "vitest"; + +const videoGenerationTaskStatusMocks = vi.hoisted(() => ({ + buildActiveVideoGenerationTaskPromptContextForSession: vi.fn(), +})); + +vi.mock("../../video-generation-task-status.js", () => videoGenerationTaskStatusMocks); + +import { resolveAttemptPrependSystemContext } from "./attempt.prompt-helpers.js"; + +describe("resolveAttemptPrependSystemContext", () => { + it("prepends active video task guidance ahead of hook system context", () => { + videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue( + "Active task hint", + ); + + const result = resolveAttemptPrependSystemContext({ + sessionKey: "agent:main:discord:direct:123", + trigger: "user", + hookPrependSystemContext: "Hook system context", + }); + + expect( + videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession, + ).toHaveBeenCalledWith("agent:main:discord:direct:123"); + expect(result).toBe("Active task hint\n\nHook system context"); + }); + + it("skips active video task guidance for non-user triggers", () => { + videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReset(); + videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession.mockReturnValue( + "Should not be used", + ); + + const result = resolveAttemptPrependSystemContext({ + sessionKey: "agent:main:discord:direct:123", + trigger: "heartbeat", + hookPrependSystemContext: "Hook system context", + }); + + expect( + videoGenerationTaskStatusMocks.buildActiveVideoGenerationTaskPromptContextForSession, + ).not.toHaveBeenCalled(); + expect(result).toBe("Hook system context"); + }); +}); diff --git a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts index caf8a7afdb4..14a636fb536 100644 --- a/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts +++ b/src/agents/pi-embedded-runner/run/attempt.prompt-helpers.ts @@ -8,6 +8,7 @@ import { isCronSessionKey, isSubagentSessionKey } from "../../../routing/session import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { prependSystemPromptAdditionAfterCacheBoundary } from "../../system-prompt-cache-boundary.js"; import { resolveEffectiveToolFsWorkspaceOnly } from "../../tool-fs-policy.js"; +import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js"; import type { CompactEmbeddedPiSessionParams } from "../compact.js"; import { buildEmbeddedCompactionRuntimeContext } from "../compaction-runtime-context.js"; import { log } from "../logger.js"; @@ -119,6 +120,18 @@ export function prependSystemPromptAddition(params: { return prependSystemPromptAdditionAfterCacheBoundary(params); } +export function resolveAttemptPrependSystemContext(params: { + sessionKey?: string; + trigger?: EmbeddedRunAttemptParams["trigger"]; + hookPrependSystemContext?: string; +}): string | undefined { + const activeVideoTaskPromptContext = + params.trigger === "user" || params.trigger === "manual" + ? buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey) + : undefined; + return joinPresentTextSegments([activeVideoTaskPromptContext, params.hookPrependSystemContext]); +} + /** Build runtime context passed into context-engine afterTurn hooks. */ export function buildAfterTurnRuntimeContext(params: { attempt: Pick< diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 7d9eec41eb0..31cc5d8ffe9 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -24,7 +24,6 @@ import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; import { resolveToolCallArgumentsEncoding } from "../../../plugins/provider-model-compat.js"; import { resolveProviderSystemPromptContribution } from "../../../plugins/provider-runtime.js"; import { isSubagentSessionKey } from "../../../routing/session-key.js"; -import { joinPresentTextSegments } from "../../../shared/text/join-segments.js"; import { buildTtsSystemPromptHint } from "../../../tts/tts.js"; import { resolveUserPath } from "../../../utils.js"; import { normalizeMessageChannel } from "../../../utils/message-channel.js"; @@ -94,7 +93,6 @@ import { buildSystemPromptParams } from "../../system-prompt-params.js"; import { buildSystemPromptReport } from "../../system-prompt-report.js"; import { sanitizeToolCallIdsForCloudCodeAssist } from "../../tool-call-id.js"; import { resolveTranscriptPolicy } from "../../transcript-policy.js"; -import { buildActiveVideoGenerationTaskPromptContextForSession } from "../../video-generation-task-status.js"; import { DEFAULT_BOOTSTRAP_FILENAME } from "../../workspace.js"; import { isRunnerAbortError } from "../abort.js"; import { isCacheTtlEligibleProvider } from "../cache-ttl.js"; @@ -155,6 +153,7 @@ import { buildAfterTurnRuntimeContext, prependSystemPromptAddition, resolveAttemptFsWorkspaceOnly, + resolveAttemptPrependSystemContext, resolvePromptBuildHookResult, resolvePromptModeForSession, shouldWarnOnOrphanedUserRepair, @@ -210,6 +209,7 @@ export { buildAfterTurnRuntimeContext, prependSystemPromptAddition, resolveAttemptFsWorkspaceOnly, + resolveAttemptPrependSystemContext, resolvePromptBuildHookResult, resolvePromptModeForSession, shouldWarnOnOrphanedUserRepair, @@ -1523,10 +1523,6 @@ export async function runEmbeddedAttempt( hookRunner, legacyBeforeAgentStartResult: params.legacyBeforeAgentStartResult, }); - const activeVideoTaskPromptContext = - params.trigger === "user" || params.trigger === "manual" - ? buildActiveVideoGenerationTaskPromptContextForSession(params.sessionKey) - : undefined; { if (hookResult?.prependContext) { effectivePrompt = `${hookResult.prependContext}\n\n${effectivePrompt}`; @@ -1543,10 +1539,11 @@ export async function runEmbeddedAttempt( } const prependedOrAppendedSystemPrompt = composeSystemPromptWithHookContext({ baseSystemPrompt: systemPromptText, - prependSystemContext: joinPresentTextSegments([ - activeVideoTaskPromptContext, - hookResult?.prependSystemContext, - ]), + prependSystemContext: resolveAttemptPrependSystemContext({ + sessionKey: params.sessionKey, + trigger: params.trigger, + hookPrependSystemContext: hookResult?.prependSystemContext, + }), appendSystemContext: hookResult?.appendSystemContext, }); if (prependedOrAppendedSystemPrompt) { diff --git a/src/agents/session-async-task-status.ts b/src/agents/session-async-task-status.ts new file mode 100644 index 00000000000..4d7b1ab6ee8 --- /dev/null +++ b/src/agents/session-async-task-status.ts @@ -0,0 +1,61 @@ +import { listTasksForOwnerKey } from "../tasks/runtime-internal.js"; +import type { TaskRecord, TaskRuntime, TaskStatus } from "../tasks/task-registry.types.js"; + +const DEFAULT_ACTIVE_STATUSES = new Set(["queued", "running"]); + +export function findActiveSessionTask(params: { + sessionKey?: string; + runtime?: TaskRuntime; + taskKind?: string; + statuses?: ReadonlySet; + sourceIdPrefix?: string; +}): TaskRecord | null { + const normalizedSessionKey = params.sessionKey?.trim(); + if (!normalizedSessionKey) { + return null; + } + const statuses = params.statuses ?? DEFAULT_ACTIVE_STATUSES; + const taskKind = params.taskKind?.trim(); + const sourceIdPrefix = params.sourceIdPrefix?.trim(); + const matches = listTasksForOwnerKey(normalizedSessionKey).filter((task) => { + if (task.scopeKind !== "session") { + return false; + } + if (params.runtime && task.runtime !== params.runtime) { + return false; + } + if (!statuses.has(task.status)) { + return false; + } + if (taskKind && task.taskKind !== taskKind) { + return false; + } + if (sourceIdPrefix) { + const sourceId = task.sourceId?.trim() ?? ""; + if (sourceId !== sourceIdPrefix && !sourceId.startsWith(`${sourceIdPrefix}:`)) { + return false; + } + } + return true; + }); + if (matches.length === 0) { + return null; + } + return matches.find((task) => task.status === "running") ?? matches[0] ?? null; +} + +export function buildSessionAsyncTaskStatusDetails(task: TaskRecord): Record { + return { + async: true, + active: true, + existingTask: true, + status: task.status, + task: { + taskId: task.taskId, + ...(task.runId ? { runId: task.runId } : {}), + }, + ...(task.taskKind ? { taskKind: task.taskKind } : {}), + ...(task.progressSummary ? { progressSummary: task.progressSummary } : {}), + ...(task.sourceId ? { sourceId: task.sourceId } : {}), + }; +} diff --git a/src/agents/tools/video-generate-background.test.ts b/src/agents/tools/video-generate-background.test.ts index ad354b67018..ad201196a70 100644 --- a/src/agents/tools/video-generate-background.test.ts +++ b/src/agents/tools/video-generate-background.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js"; import { createVideoGenerationTaskRun, recordVideoGenerationTaskProgress, @@ -48,6 +49,7 @@ describe("video generate background helpers", () => { }); expect(taskExecutorMocks.createRunningTaskRun).toHaveBeenCalledWith( expect.objectContaining({ + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:openai", progressSummary: "Queued video generation", }), diff --git a/src/agents/tools/video-generate-background.ts b/src/agents/tools/video-generate-background.ts index c79aa37eeda..3628afea1dd 100644 --- a/src/agents/tools/video-generate-background.ts +++ b/src/agents/tools/video-generate-background.ts @@ -10,6 +10,7 @@ import type { DeliveryContext } from "../../utils/delivery-context.js"; import { INTERNAL_MESSAGE_CHANNEL } from "../../utils/message-channel.js"; import { formatAgentInternalEventsForPrompt, type AgentInternalEvent } from "../internal-events.js"; import { deliverSubagentAnnouncement } from "../subagent-announce-delivery.js"; +import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js"; const log = createSubsystemLogger("agents/tools/video-generate-background"); @@ -35,6 +36,7 @@ export function createVideoGenerationTaskRun(params: { try { const task = createRunningTaskRun({ runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: params.providerId ? `video_generate:${params.providerId}` : "video_generate", requesterSessionKey: sessionKey, ownerKey: sessionKey, diff --git a/src/agents/tools/video-generate-tool.actions.ts b/src/agents/tools/video-generate-tool.actions.ts new file mode 100644 index 00000000000..e3b060dd701 --- /dev/null +++ b/src/agents/tools/video-generate-tool.actions.ts @@ -0,0 +1,136 @@ +import type { OpenClawConfig } from "../../config/config.js"; +import { getProviderEnvVars } from "../../secrets/provider-env-vars.js"; +import { listRuntimeVideoGenerationProviders } from "../../video-generation/runtime.js"; +import { + buildVideoGenerationTaskStatusDetails, + buildVideoGenerationTaskStatusText, + findActiveVideoGenerationTaskForSession, +} from "../video-generation-task-status.js"; + +type VideoGenerateActionResult = { + content: Array<{ type: "text"; text: string }>; + details: Record; +}; + +function getVideoGenerationProviderAuthEnvVars(providerId: string): string[] { + return getProviderEnvVars(providerId); +} + +export function createVideoGenerateListActionResult( + config?: OpenClawConfig, +): VideoGenerateActionResult { + const providers = listRuntimeVideoGenerationProviders({ config }); + if (providers.length === 0) { + return { + content: [{ type: "text", text: "No video-generation providers are registered." }], + details: { providers: [] }, + }; + } + const lines = providers.map((provider) => { + const authHints = getVideoGenerationProviderAuthEnvVars(provider.id); + const capabilities = [ + provider.capabilities.maxVideos ? `maxVideos=${provider.capabilities.maxVideos}` : null, + provider.capabilities.maxInputImages + ? `maxInputImages=${provider.capabilities.maxInputImages}` + : null, + provider.capabilities.maxInputVideos + ? `maxInputVideos=${provider.capabilities.maxInputVideos}` + : null, + provider.capabilities.maxDurationSeconds + ? `maxDurationSeconds=${provider.capabilities.maxDurationSeconds}` + : null, + provider.capabilities.supportedDurationSeconds?.length + ? `supportedDurationSeconds=${provider.capabilities.supportedDurationSeconds.join("/")}` + : null, + provider.capabilities.supportedDurationSecondsByModel && + Object.keys(provider.capabilities.supportedDurationSecondsByModel).length > 0 + ? `supportedDurationSecondsByModel=${Object.entries( + provider.capabilities.supportedDurationSecondsByModel, + ) + .map(([modelId, durations]) => `${modelId}:${durations.join("/")}`) + .join("; ")}` + : null, + provider.capabilities.supportsResolution ? "resolution" : null, + provider.capabilities.supportsAspectRatio ? "aspectRatio" : null, + provider.capabilities.supportsSize ? "size" : null, + provider.capabilities.supportsAudio ? "audio" : null, + provider.capabilities.supportsWatermark ? "watermark" : null, + ] + .filter((entry): entry is string => Boolean(entry)) + .join(", "); + return [ + `${provider.id}: default=${provider.defaultModel ?? "none"}`, + provider.models?.length ? `models=${provider.models.join(", ")}` : null, + capabilities ? `capabilities=${capabilities}` : null, + authHints.length > 0 ? `auth=${authHints.join(" / ")}` : null, + ] + .filter((entry): entry is string => Boolean(entry)) + .join(" | "); + }); + return { + content: [{ type: "text", text: lines.join("\n") }], + details: { + providers: providers.map((provider) => ({ + id: provider.id, + defaultModel: provider.defaultModel, + models: provider.models ?? [], + authEnvVars: getVideoGenerationProviderAuthEnvVars(provider.id), + capabilities: provider.capabilities, + })), + }, + }; +} + +export function createVideoGenerateStatusActionResult( + sessionKey?: string, +): VideoGenerateActionResult { + const activeTask = findActiveVideoGenerationTaskForSession(sessionKey); + if (!activeTask) { + return { + content: [ + { + type: "text", + text: "No active video generation task is currently running for this session.", + }, + ], + details: { + action: "status", + active: false, + }, + }; + } + return { + content: [ + { + type: "text", + text: buildVideoGenerationTaskStatusText(activeTask), + }, + ], + details: { + action: "status", + ...buildVideoGenerationTaskStatusDetails(activeTask), + }, + }; +} + +export function createVideoGenerateDuplicateGuardResult( + sessionKey?: string, +): VideoGenerateActionResult | null { + const activeTask = findActiveVideoGenerationTaskForSession(sessionKey); + if (!activeTask) { + return null; + } + return { + content: [ + { + type: "text", + text: buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true }), + }, + ], + details: { + action: "status", + duplicateGuard: true, + ...buildVideoGenerationTaskStatusDetails(activeTask), + }, + }; +} diff --git a/src/agents/tools/video-generate-tool.status.test.ts b/src/agents/tools/video-generate-tool.status.test.ts new file mode 100644 index 00000000000..86e38ed0878 --- /dev/null +++ b/src/agents/tools/video-generate-tool.status.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as videoGenerationRuntime from "../../video-generation/runtime.js"; +import { VIDEO_GENERATION_TASK_KIND } from "../video-generation-task-status.js"; +import { + createVideoGenerateDuplicateGuardResult, + createVideoGenerateStatusActionResult, +} from "./video-generate-tool.actions.js"; + +const taskRuntimeInternalMocks = vi.hoisted(() => ({ + listTasksForOwnerKey: vi.fn(), +})); + +vi.mock("../../tasks/runtime-internal.js", () => taskRuntimeInternalMocks); + +describe("createVideoGenerateTool status actions", () => { + beforeEach(() => { + vi.restoreAllMocks(); + vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]); + taskRuntimeInternalMocks.listTasksForOwnerKey.mockReset(); + taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([]); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("returns active task status instead of starting a duplicate generation", async () => { + taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([ + { + taskId: "task-active", + runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, + sourceId: "video_generate:openai", + requesterSessionKey: "agent:main:discord:direct:123", + ownerKey: "agent:main:discord:direct:123", + scopeKind: "session", + runId: "tool:video_generate:active", + task: "friendly lobster surfing", + status: "running", + deliveryStatus: "not_applicable", + notifyPolicy: "silent", + createdAt: Date.now(), + progressSummary: "Generating video", + }, + ]); + + const result = createVideoGenerateDuplicateGuardResult("agent:main:discord:direct:123"); + const text = (result?.content?.[0] as { text: string } | undefined)?.text ?? ""; + + expect(result).not.toBeNull(); + expect(text).toContain("Video generation task task-active is already running with openai."); + expect(text).toContain("Do not call video_generate again for this request."); + expect(result?.details).toMatchObject({ + action: "status", + duplicateGuard: true, + active: true, + existingTask: true, + status: "running", + taskKind: VIDEO_GENERATION_TASK_KIND, + provider: "openai", + task: { + taskId: "task-active", + runId: "tool:video_generate:active", + }, + progressSummary: "Generating video", + }); + }); + + it("reports active task status when action=status is requested", async () => { + taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([ + { + taskId: "task-active", + runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, + sourceId: "video_generate:google", + requesterSessionKey: "agent:main:discord:direct:123", + ownerKey: "agent:main:discord:direct:123", + scopeKind: "session", + runId: "tool:video_generate:active", + task: "friendly lobster surfing", + status: "queued", + deliveryStatus: "not_applicable", + notifyPolicy: "silent", + createdAt: Date.now(), + progressSummary: "Queued video generation", + }, + ]); + + const result = createVideoGenerateStatusActionResult("agent:main:discord:direct:123"); + const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; + + expect(text).toContain("Video generation task task-active is already queued with google."); + expect(result.details).toMatchObject({ + action: "status", + active: true, + existingTask: true, + status: "queued", + taskKind: VIDEO_GENERATION_TASK_KIND, + provider: "google", + task: { + taskId: "task-active", + }, + progressSummary: "Queued video generation", + }); + }); +}); diff --git a/src/agents/tools/video-generate-tool.test.ts b/src/agents/tools/video-generate-tool.test.ts index 9608d1535c2..7a245b9e343 100644 --- a/src/agents/tools/video-generate-tool.test.ts +++ b/src/agents/tools/video-generate-tool.test.ts @@ -222,112 +222,6 @@ describe("createVideoGenerateTool", () => { ); }); - it("returns active task status instead of starting a duplicate generation", async () => { - const generateSpy = vi.spyOn(videoGenerationRuntime, "generateVideo"); - taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([ - { - taskId: "task-active", - runtime: "cli", - sourceId: "video_generate:openai", - requesterSessionKey: "agent:main:discord:direct:123", - ownerKey: "agent:main:discord:direct:123", - scopeKind: "session", - runId: "tool:video_generate:active", - task: "friendly lobster surfing", - status: "running", - deliveryStatus: "not_applicable", - notifyPolicy: "silent", - createdAt: Date.now(), - progressSummary: "Generating video", - }, - ]); - - const tool = createVideoGenerateTool({ - config: asConfig({ - agents: { - defaults: { - videoGenerationModel: { primary: "openai/sora-2" }, - }, - }, - }), - agentSessionKey: "agent:main:discord:direct:123", - }); - if (!tool) { - throw new Error("expected video_generate tool"); - } - - const result = await tool.execute("call-dup", { prompt: "friendly lobster surfing" }); - const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; - - expect(text).toContain("Video generation task task-active is already running with openai."); - expect(text).toContain("Do not call video_generate again for this request."); - expect(result.details).toMatchObject({ - action: "status", - duplicateGuard: true, - active: true, - existingTask: true, - status: "running", - provider: "openai", - task: { - taskId: "task-active", - runId: "tool:video_generate:active", - }, - progressSummary: "Generating video", - }); - expect(taskExecutorMocks.createRunningTaskRun).not.toHaveBeenCalled(); - expect(generateSpy).not.toHaveBeenCalled(); - }); - - it("reports active task status when action=status is requested", async () => { - taskRuntimeInternalMocks.listTasksForOwnerKey.mockReturnValue([ - { - taskId: "task-active", - runtime: "cli", - sourceId: "video_generate:google", - requesterSessionKey: "agent:main:discord:direct:123", - ownerKey: "agent:main:discord:direct:123", - scopeKind: "session", - runId: "tool:video_generate:active", - task: "friendly lobster surfing", - status: "queued", - deliveryStatus: "not_applicable", - notifyPolicy: "silent", - createdAt: Date.now(), - progressSummary: "Queued video generation", - }, - ]); - - const tool = createVideoGenerateTool({ - config: asConfig({ - agents: { - defaults: { - videoGenerationModel: { primary: "google/veo-3.1-fast-generate-preview" }, - }, - }, - }), - agentSessionKey: "agent:main:discord:direct:123", - }); - if (!tool) { - throw new Error("expected video_generate tool"); - } - - const result = await tool.execute("call-status", { action: "status" }); - const text = (result.content?.[0] as { text: string } | undefined)?.text ?? ""; - - expect(text).toContain("Video generation task task-active is already queued with google."); - expect(result.details).toMatchObject({ - action: "status", - active: true, - existingTask: true, - status: "queued", - provider: "google", - task: { - taskId: "task-active", - }, - progressSummary: "Queued video generation", - }); - }); - it("surfaces provider generation failures inline when there is no detached session", async () => { vi.spyOn(videoGenerationRuntime, "generateVideo").mockRejectedValue(new Error("queue boom")); diff --git a/src/agents/tools/video-generate-tool.ts b/src/agents/tools/video-generate-tool.ts index 9a4a6ca3def..0a71ad13c53 100644 --- a/src/agents/tools/video-generate-tool.ts +++ b/src/agents/tools/video-generate-tool.ts @@ -5,7 +5,6 @@ import { createSubsystemLogger } from "../../logging/subsystem.js"; import { saveMediaBuffer } from "../../media/store.js"; import { loadWebMedia } from "../../media/web-media.js"; import { readSnakeCaseParamRaw } from "../../param-key.js"; -import { getProviderEnvVars } from "../../secrets/provider-env-vars.js"; import { resolveUserPath } from "../../utils.js"; import type { DeliveryContext } from "../../utils/delivery-context.js"; import { resolveVideoGenerationSupportedDurations } from "../../video-generation/duration-support.js"; @@ -21,11 +20,6 @@ import type { VideoGenerationSourceAsset, } from "../../video-generation/types.js"; import { normalizeProviderId } from "../provider-id.js"; -import { - buildVideoGenerationTaskStatusDetails, - buildVideoGenerationTaskStatusText, - findActiveVideoGenerationTaskForSession, -} from "../video-generation-task-status.js"; import { ToolInputError, readNumberParam, @@ -60,6 +54,11 @@ import { type VideoGenerationTaskHandle, wakeVideoGenerationTaskCompletion, } from "./video-generate-background.js"; +import { + createVideoGenerateDuplicateGuardResult, + createVideoGenerateListActionResult, + createVideoGenerateStatusActionResult, +} from "./video-generate-tool.actions.js"; const log = createSubsystemLogger("agents/tools/video-generate"); const MAX_INPUT_IMAGES = 5; @@ -149,10 +148,6 @@ const VideoGenerateToolSchema = Type.Object({ ), }); -function getVideoGenerationProviderAuthEnvVars(providerId: string): string[] { - return getProviderEnvVars(providerId); -} - function resolveVideoGenerationModelCandidates(params: { cfg?: OpenClawConfig; agentDir?: string; @@ -755,113 +750,18 @@ export function createVideoGenerateTool(options?: { applyVideoGenerationModelConfigDefaults(cfg, videoGenerationModelConfig) ?? cfg; if (action === "list") { - const providers = listRuntimeVideoGenerationProviders({ config: effectiveCfg }); - if (providers.length === 0) { - return { - content: [{ type: "text", text: "No video-generation providers are registered." }], - details: { providers: [] }, - }; - } - const lines = providers.map((provider) => { - const authHints = getVideoGenerationProviderAuthEnvVars(provider.id); - const capabilities = [ - provider.capabilities.maxVideos ? `maxVideos=${provider.capabilities.maxVideos}` : null, - provider.capabilities.maxInputImages - ? `maxInputImages=${provider.capabilities.maxInputImages}` - : null, - provider.capabilities.maxInputVideos - ? `maxInputVideos=${provider.capabilities.maxInputVideos}` - : null, - provider.capabilities.maxDurationSeconds - ? `maxDurationSeconds=${provider.capabilities.maxDurationSeconds}` - : null, - provider.capabilities.supportedDurationSeconds?.length - ? `supportedDurationSeconds=${provider.capabilities.supportedDurationSeconds.join("/")}` - : null, - provider.capabilities.supportedDurationSecondsByModel && - Object.keys(provider.capabilities.supportedDurationSecondsByModel).length > 0 - ? `supportedDurationSecondsByModel=${Object.entries( - provider.capabilities.supportedDurationSecondsByModel, - ) - .map(([modelId, durations]) => `${modelId}:${durations.join("/")}`) - .join("; ")}` - : null, - provider.capabilities.supportsResolution ? "resolution" : null, - provider.capabilities.supportsAspectRatio ? "aspectRatio" : null, - provider.capabilities.supportsSize ? "size" : null, - provider.capabilities.supportsAudio ? "audio" : null, - provider.capabilities.supportsWatermark ? "watermark" : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join(", "); - return [ - `${provider.id}: default=${provider.defaultModel ?? "none"}`, - provider.models?.length ? `models=${provider.models.join(", ")}` : null, - capabilities ? `capabilities=${capabilities}` : null, - authHints.length > 0 ? `auth=${authHints.join(" / ")}` : null, - ] - .filter((entry): entry is string => Boolean(entry)) - .join(" | "); - }); - return { - content: [{ type: "text", text: lines.join("\n") }], - details: { - providers: providers.map((provider) => ({ - id: provider.id, - defaultModel: provider.defaultModel, - models: provider.models ?? [], - authEnvVars: getVideoGenerationProviderAuthEnvVars(provider.id), - capabilities: provider.capabilities, - })), - }, - }; + return createVideoGenerateListActionResult(effectiveCfg); } if (action === "status") { - const activeTask = findActiveVideoGenerationTaskForSession(options?.agentSessionKey); - if (!activeTask) { - return { - content: [ - { - type: "text", - text: "No active video generation task is currently running for this session.", - }, - ], - details: { - action: "status", - active: false, - }, - }; - } - return { - content: [ - { - type: "text", - text: buildVideoGenerationTaskStatusText(activeTask), - }, - ], - details: { - action: "status", - ...buildVideoGenerationTaskStatusDetails(activeTask), - }, - }; + return createVideoGenerateStatusActionResult(options?.agentSessionKey); } - const activeTask = findActiveVideoGenerationTaskForSession(options?.agentSessionKey); - if (activeTask) { - return { - content: [ - { - type: "text", - text: buildVideoGenerationTaskStatusText(activeTask, { duplicateGuard: true }), - }, - ], - details: { - action: "status", - duplicateGuard: true, - ...buildVideoGenerationTaskStatusDetails(activeTask), - }, - }; + const duplicateGuardResult = createVideoGenerateDuplicateGuardResult( + options?.agentSessionKey, + ); + if (duplicateGuardResult) { + return duplicateGuardResult; } const prompt = readStringParam(args, "prompt", { required: true }); diff --git a/src/agents/video-generation-task-status.test.ts b/src/agents/video-generation-task-status.test.ts index 3fda4eefba6..d2bfe3ac37b 100644 --- a/src/agents/video-generation-task-status.test.ts +++ b/src/agents/video-generation-task-status.test.ts @@ -6,6 +6,7 @@ import { findActiveVideoGenerationTaskForSession, getVideoGenerationTaskProviderId, isActiveVideoGenerationTask, + VIDEO_GENERATION_TASK_KIND, } from "./video-generation-task-status.js"; const taskRuntimeInternalMocks = vi.hoisted(() => ({ @@ -25,6 +26,7 @@ describe("video generation task status", () => { isActiveVideoGenerationTask({ taskId: "task-1", runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:openai", requesterSessionKey: "agent:main", ownerKey: "agent:main", @@ -40,6 +42,7 @@ describe("video generation task status", () => { isActiveVideoGenerationTask({ taskId: "task-2", runtime: "cron", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:openai", requesterSessionKey: "agent:main", ownerKey: "agent:main", @@ -58,6 +61,7 @@ describe("video generation task status", () => { { taskId: "task-queued", runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:google", requesterSessionKey: "agent:main", ownerKey: "agent:main", @@ -71,6 +75,7 @@ describe("video generation task status", () => { { taskId: "task-running", runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:openai", requesterSessionKey: "agent:main", ownerKey: "agent:main", @@ -95,6 +100,7 @@ describe("video generation task status", () => { active: true, existingTask: true, status: "running", + taskKind: VIDEO_GENERATION_TASK_KIND, provider: "openai", progressSummary: "Generating video", }); @@ -105,6 +111,7 @@ describe("video generation task status", () => { { taskId: "task-running", runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, sourceId: "video_generate:openai", requesterSessionKey: "agent:main", ownerKey: "agent:main", diff --git a/src/agents/video-generation-task-status.ts b/src/agents/video-generation-task-status.ts index 106435336af..c4566ccbda8 100644 --- a/src/agents/video-generation-task-status.ts +++ b/src/agents/video-generation-task-status.ts @@ -1,21 +1,18 @@ -import { listTasksForOwnerKey } from "../tasks/runtime-internal.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; +import { + buildSessionAsyncTaskStatusDetails, + findActiveSessionTask, +} from "./session-async-task-status.js"; -const ACTIVE_VIDEO_GENERATION_STATUSES = new Set(["queued", "running"]); +export const VIDEO_GENERATION_TASK_KIND = "video_generation"; const VIDEO_GENERATION_SOURCE_PREFIX = "video_generate"; -function isActiveStatus(status: string): boolean { - return ACTIVE_VIDEO_GENERATION_STATUSES.has(status); -} - export function isActiveVideoGenerationTask(task: TaskRecord): boolean { - const sourceId = task.sourceId?.trim() ?? ""; return ( task.runtime === "cli" && task.scopeKind === "session" && - isActiveStatus(task.status) && - (sourceId === VIDEO_GENERATION_SOURCE_PREFIX || - sourceId.startsWith(`${VIDEO_GENERATION_SOURCE_PREFIX}:`)) + task.taskKind === VIDEO_GENERATION_TASK_KIND && + (task.status === "queued" || task.status === "running") ); } @@ -29,32 +26,18 @@ export function getVideoGenerationTaskProviderId(task: TaskRecord): string | und } export function findActiveVideoGenerationTaskForSession(sessionKey?: string): TaskRecord | null { - const normalizedSessionKey = sessionKey?.trim(); - if (!normalizedSessionKey) { - return null; - } - const activeTasks = listTasksForOwnerKey(normalizedSessionKey).filter( - isActiveVideoGenerationTask, - ); - if (activeTasks.length === 0) { - return null; - } - return activeTasks.find((task) => task.status === "running") ?? activeTasks[0] ?? null; + return findActiveSessionTask({ + sessionKey, + runtime: "cli", + taskKind: VIDEO_GENERATION_TASK_KIND, + sourceIdPrefix: VIDEO_GENERATION_SOURCE_PREFIX, + }); } export function buildVideoGenerationTaskStatusDetails(task: TaskRecord): Record { const provider = getVideoGenerationTaskProviderId(task); return { - async: true, - active: true, - existingTask: true, - status: task.status, - task: { - taskId: task.taskId, - ...(task.runId ? { runId: task.runId } : {}), - }, - ...(task.progressSummary ? { progressSummary: task.progressSummary } : {}), - ...(task.sourceId ? { sourceId: task.sourceId } : {}), + ...buildSessionAsyncTaskStatusDetails(task), ...(provider ? { provider } : {}), }; } diff --git a/src/tasks/task-executor.test.ts b/src/tasks/task-executor.test.ts index 9f437684e94..e46132e3953 100644 --- a/src/tasks/task-executor.test.ts +++ b/src/tasks/task-executor.test.ts @@ -178,6 +178,33 @@ describe("task-executor", () => { }); }); + it("persists explicit task kind metadata on created runs", async () => { + await withTaskExecutorStateDir(async () => { + const created = createRunningTaskRun({ + runtime: "cli", + taskKind: "video_generation", + sourceId: "video_generate:openai", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:main", + runId: "run-executor-kind", + task: "Generate lobster video", + startedAt: 10, + deliveryStatus: "not_applicable", + }); + + expect(getTaskById(created.taskId)).toMatchObject({ + taskId: created.taskId, + taskKind: "video_generation", + sourceId: "video_generate:openai", + }); + expect(findTaskByRunId("run-executor-kind")).toMatchObject({ + taskId: created.taskId, + taskKind: "video_generation", + }); + }); + }); + it("auto-creates a one-task flow and keeps it synced with task status", async () => { await withTaskExecutorStateDir(async () => { const created = createRunningTaskRun({ diff --git a/src/tasks/task-executor.ts b/src/tasks/task-executor.ts index bd1a534fab1..6990b679a46 100644 --- a/src/tasks/task-executor.ts +++ b/src/tasks/task-executor.ts @@ -84,6 +84,7 @@ function ensureSingleTaskFlow(params: { export function createQueuedTaskRun(params: { runtime: TaskRuntime; + taskKind?: string; sourceId?: string; requesterSessionKey?: string; ownerKey?: string; @@ -116,6 +117,7 @@ export function getFlowTaskSummary(flowId: string): TaskRegistrySummary { export function createRunningTaskRun(params: { runtime: TaskRuntime; + taskKind?: string; sourceId?: string; requesterSessionKey?: string; ownerKey?: string; diff --git a/src/tasks/task-registry.ts b/src/tasks/task-registry.ts index 86d3d45d898..3f96ef6ad64 100644 --- a/src/tasks/task-registry.ts +++ b/src/tasks/task-registry.ts @@ -651,6 +651,7 @@ function findExistingTaskForCreate(params: { function mergeExistingTaskForCreate( existing: TaskRecord, params: { + taskKind?: string; requesterOrigin?: TaskDeliveryState["requesterOrigin"]; sourceId?: string; parentFlowId?: string; @@ -676,6 +677,9 @@ function mergeExistingTaskForCreate( if (params.sourceId?.trim() && !existing.sourceId?.trim()) { patch.sourceId = params.sourceId.trim(); } + if (params.taskKind?.trim() && !existing.taskKind?.trim()) { + patch.taskKind = params.taskKind.trim(); + } if (params.parentFlowId?.trim() && !existing.parentFlowId?.trim()) { assertParentFlowLinkAllowed({ ownerKey: existing.ownerKey, @@ -1357,6 +1361,7 @@ function ensureListener() { export function createTaskRecord(params: { runtime: TaskRuntime; + taskKind?: string; sourceId?: string; requesterSessionKey?: string; ownerKey?: string; @@ -1431,6 +1436,7 @@ export function createTaskRecord(params: { const record: TaskRecord = { taskId, runtime: params.runtime, + taskKind: params.taskKind?.trim() || undefined, sourceId: params.sourceId?.trim() || undefined, requesterSessionKey, ownerKey, diff --git a/src/tasks/task-registry.types.ts b/src/tasks/task-registry.types.ts index 2f5502375b3..0a770a8229f 100644 --- a/src/tasks/task-registry.types.ts +++ b/src/tasks/task-registry.types.ts @@ -53,6 +53,7 @@ export type TaskDeliveryState = { export type TaskRecord = { taskId: string; runtime: TaskRuntime; + taskKind?: string; sourceId?: string; requesterSessionKey: string; ownerKey: string;