mirror of https://github.com/openclaw/openclaw.git
fix: normalize raw MCP schemas for OpenAI Responses (#58299) (thanks @yelog)
This commit is contained in:
parent
dd3796aef3
commit
b4433a1bfe
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
// 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<string, unknown>,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof convertTools>[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<typeof convertTools>[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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>)
|
||||
: undefined;
|
||||
if (!schema) {
|
||||
return tool;
|
||||
): unknown {
|
||||
const schemaRecord =
|
||||
schema && typeof schema === "object" ? (schema as Record<string, unknown>) : 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<string, unknown> = {};
|
||||
const requiredCounts = new Map<string, number>();
|
||||
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<string, unknown> = { ...schema };
|
||||
const nextSchema: Record<string, unknown> = { ...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<string, unknown>)
|
||||
: 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),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue