Repository navigation
fix(tools): keep opaque object schemas open #3097
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,26 @@ function isPlainObject(v: unknown): v is Record<string, unknown> { | |
| return v !== null && typeof v === "object" && !Array.isArray(v); | ||
| } | ||
|
|
||
| function hasOwn(obj: Record<string, unknown>, key: string): boolean { | ||
| return Object.prototype.hasOwnProperty.call(obj, key); | ||
| } | ||
|
|
||
| function keepOpaqueObjectSchemasOpen(schema: Record<string, unknown>): void { | ||
| const explicitAdditionalProperties = hasOwn(schema, "additionalProperties"); | ||
| if (explicitAdditionalProperties) return; | ||
|
|
||
| const properties = schema.properties; | ||
| const isObjectSchema = schema.type === "object" || isPlainObject(properties); | ||
| if (!isObjectSchema) return; | ||
|
|
||
| if (properties === undefined) { | ||
| schema.properties = {}; | ||
| schema.additionalProperties = true; | ||
| } else if (isPlainObject(properties) && Object.keys(properties).length === 0) { | ||
| schema.additionalProperties = true; | ||
| } | ||
| } | ||
|
|
||
| function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> { | ||
| if (depth > MAX_RECURSION_DEPTH) return {}; | ||
| if (!isPlainObject(value)) return {}; | ||
|
|
@@ -85,15 +105,17 @@ function sanitizeSchema(value: unknown, depth = 0): Record<string, unknown> { | |
| result.required = (result.required as string[]).filter((r) => validKeys.has(r)); | ||
| } | ||
|
|
||
| keepOpaqueObjectSchemasOpen(result); | ||
|
|
||
| return result; | ||
| } | ||
|
|
||
| function normalizeParameters(parameters: unknown): unknown { | ||
| if (isPlainObject(parameters)) return sanitizeSchema(parameters); | ||
| if (parameters === null || parameters === undefined) { | ||
| return { type: "object", properties: {} }; | ||
| return { type: "object", properties: {}, additionalProperties: true }; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When Useful? React with 👍 / 👎. |
||
| } | ||
| return { type: "object", properties: {} }; | ||
| return { type: "object", properties: {}, additionalProperties: true }; | ||
| } | ||
|
|
||
| export function sanitizeOpenAITool(tool: unknown): unknown { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -28,6 +28,25 @@ function isPlainObject(value: unknown): value is JsonRecord { | |
| return Boolean(value) && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
|
|
||
| function hasOwn(obj: JsonRecord, key: string): boolean { | ||
| return Object.prototype.hasOwnProperty.call(obj, key); | ||
| } | ||
|
|
||
| function keepOpaqueObjectSchemasOpen(schema: JsonRecord): void { | ||
| if (hasOwn(schema, "additionalProperties")) return; | ||
|
|
||
| const properties = schema.properties; | ||
| const isObjectSchema = schema.type === "object" || isPlainObject(properties); | ||
| if (!isObjectSchema) return; | ||
|
Comment on lines
+38
to
+40
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In JSON Schema, the const properties = schema.properties;
const type = schema.type;
const isObjectSchema =
type === "object" ||
(Array.isArray(type) && type.includes("object")) ||
isPlainObject(properties);
if (!isObjectSchema) return; |
||
|
|
||
| if (properties === undefined) { | ||
| schema.properties = {}; | ||
| schema.additionalProperties = true; | ||
| } else if (isPlainObject(properties) && Object.keys(properties).length === 0) { | ||
| schema.additionalProperties = true; | ||
| } | ||
| } | ||
|
|
||
| function coerceNumericString(value: unknown): unknown { | ||
| if (typeof value !== "string") return value; | ||
| const trimmed = value.trim(); | ||
|
|
@@ -118,6 +137,8 @@ export function coerceSchemaNumericFields(schema: unknown): unknown { | |
| result.else = coerceSchemaNumericFields(result.else); | ||
| } | ||
|
|
||
| keepOpaqueObjectSchemasOpen(result); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For Gemini/Antigravity targets, Useful? React with 👍 / 👎. |
||
|
|
||
| return result; | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,98 @@ | ||
| import test from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
|
|
||
| import { sanitizeOpenAITool } from "../../open-sse/services/toolSchemaSanitizer.ts"; | ||
| import { coerceToolSchemas } from "../../open-sse/translator/helpers/schemaCoercion.ts"; | ||
|
|
||
| test("OpenAI sanitizer keeps generic MCP wrapper args open-world", () => { | ||
| const sanitized = sanitizeOpenAITool({ | ||
| type: "function", | ||
| function: { | ||
| name: "SPLOX_EXECUTE_TOOL", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| mcp_server_id: { type: "string" }, | ||
| slug: { type: "string" }, | ||
| args: { type: "object", properties: {} }, | ||
| }, | ||
| required: ["mcp_server_id", "slug", "args"], | ||
| }, | ||
| }, | ||
| }) as any; | ||
|
|
||
| assert.equal( | ||
| sanitized.function.parameters.properties.args.additionalProperties, | ||
| true | ||
| ); | ||
| assert.deepEqual(sanitized.function.parameters.properties.args.properties, {}); | ||
| }); | ||
|
|
||
| test("OpenAI Responses sanitizer keeps opaque execution/schema/additional_vars slots open-world", () => { | ||
| const sanitized = sanitizeOpenAITool({ | ||
| type: "function", | ||
| name: "dynamic_tools_register", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| execution: { type: "object", properties: {} }, | ||
| schema: { type: "object" }, | ||
| additional_vars: { type: "object", properties: {} }, | ||
| }, | ||
| required: ["execution", "schema"], | ||
| }, | ||
| }) as any; | ||
|
|
||
| const props = sanitized.parameters.properties; | ||
| assert.equal(props.execution.additionalProperties, true); | ||
| assert.equal(props.schema.additionalProperties, true); | ||
| assert.deepEqual(props.schema.properties, {}); | ||
| assert.equal(props.additional_vars.additionalProperties, true); | ||
| }); | ||
|
|
||
| test("schema coercion opens opaque nested objects after translation", () => { | ||
| const coerced = coerceToolSchemas([ | ||
| { | ||
| type: "function", | ||
| function: { | ||
| name: "remote_server_write_env", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| path: { type: "string" }, | ||
| additional_vars: { type: "object", properties: {} }, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| ]) as any; | ||
|
|
||
| assert.equal( | ||
| coerced[0].function.parameters.properties.additional_vars.additionalProperties, | ||
| true | ||
| ); | ||
| }); | ||
|
|
||
| test("explicitly closed object schemas stay closed", () => { | ||
| const sanitized = sanitizeOpenAITool({ | ||
| type: "function", | ||
| function: { | ||
| name: "closed", | ||
| parameters: { | ||
| type: "object", | ||
| properties: { | ||
| payload: { | ||
| type: "object", | ||
| properties: {}, | ||
| additionalProperties: false, | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }) as any; | ||
|
|
||
| assert.equal( | ||
| sanitized.function.parameters.properties.payload.additionalProperties, | ||
| false | ||
| ); | ||
| }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In JSON Schema, the
typeproperty can sometimes be defined as an array of strings (for example,type: ["object", "null"]to represent a nullable object). To ensure these schemas are also correctly identified as object schemas whenpropertiesis undefined, we should check iftypeis either"object"or an array containing"object".