diff --git a/docs/current-handoff.md b/docs/current-handoff.md index a01fe08..ef564e7 100644 --- a/docs/current-handoff.md +++ b/docs/current-handoff.md @@ -36,7 +36,8 @@ If YES, the next live experiment is one timed/costed OpenAI UI investigation mea ## OpenAI alternate structured output - The OpenAI provider now honors a caller-supplied structured-output schema, applying its existing strict-schema transport projection; absent an alternate schema, initial reconstruction retains its existing strict schema and transport normalization. -- This deterministically fixes the focused-deconstruction 502 root cause: its six-field schema now reaches OpenAI rather than being replaced by the initial-reconstruction schema. Zero live calls occurred. +- The next live run exposed incomplete recursive strict projection: OpenAI rejected `relationships.items` because it lacked `additionalProperties: false`. The projector now recognizes every `type: "object"` node, including property-less objects in array items, and recursively enforces strict object schemas while preserving initial-reconstruction optionality/nullability behavior. +- This deterministically fixes the focused-deconstruction transport failures. Provider and focused-route suites pass with zero live calls. - Next boundary: retry one fresh real Playwright-driven OpenAI/Terra investigation; do not reuse the failed focused-deconstruction attempt. ## Repository checkpoint diff --git a/lib/llm/provider.js b/lib/llm/provider.js index 1ca6762..9fa2182 100644 --- a/lib/llm/provider.js +++ b/lib/llm/provider.js @@ -112,7 +112,7 @@ const openAIReconstructionJsonSchema = createOpenAIStrictSchema( /** @internal OpenAI Structured Outputs requires every object property. */ export function createOpenAIStrictSchema(schema, zodSchema = reconstructionV2Schema) { const projected = structuredClone(schema); - projectOpenAIStrictSchema(projected, zodSchema, projected); + projectOpenAIStrictSchema(projected, zodSchema, projected, new WeakSet()); return projected; } @@ -157,10 +157,12 @@ function zodAcceptsNull(schema) { return schema?.isNullable?.() === true; } -function projectOpenAIStrictSchema(schema, zodSchema, rootSchema) { +function projectOpenAIStrictSchema(schema, zodSchema, rootSchema, visited) { const resolved = resolveSchema(schema, rootSchema); + if (!resolved || typeof resolved !== "object" || visited.has(resolved)) return; + visited.add(resolved); const shape = zodObjectShape(zodSchema); - if (resolved?.properties) { + if (resolved?.type === "object" || resolved?.properties) { if (shape) { for (const [key, property] of Object.entries(resolved.properties)) { const propertyZodSchema = shape[key]; @@ -169,17 +171,28 @@ function projectOpenAIStrictSchema(schema, zodSchema, rootSchema) { } } } - resolved.required = Object.keys(resolved.properties); + if (resolved.properties) resolved.required = Object.keys(resolved.properties); + resolved.additionalProperties = false; } if (resolved?.items) { - projectOpenAIStrictSchema(resolved.items, zodArrayItem(zodSchema), rootSchema); + projectOpenAIStrictSchema(resolved.items, zodArrayItem(zodSchema), rootSchema, visited); } if (resolved?.properties) { for (const [key, property] of Object.entries(resolved.properties)) { - projectOpenAIStrictSchema(property, shape?.[key], rootSchema); + projectOpenAIStrictSchema(property, shape?.[key], rootSchema, visited); } } + for (const branch of [ + ...(resolved.anyOf ?? []), + ...(resolved.oneOf ?? []), + ...(resolved.allOf ?? []), + ]) { + projectOpenAIStrictSchema(branch, null, rootSchema, visited); + } + for (const definition of Object.values(resolved.$defs ?? resolved.definitions ?? {})) { + projectOpenAIStrictSchema(definition, null, rootSchema, visited); + } } function schemaAllowsNull(schema, rootSchema) { diff --git a/tests/llm/provider.test.js b/tests/llm/provider.test.js index 3ee063c..adcf95c 100644 --- a/tests/llm/provider.test.js +++ b/tests/llm/provider.test.js @@ -220,6 +220,7 @@ describe("OpenAI reconstruction provider experiment seam", () => { const resolved = resolveLocalRef(schema, root); if (resolved?.properties) { expect(resolved.required).toEqual(Object.keys(resolved.properties)); + expect(resolved.additionalProperties).toBe(false); Object.values(resolved.properties).forEach((property) => assertAllPropertiesRequired(property, root)); } (resolved?.anyOf ?? []).forEach((branch) => assertAllPropertiesRequired(branch, root)); @@ -337,7 +338,9 @@ describe("OpenAI reconstruction provider experiment seam", () => { "possibleFollowUpQuestions", ]); expect(Object.keys(schema.properties)).toEqual(schema.required); + expect(schema.additionalProperties).toBe(false); expect(schema.properties).toHaveProperty("targetNodeId"); + expect(schema.properties.relationships.items.additionalProperties).toBe(false); expect(schema.properties).not.toHaveProperty("reconstruction"); expect(schema.properties).not.toHaveProperty("inputClassification"); expect(result.response).toEqual({ @@ -350,6 +353,46 @@ describe("OpenAI reconstruction provider experiment seam", () => { }); }); + it("recursively projects arbitrary nested object schemas as strict", () => { + const alternateSchema = { + type: "object", + properties: { + groups: { + type: "array", + items: { + type: "object", + properties: { + details: { + type: "object", + properties: { value: { type: "string" } }, + }, + }, + }, + }, + }, + }; + + const projected = createOpenAIStrictSchema(alternateSchema, null); + + expect(projected.additionalProperties).toBe(false); + expect(projected.properties.groups.items.additionalProperties).toBe(false); + expect(projected.properties.groups.items.properties.details.additionalProperties).toBe(false); + }); + + it("projects property-less objects nested in arrays as strict", () => { + const projected = createOpenAIStrictSchema({ + type: "object", + properties: { + relationships: { type: "array", items: { type: "object" } }, + }, + }, null); + + expect(projected.properties.relationships.items).toEqual({ + type: "object", + additionalProperties: false, + }); + }); + it("extracts ordered output_text parts from raw Responses output", async () => { const provider = createOpenAIReconstructionProvider({ apiKey: "test-key",