Skip to content

Commit f14604c

Browse files
LittleBreakcaizongding.1graysonhicksclaudeabhiaiyer91
authored
fix(core): use value-based null detection in validateToolInput instead of error message matching (#14496)
Co-authored-by: caizongding.1 <caizongding.1@jd.com> Co-authored-by: Grayson Hicks <graysonhicks@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Abhi Aiyer <abhiaiyer91@gmail.com>
1 parent dd9c4e0 commit f14604c

3 files changed

Lines changed: 153 additions & 1 deletion

File tree

.changeset/happy-clowns-change.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@mastra/core': patch
3+
---
4+
5+
Fixed null detection in tool input validation to check actual values at failing paths instead of relying on error message string matching. This ensures null values from LLMs are correctly handled even when validators produce error messages that don't contain the word "null" (e.g., "must be string"). Fixes #14476.

packages/core/src/tools/validation.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1495,6 +1495,122 @@ describe('validateToolInput - Absent Optional Fields in Nested Objects (GitHub #
14951495
});
14961496
});
14971497

1498+
describe('validateToolInput - Value-Based Null Detection (GitHub #14476)', () => {
1499+
// These tests verify the fix for https://github.com/mastra-ai/mastra/issues/14476
1500+
// The null detection in Step 5 should check the actual value at the failing path
1501+
// rather than relying on error message string matching (e.g., checking for 'null'
1502+
// in the message). This ensures null values are detected even when validators
1503+
// return messages like "must be string" or "must be object".
1504+
1505+
it('should detect null values even when error message does not contain "null"', () => {
1506+
// Use a custom refinement whose error message deliberately avoids "null".
1507+
// This simulates non-Zod Standard Schema validators (e.g. JSON Schema)
1508+
// that report errors like "must be string" instead of "received null".
1509+
const schema = z.object({
1510+
name: z.string(),
1511+
description: z
1512+
.string()
1513+
.optional()
1514+
.superRefine((val, ctx) => {
1515+
if (typeof val !== 'string' && val !== undefined) {
1516+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid string value' });
1517+
}
1518+
}),
1519+
tags: z
1520+
.array(z.string())
1521+
.optional()
1522+
.superRefine((val, ctx) => {
1523+
if (!Array.isArray(val) && val !== undefined) {
1524+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'expected an array of strings' });
1525+
}
1526+
}),
1527+
});
1528+
1529+
// LLM sends null for optional fields — error messages won't contain "null"
1530+
const input = { name: 'test', description: null, tags: null };
1531+
1532+
const result = validateToolInput(schema, input);
1533+
1534+
expect(result.error).toBeUndefined();
1535+
expect(result.data).toEqual({ name: 'test' });
1536+
});
1537+
1538+
it('should handle null in nested optional fields with non-null error messages', () => {
1539+
// Custom refinements that produce errors without "null" in the message
1540+
const schema = z.object({
1541+
config: z.object({
1542+
timeout: z
1543+
.number()
1544+
.optional()
1545+
.superRefine((val, ctx) => {
1546+
if (typeof val !== 'number' && val !== undefined) {
1547+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a numeric value' });
1548+
}
1549+
}),
1550+
retries: z
1551+
.number()
1552+
.optional()
1553+
.superRefine((val, ctx) => {
1554+
if (typeof val !== 'number' && val !== undefined) {
1555+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a numeric value' });
1556+
}
1557+
}),
1558+
label: z
1559+
.string()
1560+
.optional()
1561+
.superRefine((val, ctx) => {
1562+
if (typeof val !== 'string' && val !== undefined) {
1563+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'must be a valid string value' });
1564+
}
1565+
}),
1566+
}),
1567+
});
1568+
1569+
const input = {
1570+
config: {
1571+
timeout: null,
1572+
retries: null,
1573+
label: null,
1574+
},
1575+
};
1576+
1577+
const result = validateToolInput(schema, input);
1578+
1579+
expect(result.error).toBeUndefined();
1580+
expect(result.data).toEqual({ config: {} });
1581+
});
1582+
1583+
it('should still preserve null for .nullable() fields', () => {
1584+
const schema = z.object({
1585+
name: z.string(),
1586+
deletedAt: z.string().nullable(),
1587+
note: z.string().optional(),
1588+
});
1589+
1590+
const input = { name: 'test', deletedAt: null, note: null };
1591+
1592+
const result = validateToolInput(schema, input);
1593+
1594+
expect(result.error).toBeUndefined();
1595+
expect(result.data).toEqual({ name: 'test', deletedAt: null });
1596+
});
1597+
1598+
it('should not misidentify non-null values at failing paths as null-related', () => {
1599+
const schema = z.object({
1600+
count: z.number(),
1601+
name: z.string(),
1602+
});
1603+
1604+
// Invalid types but not null - these should NOT be treated as null-related
1605+
const input = { count: 'not-a-number', name: 123 };
1606+
1607+
const result = validateToolInput(schema, input);
1608+
1609+
// Should still fail validation (can't fix by stripping)
1610+
expect(result.error).toBeDefined();
1611+
});
1612+
});
1613+
14981614
describe('validateToolInput - Undefined to Null Conversion (GitHub #11457)', () => {
14991615
// These tests verify the fix for https://github.com/mastra-ai/mastra/issues/11457
15001616
// When schemas are processed through OpenAI compat layers, .optional() is converted

packages/core/src/tools/validation.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,28 @@ function stripNullishValuesAtPaths(input: unknown, paths: Set<string>, currentPa
322322
return result;
323323
}
324324

325+
/**
326+
* Gets the value at a path in a nested object, using the same path segment format
327+
* as Standard Schema validation issues.
328+
*
329+
* @param obj The object to traverse
330+
* @param pathSegments Array of path segments from a validation issue
331+
* @returns The value at the path, or a sentinel symbol if the path doesn't exist
332+
*/
333+
const PATH_NOT_FOUND = Symbol('PATH_NOT_FOUND');
334+
function getValueAtPath(obj: unknown, pathSegments: ReadonlyArray<PropertyKey | { key: PropertyKey }>): unknown {
335+
let current: unknown = obj;
336+
for (const segment of pathSegments) {
337+
if (current === null || current === undefined || typeof current !== 'object') {
338+
return PATH_NOT_FOUND;
339+
}
340+
const key =
341+
typeof segment === 'object' && segment !== null && 'key' in segment ? String(segment.key) : String(segment);
342+
current = (current as Record<string, unknown>)[key];
343+
}
344+
return current;
345+
}
346+
325347
/**
326348
* Coerces stringified JSON values in object properties when the schema expects
327349
* an array or object but the LLM returned a JSON string.
@@ -468,9 +490,18 @@ export function validateToolInput<T = unknown>(
468490
// LLMs like Gemini send null for optional fields, but Zod's .optional() only
469491
// accepts undefined, not null. We only strip nulls for fields that caused
470492
// validation errors, preserving null for .nullable() schemas that need it.
493+
//
494+
// We detect null-related failures by checking the actual value at the failing
495+
// path rather than relying on error message string matching (GitHub #14476).
496+
// This ensures we catch null values regardless of the validator's error message
497+
// format (e.g., "must be string", "must be object", etc.).
471498
const failingNullPaths = new Set(
472499
validation.issues
473-
.filter(issue => issue.message?.includes('null'))
500+
.filter(issue => {
501+
if (!issue.path || issue.path.length === 0) return false;
502+
const value = getValueAtPath(normalizedInput, issue.path);
503+
return value === null || value === undefined;
504+
})
474505
.map(issue => issue.path?.map(p => (typeof p === 'object' && 'key' in p ? String(p.key) : String(p))).join('.'))
475506
.filter((p): p is string => !!p),
476507
);

0 commit comments

Comments
 (0)