-
Notifications
You must be signed in to change notification settings - Fork 0
[rig-sampler] feat(s): add s.nullable schema helper for T | null types #32
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 |
|---|---|---|
|
|
@@ -28,7 +28,7 @@ | |
| * T:JsonSchemaObject type {[key:string]:unknown} plain JSON Schema object | ||
| * s.string/number/integer/boolean/null SchemaHelperFactory primitives; call as value or fn(desc) | ||
| * s.array(items,desc?) ArraySchema; use for homogeneous lists, e.g. s.array(s.string) | ||
| * s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; use for fixed-key shapes | ||
| * s.object(props,desc?) ObjectSchema; s.optional(inner) marks field optional; s.nullable(inner) accepts inner|null; use for fixed-key shapes | ||
| * s.record(valSchema,desc?) RecordSchema keyed by string; use for open-ended key→value maps | ||
| * s.enum(...values|values,desc) EnumSchema | ||
| * s.unknown unconstrained JSON; call as value or s.unknown("description") | ||
|
|
@@ -82,6 +82,7 @@ const OPTIONAL_SYMBOL: unique symbol = Symbol("rig.optional"); | |
| type OptionalMarker = { readonly [OPTIONAL_SYMBOL]: true }; | ||
| type UnwrapOptional<T> = Omit<T, typeof OPTIONAL_SYMBOL>; | ||
| export type OptionalSchema<Inner extends Schema = Schema> = Inner & OptionalMarker; | ||
| export type NullableSchema<Inner extends Schema = Schema> = { nullable: true; inner: Inner; description?: string }; | ||
|
|
||
| export type Schema = | ||
| | StringSchema | ||
|
|
@@ -94,7 +95,8 @@ export type Schema = | |
| | ObjectSchema<any> | ||
| | RecordSchema<any> | ||
| | EnumSchema<any> | ||
| | OptionalSchema<any>; | ||
| | OptionalSchema<any> | ||
| | NullableSchema<any>; | ||
|
|
||
| type SchemaHelperFactory<T extends Schema> = T & ((description?: string) => T); | ||
|
|
||
|
|
@@ -174,6 +176,7 @@ export type AgentInputValue<T> = | |
| T | PromptIntent | PromptBuilder; | ||
|
|
||
| export type InferSchema<T> = | ||
| T extends { nullable: true; inner: infer Inner extends Schema } ? InferSchema<Inner> | null : | ||
|
Contributor
Author
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. [/tdd] The Why this mattersTypeScript structural typing means |
||
| T extends OptionalMarker ? InferSchema<UnwrapOptional<T>> | undefined : | ||
| T extends { type: "string" } ? string : | ||
| T extends { type: "number" } ? number : | ||
|
|
@@ -257,6 +260,20 @@ export const s = { | |
| optional<Inner extends Schema>(schema: Inner, description?: string): OptionalSchema<Inner> { | ||
| return markAsOptional(cloneSchema(schema, description)); | ||
| }, | ||
| /** | ||
| * Wraps a schema to also accept `null`. The serialized JSON Schema uses `anyOf` | ||
| * with the inner schema and `{"type":"null"}`. The inferred TypeScript type is | ||
| * `InferSchema<Inner> | null`. | ||
| * | ||
| * @example | ||
| * s.nullable(s.string) // string | null | ||
| * s.nullable(s.number, "score or null") // number | null with description | ||
| */ | ||
| nullable<Inner extends Schema>(schema: Inner, description?: string): NullableSchema<Inner> { | ||
| return markAsSchema(description !== undefined | ||
| ? { nullable: true, inner: schema, description } | ||
| : { nullable: true, inner: schema }); | ||
| }, | ||
| /** Converts a rig `Schema` to a plain JSON Schema object. */ | ||
| toJsonSchema, | ||
| }; | ||
|
|
@@ -268,9 +285,13 @@ export function toJsonSchema(schema: Schema): JsonSchemaObject { | |
| } | ||
|
|
||
| function serializeSchema(schema: Schema): JsonSchemaObject { | ||
| const { description } = schema; | ||
| const { description } = schema as { description?: string }; | ||
| const withDescription = (obj: JsonSchemaObject): JsonSchemaObject => | ||
| description === undefined ? obj : { ...obj, description }; | ||
| if ("nullable" in schema && schema.nullable === true) { | ||
| const inner = serializeSchema((schema as NullableSchema).inner); | ||
| return withDescription({ anyOf: [inner, { type: "null" }] }); | ||
| } | ||
| if ("enum" in schema) { | ||
|
Contributor
Author
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. [/tdd] The |
||
| const enumValues = schema.enum as readonly unknown[]; | ||
| const allStrings = enumValues.length > 0 && enumValues.every((v) => typeof v === "string"); | ||
|
Contributor
Author
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. [/grill-with-docs] |
||
|
|
@@ -1381,6 +1402,10 @@ function validateSchema(value: unknown, schema: Schema, path: string, optional: | |
| if ((optional || isOptionalSchema(schema)) && value === undefined) { | ||
| return { ok: true }; | ||
| } | ||
| if ("nullable" in schema && schema.nullable === true) { | ||
| if (value === null) return ok(); | ||
| return validateSchema(value, (schema as NullableSchema).inner, path, false); | ||
| } | ||
| if ("enum" in schema) { | ||
| return schema.enum.some((item: Json) => deepEqual(item, value)) | ||
| ? ok() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1085,6 +1085,40 @@ describe("toJsonSchema", () => { | |
| }); | ||
| }); | ||
|
|
||
| describe("s.nullable", () => { | ||
| it("serializes to anyOf with null", () => { | ||
| expect(toJsonSchema(s.nullable(s.string))).toEqual({ anyOf: [{ type: "string" }, { type: "null" }] }); | ||
| expect(toJsonSchema(s.nullable(s.number))).toEqual({ anyOf: [{ type: "number" }, { type: "null" }] }); | ||
| }); | ||
|
|
||
| it("includes description when provided", () => { | ||
| expect(toJsonSchema(s.nullable(s.string, "maybe text"))).toEqual({ | ||
| anyOf: [{ type: "string" }, { type: "null" }], | ||
| description: "maybe text", | ||
| }); | ||
| }); | ||
|
|
||
| it("accepts null values during validation", async () => { | ||
| mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: null })); | ||
| const a = agent({ output: s.object({ score: s.nullable(s.number) }) }); | ||
| const result = await a(""); | ||
| expect(result).toEqual({ score: null }); | ||
| }); | ||
|
|
||
| it("accepts non-null values during validation", async () => { | ||
| mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: 42 })); | ||
| const a = agent({ output: s.object({ score: s.nullable(s.number) }) }); | ||
| const result = await a(""); | ||
| expect(result).toEqual({ score: 42 }); | ||
| }); | ||
|
|
||
| it("rejects invalid values that are neither null nor inner type", async () => { | ||
| mocks.setSendAndWaitImpl(async () => JSON.stringify({ score: "not-a-number" })); | ||
| const a = agent({ output: s.object({ score: s.nullable(s.number) }), maxTurns: 1 }); | ||
| await expect(a("")).rejects.toBeInstanceOf(AgentError); | ||
|
Contributor
Author
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. [/tdd] Missing edge case: |
||
| }); | ||
| }); | ||
|
|
||
| describe("s.integer validation", () => { | ||
| it("accepts whole numbers", () => { | ||
| const intAgent = agent({ output: s.integer }); | ||
|
|
||
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.
[/grill-with-docs]
NullableSchemais a plain object shape, unlike every otherSchemamember which carriesSCHEMA_SYMBOLviamarkAsSchema. This meansisSchema(s.nullable(s.string))returnsfalse, and any guard that relies on the schema-symbol invariant (INV:schema-symbol, line 54) will silently miss nullable values.Suggested fix
Call
markAsSchemathe same ways.optionaldoes: