Skip to content
Merged
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
55 changes: 55 additions & 0 deletions packages/core/src/utils/schemaValidator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,61 @@ describe('SchemaValidator', () => {
expect(params.is_active).toBe(true);
});

it('should not corrupt string fields whose value is literally "true"/"false"', () => {
const mixedSchema = {
type: 'object',
properties: {
old_string: { type: 'string' },
new_string: { type: 'string' },
is_active: { type: 'boolean' },
},
required: ['old_string', 'new_string', 'is_active'],
};
// A self-hosted LLM sends `is_active` as the string "false" (the case this
// coercion exists for) which fails initial validation and triggers
// fixBooleanValues. The string-typed `old_string`/`new_string` arguments
// legitimately hold the text "true"/"false" and must survive untouched —
// previously they were rewritten into booleans, corrupting the edit.
const params = {
old_string: 'true',
new_string: 'false',
is_active: 'false',
};
expect(SchemaValidator.validate(mixedSchema, params)).toBeNull();
expect(params.old_string).toBe('true');
expect(params.new_string).toBe('false');
expect(params.is_active).toBe(false);
});

it('should not coerce string booleans for fields that also accept string', () => {
const unionSchema = {
type: 'object',
properties: {
value: { anyOf: [{ type: 'string' }, { type: 'boolean' }] },
is_active: { type: 'boolean' },
},
required: ['value', 'is_active'],
};
const params = { value: 'true', is_active: 'true' };
expect(SchemaValidator.validate(unionSchema, params)).toBeNull();
// `value` accepts string, so the literal "true" is left as a string.
expect(params.value).toBe('true');
expect(params.is_active).toBe(true);
});

it('should coerce string booleans inside arrays of booleans', () => {
const arraySchema = {
type: 'object',
properties: {
flags: { type: 'array', items: { type: 'boolean' } },
},
required: ['flags'],
};
const params = { flags: ['true', 'false', 'true'] };
expect(SchemaValidator.validate(arraySchema, params)).toBeNull();
expect(params.flags).toEqual([true, false, true]);
});

it('should pass through actual boolean values unchanged', () => {
const params = { is_background: true };
expect(SchemaValidator.validate(booleanSchema, params)).toBeNull();
Expand Down
48 changes: 38 additions & 10 deletions packages/core/src/utils/schemaValidator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,10 @@ export class SchemaValidator {
let valid = validate(data);
if (!valid && validate.errors) {
// Coerce string boolean values ("true"/"false") to actual booleans
fixBooleanValues(data as Record<string, unknown>);
fixBooleanValues(
data as Record<string, unknown>,
anySchema as Record<string, unknown>,
);
// Coerce stringified JSON values (arrays/objects) back to their proper types.
// Some LLMs serialize complex values as strings when the schema uses
// anyOf/oneOf (e.g., '["url"]' instead of ["url"] for anyOf: [array, null]).
Expand Down Expand Up @@ -272,20 +275,45 @@ function fixStringifiedJsonValues(
}
}

function fixBooleanValues(data: Record<string, unknown>) {
function fixBooleanValues(
data: Record<string, unknown>,
schema?: Record<string, unknown>,
) {
const properties = schema?.['properties'] as
| Record<string, Record<string, unknown>>
| undefined;
const items = schema?.['items'] as Record<string, unknown> | undefined;

for (const key of Object.keys(data)) {
if (!(key in data)) continue;
const value = data[key];
// Array elements share the `items` schema; object fields use their
// per-property schema.
const childSchema = Array.isArray(data) ? items : properties?.[key];

if (typeof value === 'object' && value !== null) {
fixBooleanValues(value as Record<string, unknown>);
} else if (typeof value === 'string') {
const lower = value.toLowerCase();
if (lower === 'true') {
data[key] = true;
} else if (lower === 'false') {
data[key] = false;
}
fixBooleanValues(value as Record<string, unknown>, childSchema);
continue;
}

if (typeof value !== 'string') continue;

// Only coerce when the field's schema explicitly types it as boolean (and
// does not also accept string). Without this guard a legitimate string
// value of "true"/"false" — e.g. an `old_string`/`content` argument that
// happens to be the text "true" — would be silently rewritten into a
// boolean, corrupting the tool call. Mirrors fixStringifiedJsonValues,
// which is already schema-aware for the same reason.
const accepted = childSchema ? getAcceptedTypes(childSchema) : null;
if (!accepted || accepted.has('string') || !accepted.has('boolean')) {
continue;
}

const lower = value.toLowerCase();
if (lower === 'true') {
data[key] = true;
} else if (lower === 'false') {
data[key] = false;
}
}
}
Loading