fix(tools): preserve tool availability contracts

This commit is contained in:
Ayaan Zaidi
2026-05-01 07:56:36 +05:30
parent 22d6e9564a
commit e0fe02fb09
10 changed files with 108 additions and 77 deletions

View File

@@ -230,12 +230,9 @@ describe("createImageGenerateTool", () => {
vi.unstubAllEnvs();
});
it("defers image-generation model resolution until execution", async () => {
it("returns null when no image-generation model can be inferred", () => {
stubImageGenerationProviders();
const tool = requireImageGenerateTool(createImageGenerateTool({ config: {} }));
await expect(tool.execute("tool-call-1", { prompt: "draw a chart" })).rejects.toThrow(
"No image-generation model configured.",
);
expect(createImageGenerateTool({ config: {} })).toBeNull();
});
it("tells agents how to request transparent OpenAI backgrounds", () => {

View File

@@ -565,7 +565,18 @@ export function createImageGenerateTool(options?: {
workspaceDir?: string;
sandbox?: ImageGenerateSandboxConfig;
fsPolicy?: ToolFsPolicy;
}): AnyAgentTool {
}): AnyAgentTool | null {
const cfg = options?.config ?? getRuntimeConfig();
const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!imageGenerationModelConfig) {
return null;
}
const effectiveCfg =
applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg;
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(effectiveCfg);
const sandboxConfig =
options?.sandbox && options.sandbox.root.trim()
? {
@@ -584,9 +595,8 @@ export function createImageGenerateTool(options?: {
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
const action = resolveAction(params);
const cfg = options?.config ?? getRuntimeConfig();
if (action === "list") {
const runtimeProviders = listRuntimeImageGenerationProviders({ config: cfg });
const runtimeProviders = listRuntimeImageGenerationProviders({ config: effectiveCfg });
const providers = runtimeProviders.map((provider) =>
Object.assign(
{ id: provider.id },
@@ -597,7 +607,7 @@ export function createImageGenerateTool(options?: {
configured: isCapabilityProviderConfigured({
providers: runtimeProviders,
provider,
cfg,
cfg: effectiveCfg,
agentDir: options?.agentDir,
}),
authEnvVars: getImageGenerationProviderAuthEnvVars(provider.id),
@@ -647,16 +657,6 @@ export function createImageGenerateTool(options?: {
};
}
const imageGenerationModelConfig = resolveImageGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!imageGenerationModelConfig) {
throw new ToolInputError("No image-generation model configured.");
}
const effectiveCfg =
applyImageGenerationModelConfigDefaults(cfg, imageGenerationModelConfig) ?? cfg;
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(effectiveCfg);
const prompt = readStringParam(params, "prompt", { required: true });
const imageInputs = normalizeReferenceImages(params);
const model = readStringParam(params, "model");

View File

@@ -618,20 +618,13 @@ describe("image tool implicit imageModel config", () => {
__testing.setProviderDepsForTest();
});
it("defers image model pairing until execution", async () => {
it("stays disabled without auth when no pairing is possible", async () => {
await withTempAgentDir(async (agentDir) => {
const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "openai/gpt-5.4" } } },
};
expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull();
const tool = createImageTool({ config: cfg, agentDir });
expect(tool).not.toBeNull();
await expect(
tool?.execute("tool-call-1", {
image: `data:image/png;base64,${ONE_PIXEL_PNG_B64}`,
prompt: "describe it",
}),
).rejects.toThrow("No image model configured.");
expect(createImageTool({ config: cfg, agentDir })).toBeNull();
});
});

View File

@@ -380,6 +380,15 @@ export function createImageTool(options?: {
}
return null;
}
const imageModelConfig = resolveImageModelConfigForTool({
cfg: options?.config,
agentDir,
});
if (!imageModelConfig) {
return null;
}
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(options?.config);
// If model has native vision, images in the prompt are auto-injected
// so this tool is only needed when image wasn't provided in the prompt
const description = options?.modelHasVision
@@ -453,14 +462,6 @@ export function createImageTool(options?: {
record,
DEFAULT_PROMPT,
);
const imageModelConfig = resolveImageModelConfigForTool({
cfg: options?.config,
agentDir,
});
if (!imageModelConfig) {
throw new Error("No image model configured.");
}
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(options?.config);
const maxBytesMb = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
const maxBytes = pickMaxBytes(options?.config, maxBytesMb);

View File

@@ -129,12 +129,9 @@ describe("createMusicGenerateTool", () => {
vi.unstubAllEnvs();
});
it("defers music-generation model resolution until execution", async () => {
it("returns null when no music-generation config or auth-backed provider is available", () => {
vi.spyOn(musicGenerationRuntime, "listRuntimeMusicGenerationProviders").mockReturnValue([]);
const tool = createMusicGenerateTool({ config: asConfig({}) });
await expect(tool.execute("tool-call-1", { prompt: "make a loop" })).rejects.toThrow(
"No music-generation model configured.",
);
expect(createMusicGenerateTool({ config: asConfig({}) })).toBeNull();
});
it("registers when music-generation config is present", () => {

View File

@@ -493,7 +493,16 @@ export function createMusicGenerateTool(options?: {
sandbox?: MusicGenerateSandboxConfig;
fsPolicy?: ToolFsPolicy;
scheduleBackgroundWork?: MusicGenerateBackgroundScheduler;
}): AnyAgentTool {
}): AnyAgentTool | null {
const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig();
const musicGenerationModelConfig = resolveMusicGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!musicGenerationModelConfig) {
return null;
}
const sandboxConfig = options?.sandbox
? {
root: options.sandbox.root,
@@ -514,26 +523,17 @@ export function createMusicGenerateTool(options?: {
execute: async (_toolCallId, rawArgs) => {
const args = rawArgs as Record<string, unknown>;
const action = resolveAction(args);
const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig();
const effectiveCfg =
applyMusicGenerationModelConfigDefaults(cfg, musicGenerationModelConfig) ?? cfg;
if (action === "list") {
return createMusicGenerateListActionResult(cfg);
return createMusicGenerateListActionResult(effectiveCfg);
}
if (action === "status") {
return createMusicGenerateStatusActionResult(options?.agentSessionKey);
}
const musicGenerationModelConfig = resolveMusicGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!musicGenerationModelConfig) {
throw new ToolInputError("No music-generation model configured.");
}
const effectiveCfg =
applyMusicGenerationModelConfigDefaults(cfg, musicGenerationModelConfig) ?? cfg;
const duplicateGuardResult = createMusicGenerateDuplicateGuardResult(
options?.agentSessionKey,
);

View File

@@ -257,6 +257,11 @@ export function createPdfTool(options?: {
return null;
}
const pdfModelConfig = resolvePdfModelConfigForTool({ cfg: options?.config, agentDir });
if (!pdfModelConfig) {
return null;
}
const maxBytesMbDefault = (
options?.config?.agents?.defaults as Record<string, unknown> | undefined
)?.pdfMaxBytesMb;
@@ -303,10 +308,6 @@ export function createPdfTool(options?: {
record,
DEFAULT_PROMPT,
);
const pdfModelConfig = resolvePdfModelConfigForTool({ cfg: options?.config, agentDir });
if (!pdfModelConfig) {
throw new Error("No PDF model configured.");
}
const maxBytesMbRaw = typeof record.maxBytesMb === "number" ? record.maxBytesMb : undefined;
const maxBytesMb =
typeof maxBytesMbRaw === "number" && Number.isFinite(maxBytesMbRaw) && maxBytesMbRaw > 0

View File

@@ -93,13 +93,10 @@ describe("createVideoGenerateTool", () => {
vi.unstubAllEnvs();
});
it("defers video-generation model resolution until execution", async () => {
it("returns null when no video-generation config or auth-backed provider is available", () => {
vi.spyOn(videoGenerationRuntime, "listRuntimeVideoGenerationProviders").mockReturnValue([]);
const tool = createVideoGenerateTool({ config: asConfig({}) });
await expect(tool.execute("tool-call-1", { prompt: "make a clip" })).rejects.toThrow(
"No video-generation model configured.",
);
expect(createVideoGenerateTool({ config: asConfig({}) })).toBeNull();
});
it("registers when video-generation config is present", () => {

View File

@@ -800,7 +800,16 @@ export function createVideoGenerateTool(options?: {
sandbox?: VideoGenerateSandboxConfig;
fsPolicy?: ToolFsPolicy;
scheduleBackgroundWork?: VideoGenerateBackgroundScheduler;
}): AnyAgentTool {
}): AnyAgentTool | null {
const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig();
const videoGenerationModelConfig = resolveVideoGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!videoGenerationModelConfig) {
return null;
}
const sandboxConfig = options?.sandbox
? {
root: options.sandbox.root,
@@ -821,27 +830,18 @@ export function createVideoGenerateTool(options?: {
execute: async (_toolCallId, rawArgs) => {
const args = rawArgs as Record<string, unknown>;
const action = resolveAction(args);
const cfg: OpenClawConfig = options?.config ?? getRuntimeConfig();
const effectiveCfg =
applyVideoGenerationModelConfigDefaults(cfg, videoGenerationModelConfig) ?? cfg;
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(effectiveCfg);
if (action === "list") {
return createVideoGenerateListActionResult(cfg);
return createVideoGenerateListActionResult(effectiveCfg);
}
if (action === "status") {
return createVideoGenerateStatusActionResult(options?.agentSessionKey);
}
const videoGenerationModelConfig = resolveVideoGenerationModelConfigForTool({
cfg,
agentDir: options?.agentDir,
});
if (!videoGenerationModelConfig) {
throw new ToolInputError("No video-generation model configured.");
}
const effectiveCfg =
applyVideoGenerationModelConfigDefaults(cfg, videoGenerationModelConfig) ?? cfg;
const remoteMediaSsrfPolicy = resolveRemoteMediaSsrfPolicy(effectiveCfg);
const duplicateGuardResult = createVideoGenerateDuplicateGuardResult(
options?.agentSessionKey,
);

View File

@@ -14,6 +14,51 @@ const WebSearchSchema = {
type: "number",
description: "Number of results to return.",
minimum: 1,
maximum: 20,
},
country: {
type: "string",
description: "2-letter country code for region-specific results.",
},
language: {
type: "string",
description: "ISO 639-1 language code for results.",
},
freshness: {
type: "string",
description: "Filter by time: day, week, month, or year.",
},
date_after: {
type: "string",
description: "Only results published after this date (YYYY-MM-DD).",
},
date_before: {
type: "string",
description: "Only results published before this date (YYYY-MM-DD).",
},
search_lang: {
type: "string",
description: "Brave search result language code.",
},
ui_lang: {
type: "string",
description: "Brave UI locale code in language-region format.",
},
domain_filter: {
type: "array",
items: { type: "string" },
description: "Perplexity native Search API domain filter.",
},
max_tokens: {
type: "number",
description: "Perplexity native Search API total content budget.",
minimum: 1,
maximum: 1000000,
},
max_tokens_per_page: {
type: "number",
description: "Perplexity native Search API max tokens extracted per page.",
minimum: 1,
},
},
} satisfies Record<string, unknown>;