Skip to content

Commit 59a9f51

Browse files
committed
fix(agents): guard OpenAI tool schema conversion
1 parent 5912b9e commit 59a9f51

4 files changed

Lines changed: 825 additions & 40 deletions

File tree

src/agents/openai-tool-schema.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { beforeEach, describe, expect, it } from "vitest";
22
import {
33
clearOpenAIToolSchemaCacheForTest,
4+
findOpenAIStrictToolSchemaDiagnostics,
45
isStrictOpenAIJsonSchemaCompatible,
56
normalizeStrictOpenAIJsonSchema,
67
resolveOpenAIStrictToolFlagForInventory,
@@ -91,4 +92,28 @@ describe("OpenAI strict tool schema normalization", () => {
9192
}),
9293
).toBe(third);
9394
});
95+
96+
it("reports unreadable schemas without aborting strict compatibility checks", () => {
97+
const schema = {
98+
type: "object",
99+
properties: {
100+
safe: { type: "string" },
101+
get broken() {
102+
throw new Error("strict schema child getter exploded");
103+
},
104+
},
105+
required: ["safe", "broken"],
106+
};
107+
const tool = { name: "fuzzplugin_strict", parameters: schema };
108+
109+
expect(isStrictOpenAIJsonSchemaCompatible(schema)).toBe(false);
110+
expect(resolveOpenAIStrictToolFlagForInventory([tool], true)).toBe(false);
111+
expect(findOpenAIStrictToolSchemaDiagnostics([tool])).toEqual([
112+
{
113+
toolIndex: 0,
114+
toolName: "fuzzplugin_strict",
115+
violations: ["fuzzplugin_strict.parameters is unreadable"],
116+
},
117+
]);
118+
});
94119
});

src/agents/openai-tool-schema.ts

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ type ToolWithParameters = {
1212
parameters: unknown;
1313
};
1414

15+
type ToolFieldRead<TField extends keyof ToolWithParameters> =
16+
| { readable: true; value: ToolWithParameters[TField] }
17+
| { readable: false };
18+
1519
const MAX_STRICT_SCHEMA_CACHE_ENTRIES_PER_SCHEMA = 8;
1620
let strictOpenAISchemaCache = new WeakMap<object, Array<{ key: string; value: unknown }>>();
1721

@@ -154,7 +158,11 @@ export function normalizeOpenAIStrictToolParameters<T>(
154158
}
155159

156160
export function isStrictOpenAIJsonSchemaCompatible(schema: unknown): boolean {
157-
return isStrictOpenAIJsonSchemaCompatibleRecursive(normalizeStrictOpenAIJsonSchema(schema));
161+
try {
162+
return isStrictOpenAIJsonSchemaCompatibleRecursive(normalizeStrictOpenAIJsonSchema(schema));
163+
} catch {
164+
return false;
165+
}
158166
}
159167

160168
type OpenAIStrictToolSchemaDiagnostic = {
@@ -167,23 +175,61 @@ export function findOpenAIStrictToolSchemaDiagnostics(
167175
tools: readonly ToolWithParameters[],
168176
): OpenAIStrictToolSchemaDiagnostic[] {
169177
return tools.flatMap((tool, toolIndex) => {
170-
const violations = findStrictOpenAIJsonSchemaViolations(
171-
normalizeStrictOpenAIJsonSchema(tool.parameters),
172-
`${typeof tool.name === "string" && tool.name ? tool.name : `tool[${toolIndex}]`}.parameters`,
173-
);
178+
const nameRead = readToolField(tool, "name");
179+
const toolName =
180+
nameRead.readable && typeof nameRead.value === "string" && nameRead.value
181+
? nameRead.value
182+
: `tool[${toolIndex}]`;
183+
const descriptorViolations = nameRead.readable ? [] : [`${toolName}.name is unreadable`];
184+
const parametersRead = readToolField(tool, "parameters");
185+
if (!parametersRead.readable) {
186+
return [
187+
{
188+
toolIndex,
189+
...(nameRead.readable && typeof nameRead.value === "string" && nameRead.value
190+
? { toolName: nameRead.value }
191+
: {}),
192+
violations: [...descriptorViolations, `${toolName}.parameters is unreadable`],
193+
},
194+
];
195+
}
196+
197+
let schemaViolations: string[];
198+
try {
199+
schemaViolations = findStrictOpenAIJsonSchemaViolations(
200+
normalizeStrictOpenAIJsonSchema(parametersRead.value),
201+
`${toolName}.parameters`,
202+
);
203+
} catch {
204+
schemaViolations = [`${toolName}.parameters is unreadable`];
205+
}
206+
const violations = [...descriptorViolations, ...schemaViolations];
174207
if (violations.length === 0) {
175208
return [];
176209
}
177210
return [
178211
{
179212
toolIndex,
180-
...(typeof tool.name === "string" && tool.name ? { toolName: tool.name } : {}),
213+
...(nameRead.readable && typeof nameRead.value === "string" && nameRead.value
214+
? { toolName: nameRead.value }
215+
: {}),
181216
violations,
182217
},
183218
];
184219
});
185220
}
186221

222+
function readToolField<TField extends keyof ToolWithParameters>(
223+
tool: ToolWithParameters,
224+
field: TField,
225+
): ToolFieldRead<TField> {
226+
try {
227+
return { readable: true, value: tool[field] };
228+
} catch {
229+
return { readable: false };
230+
}
231+
}
232+
187233
function isStrictOpenAIJsonSchemaCompatibleRecursive(schema: unknown): boolean {
188234
if (Array.isArray(schema)) {
189235
return schema.every((entry) => isStrictOpenAIJsonSchemaCompatibleRecursive(entry));
@@ -304,5 +350,8 @@ export function resolveOpenAIStrictToolFlagForInventory(
304350
if (strict !== true) {
305351
return strict === false ? false : undefined;
306352
}
307-
return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters));
353+
return tools.every((tool) => {
354+
const parametersRead = readToolField(tool, "parameters");
355+
return parametersRead.readable && isStrictOpenAIJsonSchemaCompatible(parametersRead.value);
356+
});
308357
}

0 commit comments

Comments
 (0)