diff --git a/packages/core/src/utils/schemaValidator.test.ts b/packages/core/src/utils/schemaValidator.test.ts index 4928832c80a..40fb96483e5 100644 --- a/packages/core/src/utils/schemaValidator.test.ts +++ b/packages/core/src/utils/schemaValidator.test.ts @@ -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(); diff --git a/packages/core/src/utils/schemaValidator.ts b/packages/core/src/utils/schemaValidator.ts index 1c858dd2110..794fd10527b 100644 --- a/packages/core/src/utils/schemaValidator.ts +++ b/packages/core/src/utils/schemaValidator.ts @@ -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); + fixBooleanValues( + data as Record, + anySchema as Record, + ); // 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]). @@ -272,20 +275,45 @@ function fixStringifiedJsonValues( } } -function fixBooleanValues(data: Record) { +function fixBooleanValues( + data: Record, + schema?: Record, +) { + const properties = schema?.['properties'] as + | Record> + | undefined; + const items = schema?.['items'] as Record | 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); - } 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, 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; } } }