diff --git a/.changeset/tool-args-type-coercion.md b/.changeset/tool-args-type-coercion.md
new file mode 100644
index 0000000000..3ec3cb1563
--- /dev/null
+++ b/.changeset/tool-args-type-coercion.md
@@ -0,0 +1,5 @@
+---
+"@moonshot-ai/kimi-code": patch
+---
+
+Fix tool calls failing validation when a model sends numeric or boolean arguments as strings.
diff --git a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
index c0d3a6946c..242a1160fe 100644
--- a/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
+++ b/packages/agent-core-v2/src/agent/toolExecutor/toolExecutorService.ts
@@ -7,7 +7,10 @@
* through the ordered `onDidExecuteTool` hook, publishes tool lifecycle
* events through `event`, records telemetry through `telemetry`, truncates
* oversized outputs through `toolResultTruncation`, and logs parse
- * diagnostics through `log`. Bound at Agent scope.
+ * diagnostics through `log`. Non-conforming argument types are normalized
+ * through the `tool` domain's args normalization before validation, with
+ * coercions surfaced as model-visible notes on the tool result. Bound at
+ * Agent scope.
*/
import { InstantiationType } from '#/_base/di/extensions';
@@ -23,6 +26,7 @@ import {
type JsonType,
type ToolArgsValidator,
} from '#/tool/args-validator';
+import { normalizeToolArgs, type ArgCoercion } from '#/tool/args-normalize';
import { PathSecurityError } from '#/tool/path-access';
import { isAbortError, isUserCancellation } from '#/_base/utils/abort';
import { IEventBus } from '#/app/event/eventBus';
@@ -600,7 +604,10 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
const coercedResult = coerceToolResult(didCtx.result, call.toolName);
const effectiveResult = normalizeToolResult(coercedResult);
- const finalResult: ToolResult = {
+ const mergedNote = [call.normalizationNote, effectiveResult.note]
+ .filter((part): part is string => typeof part === 'string' && part.length > 0)
+ .join('\n');
+ const baseResult = {
...effectiveResult,
description: result.description,
display: result.display,
@@ -612,6 +619,8 @@ export class AgentToolExecutorService implements IAgentToolExecutorService {
stopBatchAfterThis: result.stopBatchAfterThis,
delivery: coercedResult.delivery,
};
+ const finalResult: ToolResult =
+ mergedNote.length > 0 ? { ...baseResult, note: mergedNote } : baseResult;
return this.resultTruncation.truncateForModel({
toolName: call.toolName,
toolCallId: call.toolCall.id,
@@ -626,6 +635,7 @@ interface RunnableToolCall {
readonly toolName: string;
readonly tool: ExecutableTool;
readonly args: unknown;
+ readonly normalizationNote?: string;
}
interface RejectedToolCall {
@@ -715,17 +725,37 @@ function preflightToolCall(
output: unavailable,
};
}
- const validationError = validateExecutableToolArgs(tool, parsedArgs.data);
+ const normalization = normalizeToolArgs(tool.parameters, parsedArgs.data);
+ if (normalization.coercions.length > 0) {
+ log?.debug('tool args coerced to match schema', {
+ toolName,
+ toolCallId: toolCall.id,
+ coercions: normalization.coercions
+ .map((coercion) => `${coercion.path}: ${coercion.received} -> ${coercion.expected}`)
+ .join('; '),
+ });
+ }
+ const validationError = validateExecutableToolArgs(tool, normalization.args);
if (validationError !== null) {
return {
kind: 'rejected',
toolCall,
toolName,
- args: parsedArgs.data,
+ args: normalization.args,
output: `Invalid args for tool "${toolName}": ${validationError}`,
};
}
- return { kind: 'runnable', toolCall, toolName, tool, args: parsedArgs.data };
+ return {
+ kind: 'runnable',
+ toolCall,
+ toolName,
+ tool,
+ args: normalization.args,
+ normalizationNote:
+ normalization.coercions.length > 0
+ ? coercionNote(toolName, normalization.coercions)
+ : undefined,
+ };
}
export function parseToolCallArguments(raw: unknown): {
@@ -746,6 +776,13 @@ export function parseToolCallArguments(raw: unknown): {
}
}
+function coercionNote(toolName: string, coercions: readonly ArgCoercion[]): string {
+ const details = coercions
+ .map((coercion) => `${coercion.path}: ${coercion.received} -> ${coercion.expected}`)
+ .join('; ');
+ return `Note: tool "${toolName}" received argument type(s) that did not match its schema (${details}). They were coerced to the declared types before validation. Emit argument types exactly as the tool schema declares.`;
+}
+
function validateExecutableToolArgs(tool: ExecutableTool, args: unknown): string | null {
let validator = validators.get(tool);
if (validator === undefined) {
diff --git a/packages/agent-core-v2/src/tool/args-normalize.ts b/packages/agent-core-v2/src/tool/args-normalize.ts
new file mode 100644
index 0000000000..cd0b4bdb84
--- /dev/null
+++ b/packages/agent-core-v2/src/tool/args-normalize.ts
@@ -0,0 +1,206 @@
+/**
+ * `tool` domain (L3) — pre-validation tool-args normalization.
+ *
+ * Model tool-call output is untrusted input: some providers emit primitive
+ * arguments as JSON strings (`"3"` for an integer) instead of the declared
+ * types. The harness absorbs that at the parse/validation boundary — the
+ * earliest point it controls — while keeping validation itself strict.
+ *
+ * The rules are deliberately generic and bounded so this layer stays a single
+ * schema-driven rule instead of growing into a catalog of model-specific
+ * hacks:
+ * - only string → integer / number / boolean coercions are attempted
+ * - only when the coercion is lossless and the schema does not already
+ * accept strings at that position
+ * - `$ref` nodes and ambiguous (string-accepting) schemas are left alone
+ * - composition keywords are consulted for type discovery only, never as a
+ * proof of validity; under-coercion is deliberate, since whatever this
+ * layer misses still faces strict validation below
+ *
+ * Whatever cannot be normalized still fails validation afterwards, where the
+ * error message reports what was actually received. Pure helper; no scoped
+ * service.
+ */
+
+export type CoercionTarget = 'integer' | 'number' | 'boolean';
+
+export interface ArgCoercion {
+ readonly path: string;
+ readonly received: string;
+ readonly expected: CoercionTarget;
+}
+
+export interface ArgsNormalization {
+ readonly args: unknown;
+ readonly coercions: readonly ArgCoercion[];
+}
+
+const INTEGER_TEXT = /^[+-]?\d+$/;
+const NUMBER_TEXT = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
+
+type PrimitiveType = 'string' | 'integer' | 'number' | 'boolean' | 'object' | 'array' | 'null';
+
+function collectPrimitiveTypes(node: Record, into: Set): void {
+ const type = node['type'];
+ if (typeof type === 'string') into.add(type as PrimitiveType);
+ if (Array.isArray(type)) {
+ for (const entry of type) {
+ if (typeof entry === 'string') into.add(entry as PrimitiveType);
+ }
+ }
+ for (const keyword of ['anyOf', 'oneOf', 'allOf'] as const) {
+ const branches = node[keyword];
+ if (Array.isArray(branches)) {
+ for (const branch of branches) {
+ if (branch !== null && typeof branch === 'object' && !Array.isArray(branch)) {
+ collectPrimitiveTypes(branch as Record, into);
+ }
+ }
+ }
+ }
+}
+
+function isSchemaNode(value: unknown): value is Record {
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
+}
+
+function isUnconstrained(node: Record): boolean {
+ return (
+ node['type'] === undefined &&
+ node['const'] === undefined &&
+ node['enum'] === undefined &&
+ node['$ref'] === undefined
+ );
+}
+
+function stringAlreadyValid(node: Record, raw: string): boolean {
+ if (node['const'] === raw) return true;
+ const enumValues = node['enum'];
+ if (Array.isArray(enumValues) && enumValues.includes(raw)) return true;
+ for (const keyword of ['anyOf', 'oneOf'] as const) {
+ const branches = node[keyword];
+ if (!Array.isArray(branches)) continue;
+ for (const branch of branches) {
+ if (!isSchemaNode(branch)) continue;
+ if (isUnconstrained(branch) || stringAlreadyValid(branch, raw)) return true;
+ }
+ }
+ return false;
+}
+
+function coerceString(
+ raw: string,
+ expected: ReadonlySet,
+): { readonly value: number | boolean; readonly target: CoercionTarget } | undefined {
+ if (expected.has('integer') && INTEGER_TEXT.test(raw)) {
+ const parsed = Number(raw);
+ if (Number.isSafeInteger(parsed)) return { value: parsed, target: 'integer' };
+ return undefined;
+ }
+ if (expected.has('number') && NUMBER_TEXT.test(raw)) {
+ const parsed = Number(raw);
+ const lossless = INTEGER_TEXT.test(raw) ? Number.isSafeInteger(parsed) : Number.isFinite(parsed);
+ if (lossless) return { value: parsed, target: 'number' };
+ return undefined;
+ }
+ if (expected.has('boolean') && (raw === 'true' || raw === 'false')) {
+ return { value: raw === 'true', target: 'boolean' };
+ }
+ return undefined;
+}
+
+export function describeReceivedValue(value: unknown): string {
+ if (typeof value === 'string') return `string ${JSON.stringify(value)}`;
+ if (typeof value === 'number') return `number ${String(value)}`;
+ if (typeof value === 'boolean') return `boolean ${String(value)}`;
+ if (value === null) return 'null';
+ if (value === undefined) return 'undefined';
+ if (Array.isArray(value)) return 'array';
+ return 'object';
+}
+
+function normalizeValue(
+ node: Record,
+ value: unknown,
+ path: string,
+ coercions: ArgCoercion[],
+): unknown {
+ if (typeof value === 'string') {
+ const expected = new Set();
+ collectPrimitiveTypes(node, expected);
+ if (!expected.has('string') && !stringAlreadyValid(node, value)) {
+ const coerced = coerceString(value, expected);
+ if (coerced !== undefined) {
+ coercions.push({
+ path,
+ received: describeReceivedValue(value),
+ expected: coerced.target,
+ });
+ return coerced.value;
+ }
+ }
+ return value;
+ }
+
+ if (Array.isArray(value)) {
+ const items = node['items'];
+ if (items !== null && typeof items === 'object' && !Array.isArray(items)) {
+ const itemSchema = items as Record;
+ if (itemSchema['$ref'] === undefined) {
+ let changed: unknown[] | undefined;
+ for (let index = 0; index < value.length; index += 1) {
+ const normalized = normalizeValue(
+ itemSchema,
+ value[index],
+ `${path}/${String(index)}`,
+ coercions,
+ );
+ if (normalized !== value[index]) {
+ changed ??= [...value];
+ changed[index] = normalized;
+ }
+ }
+ return changed ?? value;
+ }
+ }
+ return value;
+ }
+
+ if (value !== null && typeof value === 'object') {
+ const properties = node['properties'];
+ if (properties !== null && typeof properties === 'object' && !Array.isArray(properties)) {
+ const record = value as Record;
+ let changed: Record | undefined;
+ for (const [key, child] of Object.entries(properties)) {
+ if (child === null || typeof child !== 'object' || Array.isArray(child)) continue;
+ const childSchema = child as Record;
+ if (childSchema['$ref'] !== undefined) continue;
+ if (!(key in record)) continue;
+ const normalized = normalizeValue(
+ childSchema,
+ record[key],
+ `${path}/${key.replace(/~/g, '~0').replace(/\//g, '~1')}`,
+ coercions,
+ );
+ if (normalized !== record[key]) {
+ changed ??= { ...record };
+ changed[key] = normalized;
+ }
+ }
+ return changed ?? value;
+ }
+ return value;
+ }
+
+ return value;
+}
+
+export function normalizeToolArgs(
+ schema: Record,
+ args: unknown,
+): ArgsNormalization {
+ const coercions: ArgCoercion[] = [];
+ if (schema['$ref'] !== undefined) return { args, coercions };
+ const normalized = normalizeValue(schema, args, '', coercions);
+ return { args: normalized, coercions };
+}
diff --git a/packages/agent-core-v2/src/tool/args-validator.ts b/packages/agent-core-v2/src/tool/args-validator.ts
index 3d8b33b3b0..1c9ee5ea4e 100644
--- a/packages/agent-core-v2/src/tool/args-validator.ts
+++ b/packages/agent-core-v2/src/tool/args-validator.ts
@@ -3,9 +3,10 @@
*
* Compiles tool-parameter JSON Schemas into AJV validators (draft-07 /
* 2019-09 / 2020-12 detected per schema) and formats validation failures
- * into model-readable messages. Only the executor path loads this module,
- * so the AJV instances are paid for once, at execution time. Pure helper;
- * no scoped service.
+ * into model-readable messages — type failures report the value that was
+ * actually received, deduplicated across union branches. Only the executor
+ * path loads this module, so the AJV instances are paid for once, at
+ * execution time. Pure helper; no scoped service.
*/
import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv';
@@ -13,6 +14,8 @@ import Ajv2019 from 'ajv/dist/2019';
import Ajv2020 from 'ajv/dist/2020';
import addFormats from 'ajv-formats';
+import { describeReceivedValue } from '#/tool/args-normalize';
+
const DRAFT_07_AJV = new Ajv({ strict: false, allErrors: true });
addFormats(DRAFT_07_AJV);
@@ -67,7 +70,18 @@ export interface JsonObject extends Record {}
export type ToolArgsValidator = ValidateFunction;
-function formatValidationError(error: ErrorObject): string {
+function valueAtInstancePath(root: JsonType, instancePath: string): unknown {
+ if (instancePath.length === 0) return root;
+ let current: unknown = root;
+ for (const segment of instancePath.split('/').slice(1)) {
+ const key = segment.replace(/~1/g, '/').replace(/~0/g, '~');
+ if (current === null || typeof current !== 'object') return undefined;
+ current = (current as Record)[key];
+ }
+ return current;
+}
+
+function formatValidationError(error: ErrorObject, args: JsonType): string {
if (error.keyword === 'required' && 'missingProperty' in error.params) {
return `must have required property '${String(error.params['missingProperty'])}'`;
}
@@ -77,7 +91,11 @@ function formatValidationError(error: ErrorObject): string {
}
const path = error.instancePath ? `${error.instancePath} ` : '';
- return `${path}${error.message ?? 'is invalid'}`;
+ const received =
+ error.keyword === 'type'
+ ? ` (received ${describeReceivedValue(valueAtInstancePath(args, error.instancePath))})`
+ : '';
+ return `${path}${error.message ?? 'is invalid'}${received}`;
}
export function compileToolArgsValidator(schema: Record): ToolArgsValidator {
@@ -95,5 +113,5 @@ export function validateToolArgs(validator: ToolArgsValidator, args: JsonType):
return 'Tool parameter validation failed';
}
- return errors.map((error) => formatValidationError(error)).join('; ');
+ return [...new Set(errors.map((error) => formatValidationError(error, args)))].join('; ');
}
diff --git a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts
index 6eedeaa02a..72053987b1 100644
--- a/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts
+++ b/packages/agent-core-v2/test/agent/toolExecutor/toolExecutor.test.ts
@@ -941,6 +941,92 @@ describe('parseToolCallArguments', () => {
});
});
+describe('tool args normalization at the parse boundary', () => {
+ const READ_LIKE_SCHEMA = {
+ type: 'object',
+ properties: {
+ path: { type: 'string' },
+ line_offset: {
+ anyOf: [
+ { type: 'integer', minimum: 1 },
+ { type: 'integer', minimum: -1000, maximum: -1 },
+ ],
+ },
+ },
+ required: ['path'],
+ additionalProperties: false,
+ };
+
+ it('coerces schema-violating string primitives and warns via the note channel', async () => {
+ const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA });
+ registry.register(tool);
+
+ const results = await execute([
+ toolCall('call_coerced', 'read_like', { path: 'a.ts', line_offset: '3' }),
+ ]);
+
+ expect(results).toHaveLength(1);
+ expect(results[0]).not.toMatchObject({ isError: true });
+ expect(tool.calls[0]?.args).toEqual({ path: 'a.ts', line_offset: 3 });
+ expect(results[0]?.note).toContain(
+ 'tool "read_like" received argument type(s) that did not match its schema',
+ );
+ expect(results[0]?.note).toContain('/line_offset: string "3" -> integer');
+ expect(results[0]?.note).toContain('coerced to the declared types before validation');
+ expect(results[0]?.note).not.toContain('call succeeded');
+ });
+
+ it('keeps the coercion warning ahead of the tool own note', async () => {
+ const tool = new TestTool('read_like', {
+ parameters: READ_LIKE_SCHEMA,
+ result: { output: 'contents', note: 'tool status.' },
+ });
+ registry.register(tool);
+
+ const results = await execute([
+ toolCall('call_notes', 'read_like', { path: 'a.ts', line_offset: '-5' }),
+ ]);
+
+ const note = results[0]?.note ?? '';
+ expect(note).toContain('did not match its schema');
+ expect(note).toContain('tool status.');
+ expect(note.indexOf('did not match its schema')).toBeLessThan(
+ note.indexOf('tool status.'),
+ );
+ });
+
+ it('rejects with an honest received-type message when coercion cannot fix the args', async () => {
+ const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA });
+ registry.register(tool);
+
+ const results = await execute([
+ toolCall('call_rejected', 'read_like', { path: 'a.ts', line_offset: 'three' }),
+ ]);
+
+ expect(results).toEqual([
+ expect.objectContaining({
+ isError: true,
+ output:
+ 'Invalid args for tool "read_like": /line_offset must be integer (received string "three"); /line_offset must match a schema in anyOf',
+ }),
+ ]);
+ expect(tool.calls).toEqual([]);
+ });
+
+ it('passes conforming args through without a note', async () => {
+ const tool = new TestTool('read_like', { parameters: READ_LIKE_SCHEMA });
+ registry.register(tool);
+
+ const results = await execute([
+ toolCall('call_clean', 'read_like', { path: 'a.ts', line_offset: 3 }),
+ ]);
+
+ expect(results[0]).not.toMatchObject({ isError: true });
+ expect(results[0]?.note).toBeUndefined();
+ expect(tool.calls[0]?.args).toEqual({ path: 'a.ts', line_offset: 3 });
+ });
+});
+
async function execute(
calls: ToolCall[],
signal?: AbortSignal,
diff --git a/packages/agent-core-v2/test/tool/args-normalize.test.ts b/packages/agent-core-v2/test/tool/args-normalize.test.ts
new file mode 100644
index 0000000000..fcb273f226
--- /dev/null
+++ b/packages/agent-core-v2/test/tool/args-normalize.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from 'vitest';
+
+import { describeReceivedValue, normalizeToolArgs } from '#/tool/args-normalize';
+
+describe('normalizeToolArgs', () => {
+ it('coerces integer strings, including negatives', () => {
+ const schema = { type: 'object', properties: { line_offset: { type: 'integer' } } };
+ expect(normalizeToolArgs(schema, { line_offset: '3' })).toEqual({
+ args: { line_offset: 3 },
+ coercions: [{ path: '/line_offset', received: 'string "3"', expected: 'integer' }],
+ });
+ expect(normalizeToolArgs(schema, { line_offset: '-12' }).args).toEqual({ line_offset: -12 });
+ });
+
+ it('coerces through anyOf branches (Read-style union)', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ line_offset: {
+ anyOf: [
+ { type: 'integer', minimum: 1 },
+ { type: 'integer', minimum: -1000, maximum: -1 },
+ ],
+ },
+ },
+ };
+ const result = normalizeToolArgs(schema, { line_offset: '-50' });
+ expect(result.args).toEqual({ line_offset: -50 });
+ expect(result.coercions).toHaveLength(1);
+ });
+
+ it('coerces number and boolean strings', () => {
+ const schema = {
+ type: 'object',
+ properties: { temperature: { type: 'number' }, verbose: { type: 'boolean' } },
+ };
+ const result = normalizeToolArgs(schema, { temperature: '0.5', verbose: 'true' });
+ expect(result.args).toEqual({ temperature: 0.5, verbose: true });
+ expect(result.coercions.map((c) => c.expected)).toEqual(['number', 'boolean']);
+ });
+
+ it('does not coerce lossy or malformed strings', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ a: { type: 'integer' },
+ b: { type: 'integer' },
+ c: { type: 'number' },
+ d: { type: 'boolean' },
+ },
+ };
+ const result = normalizeToolArgs(schema, { a: '3.5', b: '3px', c: '', d: 'yes' });
+ expect(result.args).toEqual({ a: '3.5', b: '3px', c: '', d: 'yes' });
+ expect(result.coercions).toHaveLength(0);
+ });
+
+ it('does not coerce beyond the safe-integer range', () => {
+ const schema = { type: 'object', properties: { big: { type: 'integer' } } };
+ const result = normalizeToolArgs(schema, { big: '9007199254740993' });
+ expect(result.args).toEqual({ big: '9007199254740993' });
+ expect(result.coercions).toHaveLength(0);
+
+ const numberSchema = { type: 'object', properties: { big: { type: 'number' } } };
+ expect(normalizeToolArgs(numberSchema, { big: '9007199254740993' }).args).toEqual({
+ big: '9007199254740993',
+ });
+ });
+
+ it('leaves values alone when the schema already accepts strings', () => {
+ const schema = {
+ type: 'object',
+ properties: { id: { anyOf: [{ type: 'string' }, { type: 'integer' }] } },
+ };
+ const result = normalizeToolArgs(schema, { id: '3' });
+ expect(result.args).toEqual({ id: '3' });
+ expect(result.coercions).toHaveLength(0);
+ });
+
+ it('does not coerce strings already valid via const, enum, or unconstrained branches', () => {
+ const constSchema = {
+ type: 'object',
+ properties: { v: { oneOf: [{ type: 'integer' }, { const: '3' }] } },
+ };
+ expect(normalizeToolArgs(constSchema, { v: '3' })).toEqual({ args: { v: '3' }, coercions: [] });
+
+ const enumSchema = { type: 'object', properties: { v: { enum: ['3', 3] } } };
+ expect(normalizeToolArgs(enumSchema, { v: '3' }).args).toEqual({ v: '3' });
+
+ const openBranchSchema = {
+ type: 'object',
+ properties: { v: { anyOf: [{ type: 'integer' }, {}] } },
+ };
+ expect(normalizeToolArgs(openBranchSchema, { v: '3' }).args).toEqual({ v: '3' });
+ });
+
+ it('discovers primitive types composed with allOf', () => {
+ const schema = {
+ type: 'object',
+ properties: { v: { allOf: [{ type: 'integer' }, { minimum: 1 }] } },
+ };
+ const result = normalizeToolArgs(schema, { v: '3' });
+ expect(result.args).toEqual({ v: 3 });
+ expect(result.coercions).toEqual([
+ { path: '/v', received: 'string "3"', expected: 'integer' },
+ ]);
+ });
+
+ it('leaves non-string values and $ref nodes untouched', () => {
+ const schema = {
+ type: 'object',
+ properties: { a: { type: 'integer' }, b: { $ref: '#/definitions/x' } },
+ };
+ const result = normalizeToolArgs(schema, { a: 3, b: '3' });
+ expect(result.args).toEqual({ a: 3, b: '3' });
+ expect(result.coercions).toHaveLength(0);
+ });
+
+ it('recurses into nested objects and array items', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ range: {
+ type: 'object',
+ properties: { start: { type: 'integer' }, end: { type: 'integer' } },
+ },
+ offsets: { type: 'array', items: { type: 'integer' } },
+ },
+ };
+ const result = normalizeToolArgs(schema, {
+ range: { start: '1', end: 10 },
+ offsets: ['1', '2', 'x'],
+ });
+ expect(result.args).toEqual({ range: { start: 1, end: 10 }, offsets: [1, 2, 'x'] });
+ expect(result.coercions.map((c) => c.path)).toEqual([
+ '/range/start',
+ '/offsets/0',
+ '/offsets/1',
+ ]);
+ });
+
+ it('returns the original reference when nothing was coerced', () => {
+ const schema = {
+ type: 'object',
+ properties: { n: { type: 'integer' }, offsets: { type: 'array', items: { type: 'integer' } } },
+ };
+ const args = { n: 3, offsets: [1, 2, 3], extra: 'kept' };
+ const result = normalizeToolArgs(schema, args);
+ expect(result.args).toBe(args);
+ expect((result.args as typeof args).offsets).toBe(args.offsets);
+ expect(normalizeToolArgs(schema, {}).coercions).toHaveLength(0);
+ });
+
+ it('under-coerces ambiguous allOf compositions instead of guessing', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ v: { allOf: [{ type: 'integer' }, { anyOf: [{ type: 'string' }, { type: 'number' }] }] },
+ },
+ };
+ const result = normalizeToolArgs(schema, { v: '3' });
+ expect(result.args).toEqual({ v: '3' });
+ expect(result.coercions).toHaveLength(0);
+ });
+});
+
+describe('describeReceivedValue', () => {
+ it('describes JSON values for model-facing errors', () => {
+ expect(describeReceivedValue('3')).toBe('string "3"');
+ expect(describeReceivedValue(1.5)).toBe('number 1.5');
+ expect(describeReceivedValue(false)).toBe('boolean false');
+ expect(describeReceivedValue(null)).toBe('null');
+ expect(describeReceivedValue(undefined)).toBe('undefined');
+ expect(describeReceivedValue([])).toBe('array');
+ expect(describeReceivedValue({})).toBe('object');
+ });
+});
diff --git a/packages/agent-core-v2/test/tool/args-validator.test.ts b/packages/agent-core-v2/test/tool/args-validator.test.ts
index 0615db51e7..0642a4ca28 100644
--- a/packages/agent-core-v2/test/tool/args-validator.test.ts
+++ b/packages/agent-core-v2/test/tool/args-validator.test.ts
@@ -43,3 +43,39 @@ describe('args-validator (Ajv, format support)', () => {
expect(validate({ const: 'x' }, 'y')).toContain('constant');
});
});
+
+describe('args-validator (honest type errors)', () => {
+ it('reports the received value on type failures', () => {
+ expect(validate({ type: 'object', properties: { n: { type: 'integer' } } }, { n: '3' })).toBe(
+ '/n must be integer (received string "3")',
+ );
+ expect(validate({ type: 'boolean' }, 'true')).toBe('must be boolean (received string "true")');
+ expect(validate({ type: 'object', properties: { n: { type: 'number' } } }, { n: null })).toBe(
+ '/n must be number (received null)',
+ );
+ });
+
+ it('dedupes identical type failures from union branches', () => {
+ const schema = {
+ type: 'object',
+ properties: {
+ line_offset: {
+ anyOf: [
+ { type: 'integer', minimum: 1 },
+ { type: 'integer', minimum: -1000, maximum: -1 },
+ ],
+ },
+ },
+ };
+ const message = validate(schema, { line_offset: '3' });
+ expect(message).toBe(
+ '/line_offset must be integer (received string "3"); /line_offset must match a schema in anyOf',
+ );
+ });
+
+ it('keeps required / additionalProperties messages free of received details', () => {
+ expect(validate({ type: 'object', required: ['a'] }, {})).toBe(
+ "must have required property 'a'",
+ );
+ });
+});