fix(ai): add types to untyped enums in Moonshot tool schemas (#50961)

This commit is contained in:
Aiden Cline
2026-09-23 15:04:48 -05:00
committed by GitHub
parent d31ce33ca5
commit fb4422ef62
2 changed files with 47 additions and 3 deletions
+15 -3
View File
@@ -9,12 +9,24 @@ const tupleItemsSchema = (items: ReadonlyArray<unknown>) => {
return { anyOf: projected }
}
// Moonshot rejects an `enum` without a sibling `type`. Its `type` may be one type, or one type plus "null".
const enumType = (values: ReadonlyArray<unknown>) => {
const types = [
...new Set(values.map((value) => (value === null ? "null" : Array.isArray(value) ? "array" : typeof value))),
]
if (types.length === 1) return types[0]
if (types.length === 2 && types.includes("null")) return [...types.filter((type) => type !== "null"), "null"]
return undefined
}
const moonshotNode = (schema: unknown): unknown => {
if (Array.isArray(schema)) return schema.map(moonshotNode)
if (!isRecord(schema)) return schema
if (typeof schema.$ref === "string") return { $ref: schema.$ref }
return Object.fromEntries(
Object.entries(schema).flatMap(([key, value]) => {
const type = schema.type === undefined && Array.isArray(schema.enum) ? enumType(schema.enum) : undefined
return Object.fromEntries([
...(type === undefined ? [] : [["type", type]]),
...Object.entries(schema).flatMap(([key, value]) => {
if (key === "items" && Array.isArray(value)) return [[key, tupleItemsSchema(value)]]
if (key === "prefixItems") {
if ("items" in schema) return []
@@ -23,7 +35,7 @@ const moonshotNode = (schema: unknown): unknown => {
if (key === "unevaluatedItems") return []
return [[key, moonshotNode(value)]]
}),
)
])
}
const moonshot = (schema: JsonSchema): JsonSchema => {
@@ -28,6 +28,38 @@ describe("tool schema projections", () => {
})
})
test("moonshot derives a type for untyped enums", () => {
expect(
ToolSchemaProjection.moonshot({
type: "object",
properties: {
kind: { description: "The kind of flag", enum: ["boolean", "string"] },
level: { enum: [1, 2.5] },
optional: { enum: [null, "a"] },
choice: { anyOf: [{ enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { enum: ["x"] } },
map: { type: "object", additionalProperties: { enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { enum: ["fast"] } },
}),
).toEqual({
type: "object",
properties: {
kind: { type: "string", description: "The kind of flag", enum: ["boolean", "string"] },
level: { type: "number", enum: [1, 2.5] },
optional: { type: ["string", "null"], enum: [null, "a"] },
choice: { anyOf: [{ type: "boolean", enum: [true, false] }, { type: "null" }] },
list: { type: "array", items: { type: "string", enum: ["x"] } },
map: { type: "object", additionalProperties: { type: "string", enum: ["y"] } },
typed: { type: "string", enum: ["a", null] },
mixed: { enum: ["a", 1] },
},
$defs: { Mode: { type: "string", enum: ["fast"] } },
})
})
test("gemini handles numeric enums, dangling required fields, untyped arrays, and scalar object keys", () => {
expect(
ToolSchemaProjection.gemini({