diff --git a/src/plugin-sdk/provider-tools.test.ts b/src/plugin-sdk/provider-tools.test.ts index da605b14f4ef..7f2cba5b82b7 100644 --- a/src/plugin-sdk/provider-tools.test.ts +++ b/src/plugin-sdk/provider-tools.test.ts @@ -29,6 +29,39 @@ describe("buildProviderToolCompatFamilyHooks", () => { return normalized[0]?.parameters; } + function makeUnreadableParameterTool() { + const tool = { + name: "broken_tool", + description: "", + parameters: {}, + }; + Object.defineProperty(tool, "parameters", { + enumerable: true, + get() { + throw new Error("fuzzplugin parameters getter exploded"); + }, + }); + return tool; + } + + function makeUnreadableNestedSchemaTool() { + const tool = { + name: "nested_broken_tool", + description: "", + parameters: { + type: "object", + properties: {}, + }, + }; + Object.defineProperty(tool.parameters, "properties", { + enumerable: true, + get() { + throw new Error("fuzzplugin properties getter exploded"); + }, + }); + return tool; + } + it("covers the tool compat family matrix", () => { const cases = [ { @@ -56,6 +89,85 @@ describe("buildProviderToolCompatFamilyHooks", () => { } }); + it("skips unreadable tool schemas while normalizing provider compat families", () => { + const broken = makeUnreadableParameterTool(); + const nestedBroken = makeUnreadableNestedSchemaTool(); + const healthy = { + name: "healthy_tool", + description: "", + parameters: { + type: "object", + properties: { + mode: { + anyOf: [{ const: "a", type: "string" }, { const: "b", type: "string" }], + }, + }, + }, + }; + + const hooks = buildProviderToolCompatFamilyHooks("deepseek"); + const normalized = hooks.normalizeToolSchemas({ + provider: "deepseek", + modelId: "deepseek-v4-pro", + modelApi: "openai-completions", + model: { + provider: "deepseek", + api: "openai-completions", + id: "deepseek-v4-pro", + } as never, + tools: [broken, nestedBroken, healthy] as never, + }); + + expect(normalized[0]).toBe(broken); + expect(normalized[1]).toBe(nestedBroken); + expect(normalized[2]?.parameters).toEqual({ + type: "object", + properties: { + mode: { + type: "string", + enum: ["a", "b"], + }, + }, + }); + }); + + it("reports provider schema diagnostics without crashing on unreadable tools", () => { + const broken = makeUnreadableParameterTool(); + const nestedBroken = makeUnreadableNestedSchemaTool(); + const healthy = { + name: "healthy_tool", + description: "", + parameters: { + type: "object", + properties: { + nested: { anyOf: [{ type: "string" }, { type: "number" }] }, + }, + }, + }; + + const hooks = buildProviderToolCompatFamilyHooks("deepseek"); + + expect( + hooks.inspectToolSchemas({ + provider: "deepseek", + modelId: "deepseek-v4-pro", + modelApi: "openai-completions", + model: { + provider: "deepseek", + api: "openai-completions", + id: "deepseek-v4-pro", + } as never, + tools: [broken, nestedBroken, healthy] as never, + }), + ).toEqual([ + { + toolName: "healthy_tool", + toolIndex: 2, + violations: ["healthy_tool.parameters.properties.nested.anyOf"], + }, + ]); + }); + it("normalizes canonical OpenAI Codex Responses tool schemas", () => { const hooks = buildProviderToolCompatFamilyHooks("openai"); const tools = [{ name: "demo", description: "", parameters: {} }] as never; diff --git a/src/plugin-sdk/provider-tools.ts b/src/plugin-sdk/provider-tools.ts index 3e9091e69c86..7c4505957e74 100644 --- a/src/plugin-sdk/provider-tools.ts +++ b/src/plugin-sdk/provider-tools.ts @@ -12,6 +12,40 @@ import type { export { cleanSchemaForGemini, GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, stripUnsupportedSchemaKeywords }; +type ProviderToolSchemaSnapshot = { + name: string; + parameters: unknown; +}; + +function readProviderToolSchemaSnapshot( + tool: AnyAgentTool, + toolIndex: number, +): ProviderToolSchemaSnapshot | undefined { + try { + const rawName = tool.name; + const name = typeof rawName === "string" && rawName.trim() ? rawName : `tool[${toolIndex}]`; + return { name, parameters: tool.parameters }; + } catch { + return undefined; + } +} + +function isSchemaRecord(schema: unknown): schema is Record { + return Boolean(schema) && typeof schema === "object" && !Array.isArray(schema); +} + +function findUnsupportedSchemaKeywordsSafe( + schema: unknown, + path: string, + unsupportedKeywords: ReadonlySet, +): string[] | undefined { + try { + return findUnsupportedSchemaKeywords(schema, path, unsupportedKeywords); + } catch { + return undefined; + } +} + /** * Finds unsupported JSON-schema keywords and reports their nested schema paths. */ @@ -67,14 +101,19 @@ export function normalizeGeminiToolSchemas( /** Provider tool-schema normalization context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { - return ctx.tools.map((tool) => { - if (!tool.parameters || typeof tool.parameters !== "object") { + return ctx.tools.map((tool, toolIndex) => { + const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex); + if (!snapshot || !isSchemaRecord(snapshot.parameters)) { + return tool; + } + try { + return { + ...tool, + parameters: cleanSchemaForGemini(snapshot.parameters), + }; + } catch { return tool; } - return { - ...tool, - parameters: cleanSchemaForGemini(tool.parameters), - }; }); } @@ -86,15 +125,19 @@ export function inspectGeminiToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { return ctx.tools.flatMap((tool, toolIndex) => { - const violations = findUnsupportedSchemaKeywords( - tool.parameters, - `${tool.name}.parameters`, - GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, - ); - if (violations.length === 0) { + const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex); + if (!snapshot) { return []; } - return [{ toolName: tool.name, toolIndex, violations }]; + const violations = findUnsupportedSchemaKeywordsSafe( + snapshot.parameters, + `${snapshot.name}.parameters`, + GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, + ); + if (!violations || violations.length === 0) { + return []; + } + return [{ toolName: snapshot.name, toolIndex, violations }]; }); } @@ -108,20 +151,28 @@ export function normalizeOpenAIToolSchemas( if (!shouldApplyOpenAIToolCompat(ctx)) { return ctx.tools; } - return ctx.tools.map((tool) => { - if (tool.parameters == null) { + return ctx.tools.map((tool, toolIndex) => { + const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex); + if (!snapshot) { + return tool; + } + if (snapshot.parameters == null) { return { ...tool, parameters: normalizeOpenAIStrictCompatSchema({}), }; } - if (typeof tool.parameters !== "object") { + if (!isSchemaRecord(snapshot.parameters)) { + return tool; + } + try { + return { + ...tool, + parameters: normalizeOpenAIStrictCompatSchema(snapshot.parameters), + }; + } catch { return tool; } - return { - ...tool, - parameters: normalizeOpenAIStrictCompatSchema(tool.parameters), - }; }); } @@ -503,12 +554,18 @@ export function normalizeDeepSeekToolSchemas( /** Provider tool-schema normalization context containing the active tool list. */ ctx: ProviderNormalizeToolSchemasContext, ): AnyAgentTool[] { - return ctx.tools.map((tool) => { - if (!tool.parameters || typeof tool.parameters !== "object") { + return ctx.tools.map((tool, toolIndex) => { + const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex); + if (!snapshot || !isSchemaRecord(snapshot.parameters)) { return tool; } - const parameters = normalizeDeepSeekSchema(tool.parameters); - return parameters === tool.parameters + let parameters: unknown; + try { + parameters = normalizeDeepSeekSchema(snapshot.parameters); + } catch { + return tool; + } + return parameters === snapshot.parameters ? tool : { ...tool, @@ -525,15 +582,19 @@ export function inspectDeepSeekToolSchemas( ctx: ProviderNormalizeToolSchemasContext, ): ProviderToolSchemaDiagnostic[] { return ctx.tools.flatMap((tool, toolIndex) => { - const violations = findUnsupportedSchemaKeywords( - tool.parameters, - `${tool.name}.parameters`, - DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS, - ); - if (violations.length === 0) { + const snapshot = readProviderToolSchemaSnapshot(tool, toolIndex); + if (!snapshot) { return []; } - return [{ toolName: tool.name, toolIndex, violations }]; + const violations = findUnsupportedSchemaKeywordsSafe( + snapshot.parameters, + `${snapshot.name}.parameters`, + DEEPSEEK_UNSUPPORTED_SCHEMA_KEYWORDS, + ); + if (!violations || violations.length === 0) { + return []; + } + return [{ toolName: snapshot.name, toolIndex, violations }]; }); }