From 863116daaf406d3fc6485be819bcba0fc3d275f1 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 14 Jul 2026 05:23:17 +0800 Subject: [PATCH] fix: retain dynamic tool schema defaults --- docs/core-data-structures/tools.md | 2 + .../cordis/tool-cordis/tests/mount.spec.ts | 6 ++- packages/core/tools/README.md | 2 +- packages/core/tools/src/schema.ts | 6 +++ packages/core/tools/tests/tools.spec.ts | 37 ++++++++++++++++++- 5 files changed, 49 insertions(+), 4 deletions(-) diff --git a/docs/core-data-structures/tools.md b/docs/core-data-structures/tools.md index 1d59542404..36c59aea17 100644 --- a/docs/core-data-structures/tools.md +++ b/docs/core-data-structures/tools.md @@ -57,6 +57,8 @@ interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] + /** Default value. */ + default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ diff --git a/packages/cordis/tool-cordis/tests/mount.spec.ts b/packages/cordis/tool-cordis/tests/mount.spec.ts index fc29eeb2a0..bf2e57c13e 100644 --- a/packages/cordis/tool-cordis/tests/mount.spec.ts +++ b/packages/cordis/tool-cordis/tests/mount.spec.ts @@ -185,9 +185,13 @@ describe('cordis_mount', () => { // The registered schema is canonical JSON Schema derived from the DSL: // the required array survived, integer became number, extra is optional. const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')! - const parameters = schema.parameters as { properties: Record; required?: string[] } + const parameters = schema.parameters as { + properties: Record + required?: string[] + } expect(parameters.required).toEqual(['text']) expect(parameters.properties.count!.type).toBe('number') + expect(parameters.properties.count!.default).toBe(1) expect(parameters.properties.mode!.enum).toEqual(['fast', 'slow']) // Arg validation enforces the normalized spec: text required, extra not. expect((await call(ctx, 'json_schema_tool', { count: 2 })).isError).toBe(true) diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 5f06154ec4..d57aeba10b 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -77,7 +77,7 @@ ctx.tools.register(defineTool({ The helper converts the author-facing `SchemaSpec` (with `required: true` as a per-property boolean) to standard JSON Schema for the wire format and uses the same typed spec for execute/presentation validation. Raw JSON-Schema tool definitions (from MCP servers) are still accepted by the registry directly. -A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. +A `defineTool` tool also **validates the model-generated arguments against its `SchemaSpec` before `execute` runs** (`validateArgs`). The model's JSON is untrusted — `InferArgs` is a compile-time claim, not a runtime guarantee — so on a mismatch (missing required key, wrong primitive, bad enum member, nested violation) the tool throws a `ToolArgsError` (`code: 'INVALID_ARGS'`); the registry turns it into an `isError` result whose text lists the violations, which the model sees and self-corrects from. Validation mirrors the JSON Schema conversion exactly: extra keys are allowed, `default` is not applied, and an `object`/`array` prop without `properties`/`items` only type-checks. Raw-registered tools (MCP) are **not** validated by the harness — they validate their own input. See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details. diff --git a/packages/core/tools/src/schema.ts b/packages/core/tools/src/schema.ts index ad7c28760e..e912252b18 100644 --- a/packages/core/tools/src/schema.ts +++ b/packages/core/tools/src/schema.ts @@ -39,6 +39,11 @@ export interface SchemaProp { description?: string /** Enum of allowed values (strings only). */ enum?: string[] + /** + * Model-visible JSON Schema default annotation. Validation does not apply it; + * dynamic tool mounts may supply it even though first-party definitions do not. + */ + default?: unknown /** Nested properties for type: 'object'. */ properties?: SchemaSpec /** Items schema for type: 'array'. */ @@ -114,6 +119,7 @@ function propToJsonSchema(prop: SchemaProp): { schema: Record; const result: Record = { type: prop.type } if (prop.description) result.description = prop.description if (prop.enum) result.enum = prop.enum + if (prop.default !== undefined) result.default = prop.default const required = prop.required === true diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index 2bca8e05fc..a2dff84287 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -904,6 +904,17 @@ describe('schema DSL edge cases', () => { }) }) + it('emits default value in JSON Schema property', () => { + const spec = { + limit: { type: 'number', default: 25 }, + } satisfies SchemaSpec + const jsonSchema = schemaSpecToJsonSchema(spec) + expect(jsonSchema.properties['limit']).toMatchObject({ + type: 'number', + default: 25, + }) + }) + it('handles array items without nested properties (plain type array)', () => { const spec = { tags: { type: 'array', items: { type: 'string' } }, @@ -915,12 +926,28 @@ describe('schema DSL edge cases', () => { }) }) - it('emits only the type when optional fields are omitted', () => { + it('handles enum and default together in one property', () => { + const spec = { + level: { type: 'string', enum: ['low', 'high'], default: 'low' }, + } satisfies SchemaSpec + const jsonSchema = schemaSpecToJsonSchema(spec) + expect(jsonSchema.properties['level']).toMatchObject({ + type: 'string', + enum: ['low', 'high'], + default: 'low', + }) + }) + + it('omits description, enum, default keys when not specified', () => { const spec = { bare: { type: 'string' }, } satisfies SchemaSpec const jsonSchema = schemaSpecToJsonSchema(spec) - expect(jsonSchema.properties['bare']).toEqual({ type: 'string' }) + const prop = jsonSchema.properties['bare'] as Record + expect(prop).toEqual({ type: 'string' }) + expect('description' in prop).toBe(false) + expect('enum' in prop).toBe(false) + expect('default' in prop).toBe(false) }) it('handles array with no items (items omitted)', () => { @@ -1110,6 +1137,12 @@ describe('validateArgs (the runtime-validation RFC, part 1)', () => { expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([]) }) + it('does not apply defaults (validation only)', () => { + const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec + // absent optional is valid, and validation does not synthesize the default + expect(validateArgs(spec, {})).toEqual([]) + }) + it('type-checks primitives', () => { const spec = { s: { type: 'string' },