From 641ccba793bce2a85cb823b1bceba45742cea54e Mon Sep 17 00:00:00 2001 From: Omar Shahine Date: Wed, 19 Aug 2026 21:27:09 -0700 Subject: [PATCH] fix(cli): allow agent selection for direct inference (#125143) * fix(cli): inherit inference agent options * fix(cli): route inference model auth agents * fix(cli): route video describe agent * fix(cli): route audio transcription agent * fix(cli): preserve inherited agent selectors --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> --- config/assertion-safety-baseline.txt | 8 +- docs/cli/infer.md | 10 +- src/cli/capability-cli.test.ts | 441 ++++++++++++++++++++++++++- src/cli/capability-cli/audio.ts | 17 +- src/cli/capability-cli/embedding.ts | 17 +- src/cli/capability-cli/image.ts | 33 +- src/cli/capability-cli/metadata.ts | 8 +- src/cli/capability-cli/model.ts | 49 ++- src/cli/capability-cli/shared.ts | 10 + src/cli/capability-cli/video.ts | 28 +- 10 files changed, 562 insertions(+), 59 deletions(-) diff --git a/config/assertion-safety-baseline.txt b/config/assertion-safety-baseline.txt index 4f629fe6a098..45808af3303f 100644 --- a/config/assertion-safety-baseline.txt +++ b/config/assertion-safety-baseline.txt @@ -2385,12 +2385,12 @@ src/claws/workspace.ts 2 src/cli/acp-cli.ts 7 src/cli/attach-cli.ts 3 src/cli/capability-cli/audio.ts 4 -src/cli/capability-cli/embedding.ts 4 -src/cli/capability-cli/image.ts 22 -src/cli/capability-cli/model.ts 5 +src/cli/capability-cli/embedding.ts 3 +src/cli/capability-cli/image.ts 21 +src/cli/capability-cli/model.ts 4 src/cli/capability-cli/shared.ts 6 src/cli/capability-cli/tts.ts 6 -src/cli/capability-cli/video.ts 8 +src/cli/capability-cli/video.ts 7 src/cli/capability-cli/web.ts 6 src/cli/channel-auth.ts 4 src/cli/channel-options.ts 1 diff --git a/docs/cli/infer.md b/docs/cli/infer.md index b9666d2f0938..857e872b43ea 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -111,10 +111,10 @@ A good infer-based skill maps common user intents to the right subcommand, inclu agent; explicit multi-agent fleets with no system owner must pass `--agent`. The provider catalog remains aggregate; `--agent` scopes saved-auth and per-agent selection facts. Gateway-owned TTS provider state remains Gateway-global, so `tts providers --gateway` does not accept `--agent`. -- Runner commands that resolve agent-owned model or auth state (`model run`, `image generate`, - `image edit`, `image describe`, `image describe-many`, `video generate`, `embedding create`, and - `model auth logout`) also accept `--agent `. They resolve an explicit id first, then - `agents.defaults.systemAgent.agentId`, then the sole configured agent. +- Commands that resolve agent-owned model or auth state (`model run`, `image generate`, `image edit`, + `image describe`, `image describe-many`, `audio transcribe`, `video generate`, `video describe`, + `embedding create`, and `model auth login/logout/status`) also accept `--agent `. They resolve + an explicit id first, then `agents.defaults.systemAgent.agentId`, then the sole configured agent. - Generated image and video `--output` files are staged beside the destination and replace it only after the complete buffer is written; a failed write leaves an existing destination unchanged. - Local `model run` is a lean one-shot provider completion: it resolves the configured agent model and auth but does not start a chat-agent turn, load tools, or open bundled MCP servers. - `model run --file` attaches image files (auto-detected MIME type) to the prompt; repeat `--file` for multiple images. Non-image files are rejected — use `infer audio transcribe` or `infer video describe` instead. @@ -211,6 +211,7 @@ File transcription (not realtime session management). ```bash openclaw infer audio transcribe --file ./memo.m4a --json +openclaw infer audio transcribe --agent --file ./memo.m4a --json openclaw infer audio transcribe --file ./team-sync.m4a --language en --prompt "Focus on names and action items" --json openclaw infer audio transcribe --file ./memo.m4a --model openai/whisper-1 --json ``` @@ -245,6 +246,7 @@ Generation and description. openclaw infer video generate --prompt "cinematic sunset over the ocean" --json openclaw infer video generate --prompt "slow drone shot over a forest lake" --resolution 768P --duration 6 --json openclaw infer video describe --file ./clip.mp4 --json +openclaw infer video describe --agent --file ./clip.mp4 --json openclaw infer video describe --file ./clip.mp4 --model openai/gpt-5.4-mini --json ``` diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index bca8a8b1694e..e1905fb066bf 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -22,6 +22,26 @@ function runCapability(domain: string, action: string, ...argv: string[]): Promi return runCap("capability", domain, action, ...argv); } +function runCapabilityWithParentAgent( + domain: string, + action: string, + agent: string, + ...argv: string[] +): Promise { + return runCap("capability", domain, "--agent", agent, action, ...argv); +} + +function runModelAuthWithAgent( + position: "parent" | "leaf", + action: "login" | "logout" | "status", + agent: string, + ...argv: string[] +): Promise { + return position === "parent" + ? runCap("capability", "model", "--agent", agent, "auth", action, ...argv) + : runCap("capability", "model", "auth", action, "--agent", agent, ...argv); +} + function primeOpenAiAuthProfile(mode: "api-key" | "token" = "api-key"): void { mocks.resolveApiKeyForProviderCore.mockResolvedValueOnce({ apiKey: mode === "token" ? "profile-openai-token" : "profile-openai-key", @@ -130,6 +150,7 @@ const mocks = vi.hoisted(() => ({ generateImage: vi.fn(), listRuntimeImageGenerationProviders: vi.fn(() => []), generateVideo: vi.fn(), + describeVideoFile: vi.fn(), listRuntimeVideoGenerationProviders: vi.fn(() => []), transcribeAudioFile: vi.fn(async () => ({ text: "meeting notes" })), textToSpeech: vi.fn(async () => ({ @@ -240,6 +261,7 @@ const mocks = vi.hoisted(() => ({ runtime.log(JSON.stringify({ ok: true, providers: [{ id: "openai" }] })); }, ), + modelsAuthLoginCommand: vi.fn(), })); vi.mock("../runtime.js", () => ({ @@ -338,7 +360,7 @@ vi.mock("../agents/memory-search.js", () => ({ })); vi.mock("../commands/models/auth.js", () => ({ - modelsAuthLoginCommand: vi.fn(), + modelsAuthLoginCommand: mocks.modelsAuthLoginCommand, })); vi.mock("../commands/models/list.status-command.js", () => ({ @@ -368,7 +390,8 @@ vi.mock("../media-understanding/runtime.js", () => ({ mocks.describePreparedImageWithModel as typeof import("../media-understanding/runtime.js").describePreparedImageWithModel, describeImageFileWithModel: mocks.describeImageFileWithModel as typeof import("../media-understanding/runtime.js").describeImageFileWithModel, - describeVideoFile: vi.fn(), + describeVideoFile: + mocks.describeVideoFile as typeof import("../media-understanding/runtime.js").describeVideoFile, transcribeAudioFile: mocks.transcribeAudioFile as typeof import("../media-understanding/runtime.js").transcribeAudioFile, })); @@ -606,6 +629,11 @@ describe("capability cli", () => { mocks.generateImage.mockReset(); mocks.listRuntimeImageGenerationProviders.mockReset().mockReturnValue([]); mocks.generateVideo.mockReset(); + mocks.describeVideoFile.mockReset().mockResolvedValue({ + text: "friendly lobster", + provider: "openai", + model: "gpt-4.1-mini", + } as never); mocks.listRuntimeVideoGenerationProviders.mockReset().mockReturnValue([]); mocks.transcribeAudioFile.mockClear(); mocks.textToSpeech.mockClear(); @@ -645,6 +673,7 @@ describe("capability cli", () => { diagnostics: [], })); mocks.modelsStatusCommand.mockClear(); + mocks.modelsAuthLoginCommand.mockClear(); }); async function runModelRunWithModel(model: string, transport: "local" | "gateway") { @@ -743,6 +772,11 @@ describe("capability cli", () => { return calls[0]?.[0]; } + function firstVideoDescriptionCall() { + const calls = mocks.describeVideoFile.mock.calls as unknown as Array<[Record]>; + return calls[0]?.[0]; + } + function primeGeneratedVideoUrl(url: string): void { mocks.generateVideo.mockResolvedValue({ provider: "vydra", @@ -763,7 +797,15 @@ describe("capability cli", () => { function firstAudioTranscriptionCall() { const calls = mocks.transcribeAudioFile.mock.calls as unknown as Array< - [{ cfg?: unknown; filePath?: string; language?: unknown; prompt?: unknown }] + [ + { + agentDir?: string; + cfg?: unknown; + filePath?: string; + language?: unknown; + prompt?: unknown; + }, + ] >; return calls[0]?.[0]; } @@ -1813,6 +1855,332 @@ describe("capability cli", () => { expect(firstImageGenerationCall()?.timeoutMs).toBe(180000); }); + it("keeps image generation owner selection explicit in multi-agent fleets", async () => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await expect( + runCapability("image", "generate", "--prompt", "friendly lobster", "--json"), + ).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains("Multiple agents are configured"); + expect(mocks.generateImage).not.toHaveBeenCalled(); + }); + + it.each([ + { agent: "", message: "--agent must not be blank" }, + { agent: "retired", message: 'Unknown agent id "retired"' }, + ])( + "rejects invalid image generation agent '$agent' before dispatch", + async ({ agent, message }) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await expect( + runCapability( + "image", + "generate", + "--agent", + agent, + "--prompt", + "friendly lobster", + "--json", + ), + ).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains(message); + expect(mocks.generateImage).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + name: "model run", + run: () => runCapability("model", "run", "--agent", "beta", "--prompt", "hello", "--json"), + selectedAgent: () => firstPreparedModelParams()?.agentId, + expectedAgent: "beta", + }, + { + name: "image generate", + run: () => { + primeGeneratedImage("gpt-image-1", "provider-output.png"); + return runCapability( + "image", + "generate", + "--agent", + "beta", + "--prompt", + "portrait", + "--json", + ); + }, + selectedAgent: () => firstImageGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "image edit", + run: async () => { + const inputDir = tempDirs.make("openclaw-image-agent-"); + const inputPath = path.join(inputDir, "input.png"); + await fs.writeFile(inputPath, Buffer.from(PNG_1X1_BASE64, "base64")); + primeGeneratedImage("gpt-image-1", "provider-output.png"); + await runCapability( + "image", + "edit", + "--agent", + "beta", + "--file", + inputPath, + "--prompt", + "crop it", + "--json", + ); + }, + selectedAgent: () => firstImageGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "image describe", + run: () => + runCapability("image", "describe", "--agent", "beta", "--file", "photo.png", "--json"), + selectedAgent: () => mocks.resolveAgentDir.mock.calls[0]?.[1], + expectedAgent: "beta", + }, + { + name: "image describe-many", + run: () => + runCapability("image", "describe-many", "--agent", "beta", "--file", "photo.png", "--json"), + selectedAgent: () => mocks.resolveAgentDir.mock.calls[0]?.[1], + expectedAgent: "beta", + }, + { + name: "audio transcribe", + run: () => + runCapability("audio", "transcribe", "--agent", "beta", "--file", "memo.m4a", "--json"), + selectedAgent: () => firstAudioTranscriptionCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "video generate", + run: () => { + mocks.generateVideo.mockResolvedValue({ + provider: "minimax", + model: "MiniMax-Hailuo-2.3", + attempts: [], + videos: [ + { + buffer: Buffer.from("video-bytes"), + mimeType: "video/mp4", + fileName: "provider-name.mp4", + }, + ], + }); + return runCapability("video", "generate", "--agent", "beta", "--prompt", "clip", "--json"); + }, + selectedAgent: () => firstVideoGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "video describe", + run: () => + runCapability("video", "describe", "--agent", "beta", "--file", "clip.mp4", "--json"), + selectedAgent: () => firstVideoDescriptionCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "embedding create", + run: () => + runCapability("embedding", "create", "--agent", "beta", "--text", "hello", "--json"), + selectedAgent: () => firstEmbeddingProviderCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + ])( + "routes --agent through $name owner selection", + async ({ run, selectedAgent, expectedAgent }) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await run(); + + expect(selectedAgent()).toBe(expectedAgent); + }, + ); + + it.each([ + { + name: "model run", + run: () => + runCapabilityWithParentAgent("model", "run", "beta", "--prompt", "hello", "--json"), + selectedAgent: () => firstPreparedModelParams()?.agentId, + expectedAgent: "beta", + }, + { + name: "image generate", + run: () => { + primeGeneratedImage("gpt-image-1", "provider-output.png"); + return runCapabilityWithParentAgent( + "image", + "generate", + "beta", + "--prompt", + "portrait", + "--json", + ); + }, + selectedAgent: () => firstImageGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "image edit", + run: async () => { + const inputDir = tempDirs.make("openclaw-image-parent-agent-"); + const inputPath = path.join(inputDir, "input.png"); + await fs.writeFile(inputPath, Buffer.from(PNG_1X1_BASE64, "base64")); + primeGeneratedImage("gpt-image-1", "provider-output.png"); + await runCapabilityWithParentAgent( + "image", + "edit", + "beta", + "--file", + inputPath, + "--prompt", + "crop it", + "--json", + ); + }, + selectedAgent: () => firstImageGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "image describe", + run: () => + runCapabilityWithParentAgent("image", "describe", "beta", "--file", "photo.png", "--json"), + selectedAgent: () => mocks.resolveAgentDir.mock.calls[0]?.[1], + expectedAgent: "beta", + }, + { + name: "image describe-many", + run: () => + runCapabilityWithParentAgent( + "image", + "describe-many", + "beta", + "--file", + "photo.png", + "--json", + ), + selectedAgent: () => mocks.resolveAgentDir.mock.calls[0]?.[1], + expectedAgent: "beta", + }, + { + name: "audio transcribe", + run: () => + runCapabilityWithParentAgent("audio", "transcribe", "beta", "--file", "memo.m4a", "--json"), + selectedAgent: () => firstAudioTranscriptionCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "video generate", + run: () => { + mocks.generateVideo.mockResolvedValue({ + provider: "minimax", + model: "MiniMax-Hailuo-2.3", + attempts: [], + videos: [ + { + buffer: Buffer.from("video-bytes"), + mimeType: "video/mp4", + fileName: "provider-name.mp4", + }, + ], + }); + return runCapabilityWithParentAgent( + "video", + "generate", + "beta", + "--prompt", + "clip", + "--json", + ); + }, + selectedAgent: () => firstVideoGenerationCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "video describe", + run: () => + runCapabilityWithParentAgent("video", "describe", "beta", "--file", "clip.mp4", "--json"), + selectedAgent: () => firstVideoDescriptionCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + { + name: "embedding create", + run: () => + runCapabilityWithParentAgent("embedding", "create", "beta", "--text", "hello", "--json"), + selectedAgent: () => firstEmbeddingProviderCall()?.agentDir, + expectedAgent: "/tmp/agent-beta", + }, + ])( + "inherits parent --agent through $name owner selection", + async ({ run, selectedAgent, expectedAgent }) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await run(); + + expect(selectedAgent()).toBe(expectedAgent); + }, + ); + + it("prefers a leaf --agent over the parent selector", async () => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + primeGeneratedImage("gpt-image-1", "provider-output.png"); + + await runCap( + "capability", + "image", + "--agent", + "alpha", + "generate", + "--agent", + "beta", + "--prompt", + "portrait", + "--json", + ); + + expect(firstImageGenerationCall()?.agentDir).toBe("/tmp/agent-beta"); + }); + + it.each([ + { agent: "", message: "--agent must not be blank" }, + { agent: "retired", message: 'Unknown agent id "retired"' }, + ])("rejects invalid inherited agent '$agent' before dispatch", async ({ agent, message }) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await expect( + runCapabilityWithParentAgent( + "image", + "generate", + agent, + "--prompt", + "friendly lobster", + "--json", + ), + ).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains(message); + expect(mocks.generateImage).not.toHaveBeenCalled(); + }); + it("passes image output format and generic background hints through to generation runtime", async () => { primeGeneratedImage("gpt-image-1.5", "transparent.png"); @@ -3361,7 +3729,7 @@ describe("capability cli", () => { }); it("removes model auth profiles from the selected agent store", async () => { - mocks.loadConfig.mockReturnValue({ agents: { entries: { poe: {} } } }); + mocks.loadConfig.mockReturnValue({ agents: { entries: { poe: {} }, ownership: "explicit" } }); mocks.listProfilesForProvider.mockReturnValue(["openai:default"] as never); await runCapability( @@ -3385,6 +3753,71 @@ describe("capability cli", () => { }); }); + it.each(["parent", "leaf"] as const)( + "routes %s --agent through model auth login", + async (position) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await runModelAuthWithAgent(position, "login", "beta", "--provider", "openai"); + + expect(mocks.modelsAuthLoginCommand).toHaveBeenCalledWith( + expect.objectContaining({ provider: "openai", agent: "beta" }), + mocks.runtime, + ); + }, + ); + + it.each(["parent", "leaf"] as const)( + "routes %s --agent through model auth status", + async (position) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await runModelAuthWithAgent(position, "status", "beta", "--json"); + + expect(mocks.modelsStatusCommand).toHaveBeenCalledWith( + expect.objectContaining({ json: true, agent: "beta" }), + expect.any(Object), + ); + }, + ); + + it.each(["parent", "leaf"] as const)( + "routes %s --agent through model auth logout", + async (position) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + mocks.listProfilesForProvider.mockReturnValue(["openai:default"] as never); + + await runModelAuthWithAgent(position, "logout", "beta", "--provider", "openai", "--json"); + + expect(mocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/agent-beta"); + }, + ); + + it.each([ + { position: "parent" as const, agent: "", message: "--agent must not be blank" }, + { position: "leaf" as const, agent: "retired", message: 'Unknown agent id "retired"' }, + ])( + "rejects invalid $position model auth agent '$agent' before dispatch", + async ({ position, agent, message }) => { + mocks.loadConfig.mockReturnValue({ + agents: { entries: { alpha: {}, beta: {} }, ownership: "explicit" }, + }); + + await expect(runModelAuthWithAgent(position, "status", agent, "--json")).rejects.toThrow( + "exit 1", + ); + + expectRuntimeErrorContains(message); + expect(mocks.modelsStatusCommand).not.toHaveBeenCalled(); + }, + ); + it("fails logout if the auth store update does not complete", async () => { mocks.listProfilesForProvider.mockReturnValue(["openai:default"] as never); mocks.updateAuthProfileStoreWithLock.mockResolvedValueOnce(null as never); diff --git a/src/cli/capability-cli/audio.ts b/src/cli/capability-cli/audio.ts index a8f1c6c90c8c..25c6eff0f58b 100644 --- a/src/cli/capability-cli/audio.ts +++ b/src/cli/capability-cli/audio.ts @@ -1,5 +1,6 @@ import path from "node:path"; import type { Command } from "commander"; +import { resolveAgentDir } from "../../agents/agent-scope.js"; import { getRuntimeConfig } from "../../config/config.js"; import { inspectLocalAudioSelection } from "../../media-understanding/local-audio.js"; import { buildMediaUnderstandingRegistry } from "../../media-understanding/provider-registry.js"; @@ -16,6 +17,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityAgentOption, resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; @@ -25,15 +27,21 @@ async function runAudioTranscribe(params: { language?: string; model?: string; prompt?: string; + agent?: string; }) { const cfg = await resolveLocalCapabilityRuntimeConfig({ commandName: "infer audio transcribe", targetIds: getModelsCommandSecretTargetIds(), }); + const agentDir = resolveAgentDir( + cfg, + resolveCapabilityProviderAgentId(cfg, params.agent, "infer audio transcribe"), + ); const activeModel = requireProviderModelOverride(params.model); const result = await transcribeAudioFile({ filePath: path.resolve(params.file), cfg, + agentDir, language: params.language, activeModel, prompt: params.prompt, @@ -56,20 +64,25 @@ async function runAudioTranscribe(params: { } export function registerAudioCapabilityCommands(capability: Command): void { - const audio = capability.command("audio").description("Audio transcription"); + const audio = capability + .command("audio") + .description("Audio transcription") + .option("--agent ", "Agent whose model and auth state should be used"); audio .command("transcribe") .description("Transcribe one audio file") .requiredOption("--file ", "Audio file") + .option("--agent ", "Agent whose model and auth state should be used") .option("--language ", "Language hint") .option("--prompt ", "Prompt hint") .option("--model ", "Model override") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runAudioTranscribe({ file: String(opts.file), + agent: resolveCapabilityAgentOption(command, opts.agent), language: opts.language as string | undefined, model: opts.model as string | undefined, prompt: opts.prompt as string | undefined, diff --git a/src/cli/capability-cli/embedding.ts b/src/cli/capability-cli/embedding.ts index fa1131d8bab0..5fc7eaa5f5c9 100644 --- a/src/cli/capability-cli/embedding.ts +++ b/src/cli/capability-cli/embedding.ts @@ -17,6 +17,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityAgentOption, resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, } from "./shared.js"; @@ -102,7 +103,10 @@ async function runMemoryEmbeddingCreate(params: { } export function registerEmbeddingCapabilityCommands(capability: Command): void { - const embedding = capability.command("embedding").description("Embedding providers"); + const embedding = capability + .command("embedding") + .description("Embedding providers") + .option("--agent ", "Agent whose model and auth state should be used"); embedding .command("create") @@ -115,13 +119,13 @@ export function registerEmbeddingCapabilityCommands(capability: Command): void { "Agent whose saved provider auth is used (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runMemoryEmbeddingCreate({ texts: opts.text as string[], + agent: resolveCapabilityAgentOption(command, opts.agent), provider: opts.provider as string | undefined, model: opts.model as string | undefined, - agent: typeof opts.agent === "string" ? opts.agent : undefined, }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -132,10 +136,13 @@ export function registerEmbeddingCapabilityCommands(capability: Command): void { .description("List embedding providers") .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); - const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); + const agentId = resolveCapabilityProviderAgentId( + cfg, + resolveCapabilityAgentOption(command, opts.agent), + ); const resolvedMemory = resolveMemorySearchConfig(cfg, agentId); const selectedProvider = resolvedMemory?.provider; const providers = new Map( diff --git a/src/cli/capability-cli/image.ts b/src/cli/capability-cli/image.ts index 65b2c2a72329..3962aa5e4eba 100644 --- a/src/cli/capability-cli/image.ts +++ b/src/cli/capability-cli/image.ts @@ -40,6 +40,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityAgentOption, resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, @@ -313,8 +314,9 @@ function addImageGenerationOptions(command: Command): Command { .option("--json", "Output JSON", false); } -function resolveImageGenerationOptions(opts: Record) { +function resolveImageGenerationOptions(opts: Record, command: Command) { return { + agent: resolveCapabilityAgentOption(command, opts.agent), model: opts.model as string | undefined, count: parseOptionalPositiveInteger(opts.count, "--count"), size: opts.size as string | undefined, @@ -330,24 +332,26 @@ function resolveImageGenerationOptions(opts: Record) { quality: normalizeImageQuality(opts.quality as string | undefined), timeoutMs: parseOptionalTimeoutMs(opts.timeoutMs as string | number | undefined), output: opts.output as string | undefined, - agent: typeof opts.agent === "string" ? opts.agent : undefined, }; } export function registerImageCapabilityCommands(capability: Command): void { - const image = capability.command("image").description("Image generation and description"); + const image = capability + .command("image") + .description("Image generation and description") + .option("--agent ", "Agent whose model and auth state should be used"); addImageGenerationOptions( image .command("generate") .description("Generate images") .requiredOption("--prompt ", "Prompt text"), - ).action(async (opts) => { + ).action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runImageGenerate({ capability: "image.generate", prompt: String(opts.prompt), - ...resolveImageGenerationOptions(opts), + ...resolveImageGenerationOptions(opts, command), }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -359,14 +363,14 @@ export function registerImageCapabilityCommands(capability: Command): void { .description("Edit images with one or more input files") .requiredOption("--file ", "Input file", collectOption, []) .requiredOption("--prompt ", "Prompt text"), - ).action(async (opts) => { + ).action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const files = Array.isArray(opts.file) ? (opts.file as string[]) : [String(opts.file)]; const result = await runImageGenerate({ capability: "image.edit", prompt: String(opts.prompt), file: files, - ...resolveImageGenerationOptions(opts), + ...resolveImageGenerationOptions(opts, command), }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -384,7 +388,7 @@ export function registerImageCapabilityCommands(capability: Command): void { "Agent whose saved provider auth is used (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runImageDescribe({ capability: "image.describe", @@ -392,7 +396,7 @@ export function registerImageCapabilityCommands(capability: Command): void { model: opts.model as string | undefined, prompt: opts.prompt as string | undefined, timeoutMs: parseOptionalTimeoutMs(opts.timeoutMs), - agent: typeof opts.agent === "string" ? opts.agent : undefined, + agent: resolveCapabilityAgentOption(command, opts.agent), }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -410,7 +414,7 @@ export function registerImageCapabilityCommands(capability: Command): void { "Agent whose saved provider auth is used (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runImageDescribe({ capability: "image.describe-many", @@ -418,7 +422,7 @@ export function registerImageCapabilityCommands(capability: Command): void { model: opts.model as string | undefined, prompt: opts.prompt as string | undefined, timeoutMs: parseOptionalTimeoutMs(opts.timeoutMs), - agent: typeof opts.agent === "string" ? opts.agent : undefined, + agent: resolveCapabilityAgentOption(command, opts.agent), }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -429,10 +433,13 @@ export function registerImageCapabilityCommands(capability: Command): void { .description("List image generation providers") .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); - const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); + const agentId = resolveCapabilityProviderAgentId( + cfg, + resolveCapabilityAgentOption(command, opts.agent), + ); const selectedProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.image), ); diff --git a/src/cli/capability-cli/metadata.ts b/src/cli/capability-cli/metadata.ts index c89d8f5b33ed..beb4f4a8afb2 100644 --- a/src/cli/capability-cli/metadata.ts +++ b/src/cli/capability-cli/metadata.ts @@ -63,7 +63,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "model.auth.login", description: "Run the existing provider auth login flow.", transports: ["local"], - flags: ["--provider", "--method"], + flags: ["--provider", "--method", "--agent"], resultShape: "interactive auth result", }, { @@ -77,7 +77,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "model.auth.status", description: "Show configured model auth state.", transports: ["local"], - flags: ["--json"], + flags: ["--agent", "--json"], resultShape: "model status summary", }, { @@ -152,7 +152,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "audio.transcribe", description: "Transcribe one audio file.", transports: ["local"], - flags: ["--file", "--language", "--prompt", "--model", "--json"], + flags: ["--file", "--agent", "--language", "--prompt", "--model", "--json"], resultShape: "normalized text output", }, { @@ -259,7 +259,7 @@ export const CAPABILITY_METADATA: CapabilityMetadata[] = [ id: "video.describe", description: "Describe one video file through media-understanding providers.", transports: ["local"], - flags: ["--file", "--model", "--json"], + flags: ["--file", "--agent", "--model", "--json"], resultShape: "normalized text output", }, { diff --git a/src/cli/capability-cli/model.ts b/src/cli/capability-cli/model.ts index 80ab790c3410..d62c4388fc90 100644 --- a/src/cli/capability-cli/model.ts +++ b/src/cli/capability-cli/model.ts @@ -45,6 +45,7 @@ import { providerHasGenericConfig, providerSummaryText, requireProviderModelOverride, + resolveCapabilityAgentOption, resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, @@ -384,11 +385,11 @@ async function buildModelProviders(rawAgentId?: string) { return [...grouped.values()].toSorted((a, b) => a.provider.localeCompare(b.provider)); } -async function runModelAuthStatus() { +async function runModelAuthStatus(agent: string) { const captured: string[] = []; const { modelsStatusCommand } = await import("../../commands/models/list.status-command.js"); await modelsStatusCommand( - { json: true }, + { json: true, agent }, { log: (...args) => captured.push(args.join(" ")), error: (message) => { @@ -403,10 +404,9 @@ async function runModelAuthStatus() { return raw ? (JSON.parse(raw) as Record) : {}; } -async function runModelAuthLogout(provider: string, agent?: string) { +async function runModelAuthLogout(provider: string, agent: string) { const cfg = getRuntimeConfig(); - const agentId = resolveCapabilityProviderAgentId(cfg, agent, "infer model auth logout"); - const agentDir = resolveAgentDir(cfg, agentId); + const agentDir = resolveAgentDir(cfg, agent); const store = loadAuthProfileStoreForRuntime(agentDir); const profileIds = listProfilesForProvider(store, provider); const updated = await updateAuthProfileStoreWithLock({ @@ -446,7 +446,8 @@ async function runModelAuthLogout(provider: string, agent?: string) { export function registerModelCapabilityCommands(capability: Command): void { const model = capability .command("model") - .description("Text inference and model catalog commands"); + .description("Text inference and model catalog commands") + .option("--agent ", "Agent whose model and auth state should be used"); model .command("run") @@ -462,7 +463,7 @@ export function registerModelCapabilityCommands(capability: Command): void { "Agent whose model and credentials own the run (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const prompt = requireModelRunPrompt(opts.prompt); const thinking = normalizeModelRunThinking(opts.thinking); @@ -474,11 +475,11 @@ export function registerModelCapabilityCommands(capability: Command): void { }); const result = await runModelRun({ prompt, + agent: resolveCapabilityAgentOption(command, opts.agent), files: opts.file as string[] | undefined, model: opts.model as string | undefined, thinking, transport, - agent: typeof opts.agent === "string" ? opts.agent : undefined, }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -521,27 +522,40 @@ export function registerModelCapabilityCommands(capability: Command): void { .description("List model providers from the catalog") .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await buildModelProviders(opts.agent as string | undefined); + const result = await buildModelProviders(resolveCapabilityAgentOption(command, opts.agent)); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, providerSummaryText); }); }); - const modelAuth = model.command("auth").description("Provider auth helpers"); + const modelAuth = model + .command("auth") + .description("Provider auth helpers") + .option("--agent ", "Agent id (default: configured default agent)"); + + const resolveModelAuthAgent = (command: Command, rawAgentId: unknown, surface: string) => + resolveCapabilityProviderAgentId( + getRuntimeConfig(), + resolveCapabilityAgentOption(command, rawAgentId), + surface, + ); modelAuth .command("login") .description("Run provider auth login") .requiredOption("--provider ", "Provider id") .option("--method ", "Provider auth method id") - .action(async (opts) => { + .option("--agent ", "Agent id (default: configured default agent)") + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { + const agent = resolveModelAuthAgent(command, opts.agent, "infer model auth login"); const { modelsAuthLoginCommand } = await import("../../commands/models/auth.js"); await modelsAuthLoginCommand( { provider: String(opts.provider), method: opts.method ? String(opts.method) : undefined, + agent, }, defaultRuntime, ); @@ -557,11 +571,11 @@ export function registerModelCapabilityCommands(capability: Command): void { "Agent id (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runModelAuthLogout( String(opts.provider), - typeof opts.agent === "string" ? opts.agent : undefined, + resolveModelAuthAgent(command, opts.agent, "infer model auth logout"), ); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, (value) => JSON.stringify(value, null, 2), @@ -572,10 +586,13 @@ export function registerModelCapabilityCommands(capability: Command): void { modelAuth .command("status") .description("Show configured auth state") + .option("--agent ", "Agent id (default: configured default agent)") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { - const result = await runModelAuthStatus(); + const result = await runModelAuthStatus( + resolveModelAuthAgent(command, opts.agent, "infer model auth status"), + ); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, (value) => JSON.stringify(value, null, 2), ); diff --git a/src/cli/capability-cli/shared.ts b/src/cli/capability-cli/shared.ts index 9778475beb33..3ede3a1f3fdc 100644 --- a/src/cli/capability-cli/shared.ts +++ b/src/cli/capability-cli/shared.ts @@ -2,6 +2,7 @@ import { parseStrictFiniteNumber, parseStrictPositiveInteger, } from "@openclaw/normalization-core/number-coercion"; +import type { Command } from "commander"; import { listAgentIds, resolveAgentOperationAgentId } from "../../agents/agent-scope-config.js"; import { resolveAgentDir } from "../../agents/agent-scope.js"; import { @@ -17,6 +18,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { writeRuntimeJson, defaultRuntime, type RuntimeEnv } from "../../runtime.js"; import { getProviderEnvVars } from "../../secrets/provider-env-vars.js"; import { resolveCommandConfigWithSecrets } from "../command-config-resolution.js"; +import { inheritOptionFromParent } from "../command-options.js"; import { parseTimeoutMsWithFallback } from "../parse-timeout.js"; import type { CapabilityEnvelope, CapabilityTransport } from "./metadata.js"; @@ -123,6 +125,14 @@ export function resolveCapabilityProviderAgentId( return agentId; } +export function resolveCapabilityAgentOption( + command: Command | undefined, + rawAgentId: unknown, +): string | undefined { + return typeof rawAgentId === "string" + ? rawAgentId + : inheritOptionFromParent(command, "agent"); +} function getAuthProfileIdsForProvider( cfg: OpenClawConfig, providerId: string, diff --git a/src/cli/capability-cli/video.ts b/src/cli/capability-cli/video.ts index 7263fa26d7e3..5e071ef37a21 100644 --- a/src/cli/capability-cli/video.ts +++ b/src/cli/capability-cli/video.ts @@ -39,6 +39,7 @@ import { parseOptionalTimeoutMs, providerHasGenericConfig, requireProviderModelOverride, + resolveCapabilityAgentOption, resolveCapabilityProviderAgentId, resolveLocalCapabilityRuntimeConfig, resolveSelectedProviderFromModelRef, @@ -234,15 +235,20 @@ async function runVideoGenerate(params: { } satisfies CapabilityEnvelope; } -async function runVideoDescribe(params: { file: string; model?: string }) { +async function runVideoDescribe(params: { file: string; model?: string; agent?: string }) { const cfg = await resolveLocalCapabilityRuntimeConfig({ commandName: "infer video.describe", targetIds: getModelsCommandSecretTargetIds(), }); + const agentDir = resolveAgentDir( + cfg, + resolveCapabilityProviderAgentId(cfg, params.agent, "infer video describe"), + ); const activeModel = requireProviderModelOverride(params.model); const result = await describeVideoFile({ filePath: path.resolve(params.file), cfg, + agentDir, activeModel, }); if (!result.text) { @@ -260,7 +266,10 @@ async function runVideoDescribe(params: { file: string; model?: string }) { } export function registerVideoCapabilityCommands(capability: Command): void { - const video = capability.command("video").description("Video generation and description"); + const video = capability + .command("video") + .description("Video generation and description") + .option("--agent ", "Agent whose model and auth state should be used"); video .command("generate") @@ -280,10 +289,11 @@ export function registerVideoCapabilityCommands(capability: Command): void { "Agent whose saved provider auth is used (default: agents.defaults.systemAgent.agentId, then the sole agent)", ) .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runVideoGenerate({ prompt: String(opts.prompt), + agent: resolveCapabilityAgentOption(command, opts.agent), model: opts.model as string | undefined, output: opts.output as string | undefined, size: opts.size as string | undefined, @@ -293,7 +303,6 @@ export function registerVideoCapabilityCommands(capability: Command): void { audio: opts.audio === true ? true : undefined, watermark: opts.watermark === true ? true : undefined, timeoutMs: parseOptionalTimeoutMs(opts.timeoutMs), - agent: typeof opts.agent === "string" ? opts.agent : undefined, }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); }); @@ -303,12 +312,14 @@ export function registerVideoCapabilityCommands(capability: Command): void { .command("describe") .description("Describe one video file") .requiredOption("--file ", "Video file") + .option("--agent ", "Agent whose model and auth state should be used") .option("--model ", "Model override") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const result = await runVideoDescribe({ file: String(opts.file), + agent: resolveCapabilityAgentOption(command, opts.agent), model: opts.model as string | undefined, }); emitJsonOrText(defaultRuntime, Boolean(opts.json), result, formatEnvelopeForText); @@ -320,10 +331,13 @@ export function registerVideoCapabilityCommands(capability: Command): void { .description("List video generation and description providers") .option("--agent ", "Agent whose provider state should be inspected") .option("--json", "Output JSON", false) - .action(async (opts) => { + .action(async (opts, command) => { await runCommandWithRuntime(defaultRuntime, async () => { const cfg = getRuntimeConfig(); - const agentId = resolveCapabilityProviderAgentId(cfg, opts.agent as string | undefined); + const agentId = resolveCapabilityProviderAgentId( + cfg, + resolveCapabilityAgentOption(command, opts.agent), + ); const selectedGenerationProvider = resolveSelectedProviderFromModelRef( resolveAgentModelPrimaryValue(cfg.agents?.defaults?.mediaModels?.video), );