diff --git a/CHANGELOG.md b/CHANGELOG.md index fce390b1154..37e39453d64 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Agents/OpenAI Responses: normalize raw bundled MCP tool schemas on the WebSocket/Responses path so bare-object, object-ish, and top-level union MCP tools no longer get rejected by OpenAI during tool registration. (#58299) Thanks @yelog. - ACP/security: replace ACP's dangerous-tool name override with semantic approval classes, so only narrow readonly reads/searches can auto-approve while indirect exec-capable and control-plane tools always require explicit prompt approval. Thanks @vincentkoc. - ACP/sessions_spawn: register ACP child runs for completion tracking and lifecycle cleanup, and make registration-failure cleanup explicitly best-effort so callers do not assume an already-started ACP turn was fully aborted. (#40885) Thanks @xaeon2026 and @vincentkoc. - ACP/tasks: mark cleanly exited ACP runs as blocked when they end on deterministic write or authorization blockers, and wake the parent session with a follow-up instead of falsely reporting success. diff --git a/src/agents/openai-ws-message-conversion.ts b/src/agents/openai-ws-message-conversion.ts index 214cd943707..72dcf702404 100644 --- a/src/agents/openai-ws-message-conversion.ts +++ b/src/agents/openai-ws-message-conversion.ts @@ -8,6 +8,7 @@ import type { OpenAIResponsesAssistantPhase, ResponseObject, } from "./openai-ws-connection.js"; +import { normalizeToolParameterSchema } from "./pi-tools.schema.js"; import { buildAssistantMessage, buildUsageWithNoCost } from "./stream-message-shared.js"; type AnyMessage = Message & { role: string; content: unknown }; @@ -277,18 +278,11 @@ export function convertTools(tools: Context["tools"]): FunctionToolDefinition[] return []; } return tools.map((tool) => { - const params = (tool.parameters ?? {}) as Record; - // Ensure `type: "object"` schemas include `properties` — the OpenAI Responses - // API rejects bare `{ type: "object" }` from MCP tools with no parameters. - const normalizedParams = - params.type === "object" && !("properties" in params) - ? { ...params, properties: {} } - : params; return { type: "function" as const, name: tool.name, description: typeof tool.description === "string" ? tool.description : undefined, - parameters: normalizedParams, + parameters: normalizeToolParameterSchema(tool.parameters ?? {}) as Record, }; }); } diff --git a/src/agents/openai-ws-stream.test.ts b/src/agents/openai-ws-stream.test.ts index 424f6301fbf..56aaf640261 100644 --- a/src/agents/openai-ws-stream.test.ts +++ b/src/agents/openai-ws-stream.test.ts @@ -369,6 +369,58 @@ describe("convertTools", () => { }); }); + it("adds missing top-level type for raw object-ish MCP schemas", () => { + const tools = [ + { + name: "query", + description: "Run a query", + parameters: { properties: { q: { type: "string" } }, required: ["q"] }, + }, + ]; + const result = convertTools(tools as unknown as Parameters[0]); + expect(result[0]?.parameters).toEqual({ + type: "object", + properties: { q: { type: "string" } }, + required: ["q"], + }); + }); + + it("flattens raw top-level anyOf MCP schemas into one object schema", () => { + const tools = [ + { + name: "dispatch", + description: "Dispatch an action", + parameters: { + anyOf: [ + { + type: "object", + properties: { action: { const: "ping" } }, + required: ["action"], + }, + { + type: "object", + properties: { + action: { const: "echo" }, + text: { type: "string" }, + }, + required: ["action", "text"], + }, + ], + }, + }, + ]; + const result = convertTools(tools as unknown as Parameters[0]); + expect(result[0]?.parameters).toEqual({ + type: "object", + properties: { + action: { type: "string", enum: ["ping", "echo"] }, + text: { type: "string" }, + }, + required: ["action"], + additionalProperties: true, + }); + }); + it("preserves existing properties on type:object schemas", () => { const tools = [ { diff --git a/src/agents/pi-tools.schema.ts b/src/agents/pi-tools.schema.ts index 92ba24b4d8e..902c6ad0937 100644 --- a/src/agents/pi-tools.schema.ts +++ b/src/agents/pi-tools.schema.ts @@ -67,21 +67,14 @@ function mergePropertySchemas(existing: unknown, incoming: unknown): unknown { return existing; } -export function normalizeToolParameters( - tool: AnyAgentTool, +export function normalizeToolParameterSchema( + schema: unknown, options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, -): AnyAgentTool { - function preserveToolMeta(target: AnyAgentTool): AnyAgentTool { - copyPluginToolMeta(tool, target); - copyChannelAgentToolMeta(tool as never, target as never); - return target; - } - const schema = - tool.parameters && typeof tool.parameters === "object" - ? (tool.parameters as Record) - : undefined; - if (!schema) { - return tool; +): unknown { + const schemaRecord = + schema && typeof schema === "object" ? (schema as Record) : undefined; + if (!schemaRecord) { + return schema; } // Provider quirks: @@ -92,7 +85,6 @@ export function normalizeToolParameters( // - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright. // // Normalize once here so callers can always pass `tools` through unchanged. - const isGeminiProvider = options?.modelProvider?.toLowerCase().includes("google") || options?.modelProvider?.toLowerCase().includes("gemini"); @@ -109,55 +101,41 @@ export function normalizeToolParameters( return s; } - // If schema already has type + properties (no top-level anyOf to merge), - // clean it for Gemini/xAI compatibility as appropriate. - if ("type" in schema && "properties" in schema && !Array.isArray(schema.anyOf)) { - return preserveToolMeta({ - ...tool, - parameters: applyProviderCleaning(schema), - }); - } - - // Some tool schemas (esp. unions) may omit `type` at the top-level. If we see - // object-ish fields, force `type: "object"` so OpenAI accepts the schema. if ( - !("type" in schema) && - (typeof schema.properties === "object" || Array.isArray(schema.required)) && - !Array.isArray(schema.anyOf) && - !Array.isArray(schema.oneOf) + "type" in schemaRecord && + "properties" in schemaRecord && + !Array.isArray(schemaRecord.anyOf) ) { - const schemaWithType = { ...schema, type: "object" }; - return preserveToolMeta({ - ...tool, - parameters: applyProviderCleaning(schemaWithType), - }); + return applyProviderCleaning(schemaRecord); } - // MCP tools with no parameters produce `{ type: "object" }` without `properties`. - // The OpenAI function-calling API rejects bare `{ type: "object" }` schemas. - // Inject an empty `properties` object — semantically identical in JSON Schema. if ( - "type" in schema && - !("properties" in schema) && - !Array.isArray(schema.anyOf) && - !Array.isArray(schema.oneOf) + !("type" in schemaRecord) && + (typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) && + !Array.isArray(schemaRecord.anyOf) && + !Array.isArray(schemaRecord.oneOf) ) { - const schemaWithProperties = { ...schema, properties: {} }; - return preserveToolMeta({ - ...tool, - parameters: applyProviderCleaning(schemaWithProperties), - }); + return applyProviderCleaning({ ...schemaRecord, type: "object" }); } - const variantKey = Array.isArray(schema.anyOf) + if ( + "type" in schemaRecord && + !("properties" in schemaRecord) && + !Array.isArray(schemaRecord.anyOf) && + !Array.isArray(schemaRecord.oneOf) + ) { + return applyProviderCleaning({ ...schemaRecord, properties: {} }); + } + + const variantKey = Array.isArray(schemaRecord.anyOf) ? "anyOf" - : Array.isArray(schema.oneOf) + : Array.isArray(schemaRecord.oneOf) ? "oneOf" : null; if (!variantKey) { - return tool; + return schema; } - const variants = schema[variantKey] as unknown[]; + const variants = schemaRecord[variantKey] as unknown[]; const mergedProperties: Record = {}; const requiredCounts = new Map(); let objectVariants = 0; @@ -189,8 +167,8 @@ export function normalizeToolParameters( } } - const baseRequired = Array.isArray(schema.required) - ? schema.required.filter((key) => typeof key === "string") + const baseRequired = Array.isArray(schemaRecord.required) + ? schemaRecord.required.filter((key) => typeof key === "string") : undefined; const mergedRequired = baseRequired && baseRequired.length > 0 @@ -201,25 +179,45 @@ export function normalizeToolParameters( .map(([key]) => key) : undefined; - const nextSchema: Record = { ...schema }; + const nextSchema: Record = { ...schemaRecord }; const flattenedSchema = { type: "object", ...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}), ...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}), properties: - Object.keys(mergedProperties).length > 0 ? mergedProperties : (schema.properties ?? {}), + Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}), ...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}), - additionalProperties: "additionalProperties" in schema ? schema.additionalProperties : true, + additionalProperties: + "additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true, }; + // Flatten union schemas into a single object schema: + // - Gemini doesn't allow top-level `type` together with `anyOf`. + // - OpenAI rejects schemas without top-level `type: "object"`. + // - Anthropic accepts proper JSON Schema with constraints. + // Merging properties preserves useful enums like `action` while keeping schemas portable. + return applyProviderCleaning(flattenedSchema); +} + +export function normalizeToolParameters( + tool: AnyAgentTool, + options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig }, +): AnyAgentTool { + function preserveToolMeta(target: AnyAgentTool): AnyAgentTool { + copyPluginToolMeta(tool, target); + copyChannelAgentToolMeta(tool as never, target as never); + return target; + } + const schema = + tool.parameters && typeof tool.parameters === "object" + ? (tool.parameters as Record) + : undefined; + if (!schema) { + return tool; + } return preserveToolMeta({ ...tool, - // Flatten union schemas into a single object schema: - // - Gemini doesn't allow top-level `type` together with `anyOf`. - // - OpenAI rejects schemas without top-level `type: "object"`. - // - Anthropic accepts proper JSON Schema with constraints. - // Merging properties preserves useful enums like `action` while keeping schemas portable. - parameters: applyProviderCleaning(flattenedSchema), + parameters: normalizeToolParameterSchema(schema, options), }); }