diff --git a/CHANGELOG.md b/CHANGELOG.md index c460a2195c7..dbc4189c045 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai ### Fixes - fix(infra): block workspace state-directory env override [AI]. (#75940) Thanks @pgondhi987. +- MCP/OpenAI: normalize parameter-free tool schemas whose top-level object `properties` is missing, null, or invalid before sending tools to OpenAI, so MCP tools without params stay usable. Fixes #75362. Thanks @tolkonepiu and @SymbolStar. - TTS: honor explicit short `[[tts:text]]...[[/tts:text]]` blocks while keeping untagged short auto-TTS suppressed, so tagged voice replies are synthesized instead of being dropped as empty voice-only payloads. Fixes #73758. Thanks @yfge. - Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. @@ -200,7 +201,6 @@ Docs: https://docs.openclaw.ai - Plugins/hooks: derive hook `ctx.channelId` from the conversation target instead of the provider name, so Discord and other channel plugins can keep per-channel state isolated. Fixes #59881. Thanks @bradfreels. - Gateway/config: log config health-state write failures instead of silently hiding config observe-recovery write errors. Thanks @sallyom. - Diagnostics: reset stuck-session timers on reply, tool, status, block, and ACP progress events, and back off repeated `session.stuck` diagnostics while a session remains unchanged. Supersedes #72010. Thanks @rubencu. -- Agents/OpenAI: normalize parameter-free MCP tool schemas whose `properties` value is null or undefined, so OpenAI no longer rejects MCP tools without parameters. Fixes #75362. (#75401) Thanks @SymbolStar. - Gateway/agents: avoid rebuilding core tools for plugin-only allowlists and keep the full plugin registry cache warm across scoped plugin loads, reducing per-turn latency spikes. Fixes #75882, #75907, #75906, #75887, and #75851. (#75922) Thanks @obviyus. ## 2026.4.30 diff --git a/src/agents/openai-tool-schema.test.ts b/src/agents/openai-tool-schema.test.ts index 6a7154e6dad..25084e311ad 100644 --- a/src/agents/openai-tool-schema.test.ts +++ b/src/agents/openai-tool-schema.test.ts @@ -6,6 +6,29 @@ import { } from "./openai-tool-schema.js"; describe("OpenAI strict tool schema normalization", () => { + it("repairs top-level object schemas with missing or invalid properties", () => { + const schemas = [ + { type: "object" }, + { type: "object", properties: undefined }, + { type: "object", properties: null }, + { type: "object", properties: [] }, + { type: "object", properties: "invalid" }, + ]; + + for (const schema of schemas) { + expect(normalizeStrictOpenAIJsonSchema(schema)).toEqual({ + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }); + expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(true); + expect( + resolveOpenAIStrictToolFlagForInventory([{ name: "empty", parameters: schema }], true), + ).toBe(true); + } + }); + it("does not close permissive nested object schemas implicitly", () => { const schema = { type: "object", @@ -30,16 +53,6 @@ describe("OpenAI strict tool schema normalization", () => { ).toBe(false); }); - it("normalizes parameter-free MCP tool schema with properties:undefined (#75362)", () => { - const schema = { type: "object", properties: undefined } as unknown; - const normalized = normalizeStrictOpenAIJsonSchema(schema) as Record; - expect(normalized.type).toBe("object"); - expect(normalized.properties).toEqual({}); - expect(normalized.required).toEqual([]); - expect(normalized.additionalProperties).toBe(false); - expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(true); - }); - it("normalizes truly empty MCP tool schema {} for strict mode", () => { const schema = {}; const normalized = normalizeStrictOpenAIJsonSchema(schema) as Record; diff --git a/src/agents/pi-tools-parameter-schema.ts b/src/agents/pi-tools-parameter-schema.ts index ffb3c6b6c07..30834c0e71f 100644 --- a/src/agents/pi-tools-parameter-schema.ts +++ b/src/agents/pi-tools-parameter-schema.ts @@ -75,6 +75,10 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { type FlattenableVariantKey = "anyOf" | "oneOf"; type TopLevelConditionalKey = FlattenableVariantKey | "allOf"; +function isSchemaRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + function hasTopLevelArrayKeyword( schemaRecord: Record, key: TopLevelConditionalKey, @@ -108,10 +112,8 @@ function hasTopLevelObjectSchema( conditionalKey: TopLevelConditionalKey | null, ): boolean { return ( - "type" in schemaRecord && - "properties" in schemaRecord && - schemaRecord.properties != null && - typeof schemaRecord.properties === "object" && + schemaRecord.type === "object" && + isSchemaRecord(schemaRecord.properties) && conditionalKey === null ); } @@ -122,23 +124,20 @@ function isObjectLikeSchemaMissingType( ): boolean { return ( !("type" in schemaRecord) && - (typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) && + (isSchemaRecord(schemaRecord.properties) || Array.isArray(schemaRecord.required)) && conditionalKey === null ); } -function isTypedSchemaMissingProperties( +function isTypedObjectSchemaMissingValidProperties( schemaRecord: Record, conditionalKey: TopLevelConditionalKey | null, ): boolean { - if (!("type" in schemaRecord) || conditionalKey !== null) { - return false; - } - if (!("properties" in schemaRecord)) { - return true; - } - const props = schemaRecord.properties; - return props == null || typeof props !== "object" || Array.isArray(props); + return ( + schemaRecord.type === "object" && + !isSchemaRecord(schemaRecord.properties) && + conditionalKey === null + ); } function isTrulyEmptySchema(schemaRecord: Record): boolean { @@ -187,10 +186,14 @@ export function normalizeToolParameterSchema( } if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) { - return applyProviderCleaning({ ...schemaRecord, type: "object" }); + return applyProviderCleaning({ + ...schemaRecord, + type: "object", + properties: isSchemaRecord(schemaRecord.properties) ? schemaRecord.properties : {}, + }); } - if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) { + if (isTypedObjectSchemaMissingValidProperties(schemaRecord, conditionalKey)) { return applyProviderCleaning({ ...schemaRecord, properties: {} }); } diff --git a/src/agents/pi-tools.schema.test.ts b/src/agents/pi-tools.schema.test.ts index e2f0c8a0d0f..ea8d6dbc64b 100644 --- a/src/agents/pi-tools.schema.test.ts +++ b/src/agents/pi-tools.schema.test.ts @@ -48,6 +48,29 @@ describe("normalizeToolParameterSchema", () => { }); }); + it("normalizes typed object schemas with missing or invalid properties", () => { + const schemas = [ + { type: "object" }, + { type: "object", properties: undefined }, + { type: "object", properties: null }, + { type: "object", properties: [] }, + { type: "object", properties: "invalid" }, + ]; + + for (const schema of schemas) { + expect(normalizeToolParameterSchema(schema)).toEqual({ + type: "object", + properties: {}, + }); + } + }); + + it("leaves non-object typed schemas without properties unchanged", () => { + const schema = { type: "array", items: { type: "string" } }; + + expect(normalizeToolParameterSchema(schema)).toEqual(schema); + }); + it("inlines local $ref before removing unsupported keywords", () => { const cleaned = cleanToolSchemaForGemini({ type: "object",