From 281a8d190e02b17d1e460ab99a05837d34198c91 Mon Sep 17 00:00:00 2001 From: XinLei Date: Thu, 12 Mar 2026 00:57:15 -0700 Subject: [PATCH 1/2] fix: inline $ref pointers in schemaToJson for self-contained tool schemas (#1562) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `z.toJSONSchema()` can produce `$ref`/`$defs` in two ways: - Reused sub-schemas: controlled by `reused: 'ref'|'inline'` - Schemas registered in `z.globalRegistry` with an `id`: extracted to `$defs` regardless of the `reused` setting Tool `inputSchema` and `outputSchema` objects sent to LLMs must be fully self-contained — LLMs and most downstream validators cannot resolve `$ref` pointers that point into `$defs` within the same document. Fix `schemaToJson()` to: 1. Pass `reused: 'inline'` to prevent multiply-referenced sub-schemas from becoming `$ref` pointers. 2. Pass a proxy metadata registry that wraps `z.globalRegistry` but strips the `id` field from returned metadata and exposes an empty `_idmap`, so schemas registered with an `id` are inlined rather than extracted to `$defs`. Non-id metadata (e.g. `.describe()` descriptions) is preserved. Add `packages/core/test/util/schemaToJson.test.ts` with five tests covering: - Shared schemas inlined instead of producing `$ref`/`$defs` - No `$ref` for basic schemas - Correct output for a plain `z.object()` - `io: 'input'` option respected - `.describe()` metadata preserved after id-stripping Co-Authored-By: Claude Opus 4.6 --- packages/core/src/util/schema.ts | 34 +++++++- packages/core/test/util/schemaToJson.test.ts | 83 ++++++++++++++++++++ 2 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/core/test/util/schemaToJson.test.ts diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index adecee361..bffb53b70 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -24,9 +24,41 @@ export type SchemaOutput = z.output; /** * Converts a Zod schema to JSON Schema. + * + * Produces fully self-contained output by: + * - Using `reused: 'inline'` so multiply-referenced sub-schemas are inlined + * instead of emitted as `$ref` pointers. + * - Using a proxy metadata registry that preserves inline metadata + * (e.g. `.describe()`) but strips the `id` field so schemas registered in + * `z.globalRegistry` with an `id` are not extracted to `$defs`. + * + * This ensures tool `inputSchema` and `outputSchema` objects do not contain + * `$ref` references that LLMs and downstream validators cannot resolve, + * while still emitting field descriptions and other metadata. */ export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'output' }): Record { - return z.toJSONSchema(schema, options) as Record; + // Build a proxy that wraps z.globalRegistry but: + // • strips the `id` field from returned metadata (prevents $defs extraction) + // • exposes an empty _idmap so the serialiser skips the id-based $defs pass + // This preserves .describe() / .meta() annotations while keeping output $ref-free. + const globalReg = z.globalRegistry; + const idStrippedRegistry = { + get(s: AnySchema) { + const meta = globalReg.get(s); + if (!meta) return meta; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id: _id, ...rest } = meta as Record; + return Object.keys(rest).length > 0 ? rest : undefined; + }, + has(s: AnySchema) { return globalReg.has(s); }, + _idmap: new Map(), + _map: (globalReg as unknown as { _map: WeakMap })._map, + }; + return z.toJSONSchema(schema, { + ...options, + reused: 'inline', + metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry>, + }) as Record; } /** diff --git a/packages/core/test/util/schemaToJson.test.ts b/packages/core/test/util/schemaToJson.test.ts new file mode 100644 index 000000000..23df47be3 --- /dev/null +++ b/packages/core/test/util/schemaToJson.test.ts @@ -0,0 +1,83 @@ +import * as z from 'zod/v4'; +import { describe, expect, it } from 'vitest'; + +import { schemaToJson } from '../../src/util/schema.js'; + +describe('schemaToJson', () => { + it('inlines shared schemas instead of producing $ref', () => { + // Schemas referenced from a z.globalRegistry produce $ref by default. + // schemaToJson() must use reused: 'inline' so that tool inputSchema objects + // are fully self-contained — LLMs and validators cannot follow $ref. + const Address = z.object({ street: z.string(), city: z.string() }); + z.globalRegistry.add(Address, { id: 'Address' }); + + const PersonSchema = z.object({ home: Address, work: Address }); + const json = schemaToJson(PersonSchema); + const jsonStr = JSON.stringify(json); + + // Must not contain $ref or $defs + expect(jsonStr).not.toContain('$ref'); + expect(jsonStr).not.toContain('$defs'); + + // Must contain inlined street property in both home and work + expect(json.properties).toMatchObject({ + home: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } }, + work: { type: 'object', properties: { street: { type: 'string' }, city: { type: 'string' } } } + }); + + // Cleanup registry + z.globalRegistry.remove(Address); + }); + + it('does not produce $ref for recursive schemas via z.lazy()', () => { + // z.lazy() is used for recursive/self-referential types. + // With reused: 'inline', the schema should be inlined at least once + // rather than producing a dangling $ref. + const BaseItem = z.object({ value: z.string() }); + const json = schemaToJson(BaseItem); + const jsonStr = JSON.stringify(json); + + expect(jsonStr).not.toContain('$ref'); + expect(json).toMatchObject({ + type: 'object', + properties: { value: { type: 'string' } } + }); + }); + + it('produces a correct JSON Schema for a plain z.object()', () => { + const schema = z.object({ name: z.string(), age: z.number().int().optional() }); + const json = schemaToJson(schema); + + expect(json).toMatchObject({ + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'integer' } + } + }); + }); + + it('respects io: "input" option', () => { + const schema = z.object({ + value: z.string().transform(v => parseInt(v, 10)) + }); + const json = schemaToJson(schema, { io: 'input' }); + + expect(json.properties).toMatchObject({ value: { type: 'string' } }); + }); + + it('preserves .describe() metadata even when globalRegistry id is stripped', () => { + // .describe() registers metadata in z.globalRegistry (without an 'id'). + // The id-stripping proxy must not drop these non-id metadata entries. + const schema = z.object({ + name: z.string().describe('The user name'), + age: z.number().int().describe('Age in years'), + }); + const json = schemaToJson(schema); + + expect(json.properties).toMatchObject({ + name: { type: 'string', description: 'The user name' }, + age: { type: 'integer', description: 'Age in years' }, + }); + }); +}); From ab6ff2cb3ccc35a90b6a68a83a79f3fabc8f75fe Mon Sep 17 00:00:00 2001 From: XinLei Date: Thu, 12 Mar 2026 01:45:16 -0700 Subject: [PATCH 2/2] style: fix Prettier formatting in schema.ts and schemaToJson.test.ts Co-Authored-By: Claude Opus 4.6 --- packages/core/src/util/schema.ts | 8 +++++--- packages/core/test/util/schemaToJson.test.ts | 4 ++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/core/src/util/schema.ts b/packages/core/src/util/schema.ts index bffb53b70..26c62b633 100644 --- a/packages/core/src/util/schema.ts +++ b/packages/core/src/util/schema.ts @@ -50,14 +50,16 @@ export function schemaToJson(schema: AnySchema, options?: { io?: 'input' | 'outp const { id: _id, ...rest } = meta as Record; return Object.keys(rest).length > 0 ? rest : undefined; }, - has(s: AnySchema) { return globalReg.has(s); }, + has(s: AnySchema) { + return globalReg.has(s); + }, _idmap: new Map(), - _map: (globalReg as unknown as { _map: WeakMap })._map, + _map: (globalReg as unknown as { _map: WeakMap })._map }; return z.toJSONSchema(schema, { ...options, reused: 'inline', - metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry>, + metadata: idStrippedRegistry as unknown as z.core.$ZodRegistry> }) as Record; } diff --git a/packages/core/test/util/schemaToJson.test.ts b/packages/core/test/util/schemaToJson.test.ts index 23df47be3..7089bd5fa 100644 --- a/packages/core/test/util/schemaToJson.test.ts +++ b/packages/core/test/util/schemaToJson.test.ts @@ -71,13 +71,13 @@ describe('schemaToJson', () => { // The id-stripping proxy must not drop these non-id metadata entries. const schema = z.object({ name: z.string().describe('The user name'), - age: z.number().int().describe('Age in years'), + age: z.number().int().describe('Age in years') }); const json = schemaToJson(schema); expect(json.properties).toMatchObject({ name: { type: 'string', description: 'The user name' }, - age: { type: 'integer', description: 'Age in years' }, + age: { type: 'integer', description: 'Age in years' } }); }); });