Enable tool call type coersion (#477)

* feat: enable tool call type coercion

* fix: tests for type coercion

---------

Co-authored-by: Mingholy <mingholy.lmh@gmail.com>
This commit is contained in:
Peter Stewart
2025-09-18 15:04:27 +10:00
committed by GitHub
parent 17cdce6298
commit 724c24933c
4 changed files with 36 additions and 23 deletions

View File

@@ -62,6 +62,9 @@ describe('GlobTool', () => {
// Ensure a noticeable difference in modification time
await new Promise((resolve) => setTimeout(resolve, 50));
await fs.writeFile(path.join(tempRootDir, 'newer.sortme'), 'newer_content');
// For type coercion testing
await fs.mkdir(path.join(tempRootDir, '123'));
});
afterEach(async () => {
@@ -279,26 +282,20 @@ describe('GlobTool', () => {
);
});
it('should return error if path is provided but is not a string (schema validation)', () => {
it('should pass if path is provided but is not a string (type coercion)', () => {
const params = {
pattern: '*.ts',
path: 123,
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toBe(
'params/path must be string',
);
} as unknown as GlobToolParams; // Force incorrect type
expect(globTool.validateToolParams(params)).toBeNull();
});
it('should return error if case_sensitive is provided but is not a boolean (schema validation)', () => {
it('should pass if case_sensitive is provided but is not a boolean (type coercion)', () => {
const params = {
pattern: '*.ts',
case_sensitive: 'true',
};
// @ts-expect-error - We're intentionally creating invalid params for testing
expect(globTool.validateToolParams(params)).toBe(
'params/case_sensitive must be boolean',
);
} as unknown as GlobToolParams; // Force incorrect type
expect(globTool.validateToolParams(params)).toBeNull();
});
it("should return error if search path resolves outside the tool's root directory", () => {

View File

@@ -191,14 +191,12 @@ describe('ReadManyFilesTool', () => {
);
});
it('should throw error if include array contains non-string elements', () => {
it('should coerce non-string elements in include array', () => {
const params = {
paths: ['file1.txt'],
include: ['*.ts', 123] as string[],
};
expect(() => tool.build(params)).toThrow(
'params/include/1 must be string',
);
expect(() => tool.build(params)).toBeDefined();
});
it('should throw error if exclude array contains non-string elements', () => {

View File

@@ -220,14 +220,12 @@ describe('WriteFileTool', () => {
);
});
it('should throw an error if the content is null', () => {
const dirAsFilePath = path.join(rootDir, 'a_directory');
fs.mkdirSync(dirAsFilePath);
it('should coerce null content into an empty string', () => {
const params = {
file_path: dirAsFilePath,
file_path: path.join(rootDir, 'test.txt'),
content: null,
} as unknown as WriteFileToolParams; // Intentionally non-conforming
expect(() => tool.build(params)).toThrow('params/content must be string');
expect(() => tool.build(params)).toBeDefined();
});
it('should throw error if the file_path is empty', () => {

View File

@@ -9,11 +9,12 @@ import * as addFormats from 'ajv-formats';
// Ajv's ESM/CJS interop: use 'any' for compatibility as recommended by Ajv docs
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const AjvClass = (AjvPkg as any).default || AjvPkg;
const ajValidator = new AjvClass();
const ajValidator = new AjvClass({ coerceTypes: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const addFormatsFunc = (addFormats as any).default || addFormats;
addFormatsFunc(ajValidator);
/**
* Simple utility to validate objects against JSON Schemas
*/
@@ -32,8 +33,27 @@ export class SchemaValidator {
const validate = ajValidator.compile(schema);
const valid = validate(data);
if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
// Find any True or False values and lowercase them
fixBooleanCasing(data as Record<string, unknown>);
const validate = ajValidator.compile(schema);
const valid = validate(data);
if (!valid && validate.errors) {
return ajValidator.errorsText(validate.errors, { dataVar: 'params' });
}
}
return null;
}
}
function fixBooleanCasing(data: Record<string, unknown>) {
for (const key of Object.keys(data)) {
if (!(key in data)) continue;
if (typeof data[key] === 'object') {
fixBooleanCasing(data[key] as Record<string, unknown>);
} else if (data[key] === 'True') data[key] = 'true';
else if (data[key] === 'False') data[key] = 'false';
}
}