Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions typescript/agentkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./agentkit";
export * from "./wallet-providers";
export * from "./action-providers";
export * from "./network";
export { resolveJsonSchemaRefs } from "./resolveJsonSchemaRefs";
128 changes: 128 additions & 0 deletions typescript/agentkit/src/resolveJsonSchemaRefs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { resolveJsonSchemaRefs } from "./resolveJsonSchemaRefs";

describe("resolveJsonSchemaRefs", () => {
it("returns schema unchanged when no $ref present", () => {
const schema = {
type: "object",
properties: {
name: { type: "string" },
},
};
expect(resolveJsonSchemaRefs(schema)).toEqual(schema);
});

it("inlines a simple $ref", () => {
const schema = {
$ref: "#/$defs/Address",
$defs: {
Address: {
type: "object",
properties: {
street: { type: "string" },
},
},
},
};
const result = resolveJsonSchemaRefs(schema);
expect(result).toEqual({
type: "object",
properties: {
street: { type: "string" },
},
});
});

it("inlines recursive $ref up to maxDepth", () => {
// Simulates zodToJsonSchema output for a recursive type like:
// z.object({ value: z.string(), children: z.lazy(() => schema).array() })
const schema = {
$ref: "#/$defs/TreeNode",
$defs: {
TreeNode: {
type: "object",
properties: {
value: { type: "string" },
children: {
type: "array",
items: { $ref: "#/$defs/TreeNode" },
},
},
},
},
};

const result = resolveJsonSchemaRefs(schema, 2);

// Depth 0: TreeNode inlined
expect(result.type).toBe("object");
expect(result.properties.value).toEqual({ type: "string" });

// Depth 1: children[].items -> TreeNode inlined again
expect(result.properties.children.type).toBe("array");
expect(result.properties.children.items.type).toBe("object");
expect(result.properties.children.items.properties.value).toEqual({ type: "string" });

// Depth 2: hit maxDepth, replaced with empty schema
expect(result.properties.children.items.properties.children.type).toBe("array");
expect(result.properties.children.items.properties.children.items).toEqual({});
});

it("handles definitions key (not just $defs)", () => {
const schema = {
$ref: "#/definitions/Item",
definitions: {
Item: {
type: "object",
properties: { id: { type: "number" } },
},
},
};
const result = resolveJsonSchemaRefs(schema);
expect(result).toEqual({
type: "object",
properties: { id: { type: "number" } },
});
});

it("strips $defs from output", () => {
const schema = {
type: "object",
properties: {
child: { $ref: "#/$defs/Child" },
},
$defs: {
Child: { type: "object", properties: { name: { type: "string" } } },
},
};
const result = resolveJsonSchemaRefs(schema);
expect(result.$defs).toBeUndefined();
expect(result.definitions).toBeUndefined();
expect(result.properties.child).toEqual({
type: "object",
properties: { name: { type: "string" } },
});
});

it("handles null and primitive values", () => {
expect(resolveJsonSchemaRefs({ type: "string" })).toEqual({ type: "string" });
});

it("handles union types with $ref", () => {
const schema = {
type: "object",
properties: {
value: {
anyOf: [{ type: "string" }, { $ref: "#/$defs/Nested" }],
},
},
$defs: {
Nested: { type: "object", properties: { x: { type: "number" } } },
},
};
const result = resolveJsonSchemaRefs(schema);
expect(result.properties.value.anyOf).toEqual([
{ type: "string" },
{ type: "object", properties: { x: { type: "number" } } },
]);
});
});
73 changes: 73 additions & 0 deletions typescript/agentkit/src/resolveJsonSchemaRefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/* eslint-disable @typescript-eslint/no-explicit-any */

/**
* Resolves $ref references in a JSON Schema by inlining definitions.
*
* LLM function-calling APIs (OpenAI, Anthropic) reject schemas that contain
* $ref pointers. zodToJsonSchema() produces $ref for recursive Zod types
* (z.lazy). This utility inlines those references up to a configurable depth,
* replacing deeper levels with a permissive empty schema.
*
* @param schema - A JSON Schema object, typically from zodToJsonSchema()
* @param maxDepth - Maximum recursion depth for inlining (default: 3)
* @returns A new JSON Schema with all $ref pointers resolved
*/
export function resolveJsonSchemaRefs(
schema: Record<string, any>,
maxDepth = 3,
): Record<string, any> {
const definitions = schema.$defs || schema.definitions || {};

/**
* Recursively resolves $ref pointers in a JSON Schema node.
*
* @param node - The current schema node
* @param refDepth - How many $ref expansions have occurred on the current path
* @returns The resolved schema node
*/
function resolve(node: any, refDepth: number): any {
if (node == null || typeof node !== "object") {
return node;
}

if (Array.isArray(node)) {
return node.map(item => resolve(item, refDepth));
}

// Handle $ref
if (typeof node.$ref === "string") {
const refPath = node.$ref;
// Extract definition name from "#/$defs/Name" or "#/definitions/Name"
const match = refPath.match(/^#\/(?:\$defs|definitions)\/(.+)$/);
if (!match) {
return node;
}

const defName = match[1];

// Stop inlining at max depth
if (refDepth >= maxDepth) {
return {};
}

const def = definitions[defName];
if (!def) {
return node;
}

return resolve(def, refDepth + 1);
}

// Recurse into object properties
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(node)) {
if (key === "$defs" || key === "definitions") {
continue;
}
result[key] = resolve(value, refDepth);
}
return result;
}

return resolve(schema, 0);
}
Loading