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
21 changes: 21 additions & 0 deletions .changeset/optional-null-sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@livekit/agents': patch
---

fix(llm): treat a null tool argument as absent for optional Zod fields

`zodSchemaToJsonSchema` targets `openAi`, which rewrites an `.optional()` property
into a required nullable one because a strict tool schema must list every property
in `required`. That leaves null as the only way for a model to say "not provided",
but tool arguments are validated against the original Zod schema, where
`.optional()` accepts undefined and rejects null. Any tool with a bare
`.optional()` field therefore failed with `Arguments parsing failed` as soon as the
model filled the key in with null — reliably under `strictToolSchema`, since the
decoder then enforces `required`.

Defaulted fields already round-tripped through the null sentinel resolved in
`injectSchemaDefaults`. This extends the same inverse mapping to optional fields:
a null is dropped when the property is absent from `required` and its schema does
not allow null, so Zod sees undefined. Defaults still win, and a property that is
required or genuinely nullable keeps its null so real contract violations still
surface.
87 changes: 87 additions & 0 deletions agents/src/llm/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,93 @@ describe('executeToolCall', () => {
expect(result.output).toMatch(/Arguments parsing failed/);
});

it('should treat null as absent for an optional argument without a default', async () => {
const search = tool({
name: 'search',
description: 'search',
parameters: z.object({ query: z.string(), limit: z.number().optional() }),
execute: async (args) => args,
});

const result = await executeToolCall(
FunctionCall.create({
callId: 'call-optional-1',
name: 'search',
args: '{"query":"cats","limit":null}',
}),
new ToolContext([search]),
);

expect(result.isError).toBe(false);
expect(JSON.parse(result.output)).toEqual({ query: 'cats' });
});

it('should treat null as absent for optional arguments carrying constraints', async () => {
const lookup = tool({
name: 'lookup',
description: 'lookup',
parameters: z.object({ id: z.string().min(1).optional() }),
execute: async (args) => args,
});

const result = await executeToolCall(
FunctionCall.create({
callId: 'call-optional-2',
name: 'lookup',
args: '{"id":null}',
}),
new ToolContext([lookup]),
);

expect(result.isError).toBe(false);
expect(JSON.parse(result.output)).toEqual({});
});

it('should treat nested null as absent for optional arguments', async () => {
const nested = tool({
name: 'nested',
description: 'nested',
parameters: z.object({
outer: z.object({ inner: z.string().optional(), kept: z.string() }),
list: z.array(z.object({ x: z.number().optional() })),
}),
execute: async (args) => args,
});

const result = await executeToolCall(
FunctionCall.create({
callId: 'call-optional-3',
name: 'nested',
args: JSON.stringify({ outer: { inner: null, kept: 'k' }, list: [{ x: null }] }),
}),
new ToolContext([nested]),
);

expect(result.isError).toBe(false);
expect(JSON.parse(result.output)).toEqual({ outer: { kept: 'k' }, list: [{}] });
});

it('should preserve a genuine null for a nullish argument', async () => {
const maybe = tool({
name: 'maybe',
description: 'maybe',
parameters: z.object({ n: z.number().nullish() }),
execute: async (args) => args,
});

const result = await executeToolCall(
FunctionCall.create({
callId: 'call-nullish-1',
name: 'maybe',
args: '{"n":null}',
}),
new ToolContext([maybe]),
);

expect(result.isError).toBe(false);
expect(JSON.parse(result.output)).toEqual({ n: null });
});

it('should preserve a genuine null for a nullable defaulted argument', async () => {
const maybe = tool({
name: 'maybe',
Expand Down
19 changes: 15 additions & 4 deletions agents/src/llm/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,16 +531,27 @@ function injectSchemaDefaults(
const properties = schema.properties;
const additional = schema.additionalProperties;
if (isJsonObject(properties) || isJsonObject(additional)) {
const required = Array.isArray(schema.required) ? schema.required : [];
return Object.fromEntries(
Object.entries(value).map(([key, item]) => {
Object.entries(value).flatMap<[string, unknown]>(([key, item]) => {
const prop = isJsonObject(properties) ? properties[key] : undefined;
if (isJsonObject(prop)) {
return [key, injectSchemaDefaults(item, prop, root)];
const resolved = injectSchemaDefaults(item, prop, root);
// A strict tool schema has no way to spell "absent": every property must be listed in
// `required`, so an optional one is emitted as required-and-nullable and the model
// signals "not provided" with null. Zod's `.optional()` accepts only undefined, so drop
// the key instead of handing validation a null the schema rejects. Defaults were already
// substituted above, and a property that is required or genuinely nullable keeps its
// null so a real contract violation still surfaces.
if (resolved === null && !required.includes(key) && !jsonSchemaAllowsNull(prop, root)) {
return [];
}
return [[key, resolved]];
}
if (isJsonObject(additional)) {
return [key, injectSchemaDefaults(item, additional, root)];
return [[key, injectSchemaDefaults(item, additional, root)]];
}
return [key, item];
return [[key, item]];
}),
);
}
Expand Down