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
5 changes: 5 additions & 0 deletions .changeset/tool-validation-hints.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Improve tool argument validation errors with schema hints.
76 changes: 71 additions & 5 deletions packages/agent-core/src/tools/args-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import Ajv2019 from 'ajv/dist/2019';
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';

const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true });
const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true, verbose: true });
addFormats(DRAFT_07_AJV);

const DRAFT_2019_AJV = new Ajv2019({ strict: false, allErrors: true });
const DRAFT_2019_AJV = new Ajv2019({ strict: false, allErrors: true, verbose: true });
addFormats(DRAFT_2019_AJV);

const DRAFT_2020_AJV = new Ajv2020({ strict: false, allErrors: true });
const DRAFT_2020_AJV = new Ajv2020({ strict: false, allErrors: true, verbose: true });
addFormats(DRAFT_2020_AJV);

const DRAFT_2019_KEYWORDS = new Set([
Expand Down Expand Up @@ -61,13 +61,79 @@ export interface JsonObject extends Record<string, JsonType> {}

export type ToolArgsValidator = ValidateFunction<JsonType>;

type SchemaObject = Record<string, unknown>;

function asSchemaObject(value: unknown): SchemaObject | undefined {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return undefined;
return value as SchemaObject;
}

function schemaProperties(error: ErrorObject): SchemaObject | undefined {
const parentSchema = asSchemaObject(error.parentSchema);
if (parentSchema === undefined) return undefined;
return asSchemaObject(parentSchema['properties']);
}

function validProperties(error: ErrorObject): string | undefined {
const properties = schemaProperties(error);
if (properties === undefined) return undefined;

const names = Object.keys(properties);
return names.length === 0 ? undefined : names.join(', ');
}

function formatTypeList(value: unknown): string | undefined {
if (typeof value === 'string') return value;

if (Array.isArray(value)) {
const types = value.filter((item): item is string => typeof item === 'string');
return types.length === 0 ? undefined : types.join(' or ');
}

return undefined;
}

function expectedType(schema: unknown): string | undefined {
const schemaObject = asSchemaObject(schema);
if (schemaObject === undefined) return undefined;

const directType = formatTypeList(schemaObject['type']);
if (directType !== undefined) return directType;

for (const key of ['anyOf', 'oneOf'] as const) {
const branches = schemaObject[key];
if (!Array.isArray(branches)) continue;

const branchTypes = new Set<string>();
for (const branch of branches) {
const branchType = expectedType(branch);
if (branchType !== undefined) branchTypes.add(branchType);
}
if (branchTypes.size > 0) return [...branchTypes].join(' or ');
}

return undefined;
}

function expectedPropertyType(error: ErrorObject, property: string): string | undefined {
const properties = schemaProperties(error);
if (properties === undefined) return undefined;
return expectedType(properties[property]);
}

function formatValidationError(error: ErrorObject): string {
if (error.keyword === 'required' && 'missingProperty' in error.params) {
return `must have required property '${String(error.params['missingProperty'])}'`;
const property = String(error.params['missingProperty']);
const type = expectedPropertyType(error, property);
const typeHint = type === undefined ? '' : ` (expected ${type})`;
return `must have required property '${property}'${typeHint}`;
}

if (error.keyword === 'additionalProperties' && 'additionalProperty' in error.params) {
return `must NOT have additional property '${String(error.params['additionalProperty'])}'`;
const property = String(error.params['additionalProperty']);
const properties = validProperties(error);
const propertiesHint = properties === undefined ? '' : `; valid properties: ${properties}`;
return `must NOT have additional property '${property}'${propertiesHint}`;
}

const path = error.instancePath ? `${error.instancePath} ` : '';
Expand Down
54 changes: 54 additions & 0 deletions packages/agent-core/test/tools/input-schema-io.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import { FLAG_DEFINITIONS, FlagResolver } from '../../src/flags';
import { TaskListTool } from '../../src/tools/background/task-list';
import { compileToolArgsValidator, validateToolArgs } from '../../src/tools/args-validator';
import { AskUserQuestionTool } from '../../src/tools/builtin/collaboration/ask-user';
import { ReadInputSchema } from '../../src/tools/builtin/file/read';
import { toInputJsonSchema } from '../../src/tools/support/input-schema';

/** Collect every `required` array nested anywhere inside a JSON Schema. */
function collectRequired(schema: unknown, acc: string[] = []): string[] {
Expand Down Expand Up @@ -96,3 +98,55 @@ describe('builtin tool input JSON Schema', () => {
expect(validateToolArgs(validator, { questions: [question] })).not.toBeNull();
});
});

describe('tool argument validation errors', () => {
it('lists valid properties for unknown arguments', () => {
const validator = compileToolArgsValidator(toInputJsonSchema(ReadInputSchema));

expect(validateToolArgs(validator, { path: '/tmp/a.txt', offset: 10 })).toBe(
"must NOT have additional property 'offset'; valid properties: path, line_offset, n_lines",
);
});

it('includes the expected type for missing required arguments', () => {
const validator = compileToolArgsValidator(toInputJsonSchema(ReadInputSchema));

expect(validateToolArgs(validator, { n_lines: 10 })).toBe(
"must have required property 'path' (expected string)",
);
});

it('uses the nested object schema for unknown nested arguments', () => {
const validator = compileToolArgsValidator({
type: 'object',
properties: {
root: { type: 'string' },
nested: {
type: 'object',
properties: {
child: { type: 'string' },
},
additionalProperties: false,
},
},
additionalProperties: false,
});

expect(validateToolArgs(validator, { nested: { child: 'ok', root: 'wrong level' } })).toBe(
"must NOT have additional property 'root'; valid properties: child",
);
});

it('keeps the old required message when the expected type is unknown', () => {
const validator = compileToolArgsValidator({
type: 'object',
properties: {
path: { description: 'A path-like value accepted by the tool.' },
},
required: ['path'],
additionalProperties: false,
});

expect(validateToolArgs(validator, {})).toBe("must have required property 'path'");
});
});