feat: unify JSON value schema DSL

This commit is contained in:
Tianyi Cui
2026-07-21 01:11:55 +08:00
parent 9a5c81f9e5
commit 8500974fd4
62 changed files with 1929 additions and 1179 deletions

View File

@@ -206,7 +206,7 @@ export class BashEnvRegistry extends Service {
}
}
/** Parsed tool args; execute validates value constraints absent from SchemaSpec. */
/** Parsed tool args; execute validates value constraints absent from ParameterSchemaSpec. */
interface BashToolArgs {
command: string
description: string

View File

@@ -287,7 +287,7 @@ describe('bash tool', () => {
})
// Type and required-key violations are rejected by the harness
// (defineTool validates against the SchemaSpec — the arg-validation Agent Note) before execute.
// (defineTool validates against the ParameterSchemaSpec — the arg-validation Agent Note) before execute.
it.each([
[{}, /missing required property "command"/],
[{ command: 42, description: 'd' }, /"command" must be a string/],
@@ -303,7 +303,7 @@ describe('bash tool', () => {
expect(text(result)).toMatch(pattern)
})
// Value constraints the SchemaSpec can't express stay in the tool body.
// Value constraints the ParameterSchemaSpec can't express stay in the tool body.
it.each([
[{ command: ' ', description: 'd' }, /invalid command/],
[{ command: 'x', description: ' ' }, /invalid description/],

View File

@@ -1336,6 +1336,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'InjectOptions',
declaration: 'export interface InjectOptions extends SendOptions {\n meta?: JsonValue;\n}',
},
{
name: 'JsonSchemaNode',
declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record<string, JsonSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}',
},
{
name: 'JsonSchemaScalar',
declaration: 'export type JsonSchemaScalar = string | number | boolean | null;',
},
{
name: 'JsonSchemaType',
declaration: 'export type JsonSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
},
{
name: 'JsonValue',
declaration: 'export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n};',
@@ -1368,6 +1380,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'ObjectJsonSchema',
declaration: 'export type ObjectJsonSchema = JsonSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
@@ -1544,22 +1560,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'StreamChunk',
declaration: 'export type StreamChunk = {\n type: \'block-start\';\n index: number;\n blockType: ContentBlockType;\n} | {\n type: \'text-delta\';\n index: number;\n text: string;\n} | {\n type: \'reasoning-delta\';\n index: number;\n text: string;\n} | {\n type: \'tool-call-delta\';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n} | {\n type: \'block-end\';\n index: number;\n block: ContentBlock;\n} | {\n type: \'usage\';\n usage: TokenUsage;\n} | {\n type: \'finish\';\n reason: FinishReason;\n replayState?: unknown;\n};',
},
{
name: 'StructuredOutputSchema',
declaration: 'export type StructuredOutputSchema = StructuredSchemaNode & {\n type: \'object\';\n};',
},
{
name: 'StructuredScalar',
declaration: 'export type StructuredScalar = string | number | boolean | null;',
},
{
name: 'StructuredSchemaNode',
declaration: 'export interface StructuredSchemaNode {\n type: StructuredSchemaType;\n properties?: Record<string, StructuredSchemaNode>;\n required?: string[];\n additionalProperties?: boolean;\n items?: StructuredSchemaNode;\n enum?: StructuredScalar[];\n const?: StructuredScalar;\n description?: string;\n title?: string;\n default?: unknown;\n examples?: unknown;\n}',
},
{
name: 'StructuredSchemaType',
declaration: 'export type StructuredSchemaType = \'object\' | \'array\' | \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\';',
},
{
name: 'SubagentCapabilities',
declaration: 'export interface SubagentCapabilities {\n readonly outputSchema: boolean;\n readonly depthLimit: boolean;\n readonly toolFilter: boolean;\n readonly persona: boolean;\n}',
@@ -1578,7 +1578,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'SubagentStartRequest',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: StructuredOutputSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
declaration: 'export interface SubagentStartRequest {\n readonly prompt: ContentBlock[];\n readonly parent: Agent;\n readonly signal: AbortSignal;\n readonly agentOptions?: AgentOptions;\n readonly outputSchema?: ObjectJsonSchema;\n readonly maxDepth?: number;\n readonly toolFilter?: ToolRestriction;\n readonly persona?: string;\n}',
},
{
name: 'SubagentStopReason',

View File

@@ -1,5 +1,5 @@
/**
* The registration boundary between sandboxed mount code and the real runtime: SchemaSpec
* The registration boundary between sandboxed mount code and the real runtime: ParameterSchemaSpec
* normalization + validation with teaching errors, the marker-guarded `harness.defineTool` /
* `harness.registerTool` pair, the SANDBOX CONTEXT FAÇADE a mounted plugin's `apply` receives
* in place of the real `ctx`, and the plugin-shape helpers the mount lifecycle narrows sandbox
@@ -15,12 +15,13 @@
import { Context } from 'cordis'
import type { Plugin } from 'cordis'
import { scopeOf } from '@deepseek-ai/dsh-scope'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools'
import type { ToolDefinition, ToolExecuteReturn } from '@deepseek-ai/dsh-tools'
const DYNAMIC_TOOL = Symbol('tool-cordis.dynamic-tool')
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'boolean', 'object', 'array'])
const VALID_TYPES = '\'string\' | \'number\' | \'boolean\' | \'object\' | \'array\''
const SCHEMA_TYPES = new Set<unknown>(['string', 'number', 'integer', 'boolean', 'null', 'object', 'array', 'json'])
const VALID_TYPES = '\'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\''
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
type DynamicToolDefinition = ToolDefinition & { [DYNAMIC_TOOL]: true }
type DynamicToolMarker = { [DYNAMIC_TOOL]?: unknown }
@@ -29,70 +30,196 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
return Object.prototype.toString.call(value) === '[object Object]'
}
/** Materialize realm-foreign lossless JSON without allowing JSON.stringify coercions. */
function cloneJson(value: unknown, path: string, seen = new Set<object>()): unknown {
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value
if (typeof value === 'number') {
if (Number.isFinite(value) && !Object.is(value, -0)) return value
throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
}
if (typeof value !== 'object') throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
if (seen.has(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
seen.add(value)
try {
if (Array.isArray(value)) {
const output: unknown[] = []
for (let index = 0; index < value.length; index++) {
if (!Object.hasOwn(value, index)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
output.push(cloneJson(value[index], `${path}[${index}]`, seen))
}
return output
}
if (!isPlainRecord(value)) throw new Error(`harness.defineTool ${path} must be lossless JSON data`)
const output: Record<string, unknown> = {}
for (const [key, entry] of Object.entries(value)) output[key] = cloneJson(entry, `${path}.${key}`, seen)
return output
} finally {
seen.delete(value)
}
}
/** Copy and realm-materialize the shared annotation vocabulary. */
function copyAnnotations(value: Record<string, unknown>, output: Record<string, unknown>, path: string): void {
if (Object.hasOwn(value, 'description')) output.description = value.description
if (Object.hasOwn(value, 'title')) output.title = value.title
if (Object.hasOwn(value, 'default')) output.default = cloneJson(value.default, `${path}.default`)
if (Object.hasOwn(value, 'examples')) output.examples = cloneJson(value.examples, `${path}.examples`)
}
/** Reject sandbox schema keys that the unified DSL would otherwise ignore. */
function assertSchemaKeys(value: Record<string, unknown>, path: string, allowed: readonly string[]): void {
for (const key of Object.keys(value)) {
if (!allowed.includes(key)) throw new Error(`harness.defineTool ${path}.${key} is not supported by the unified schema DSL`)
}
}
/**
* Normalize a sandbox-provided `parameters` value into a fresh host-realm
* SchemaSpec. Accepts the DSL directly, or the JSON-Schema-style
* `{ type: 'object', properties, required: […] }` wrapper models write by
* prior — the wrapper unwraps and its `required` array becomes per-property
* flags (see the module doc).
* ParameterSchemaSpec. A raw JSON-Schema object wrapper retains its open root
* default, while the direct DSL is already an implicit open property map.
*/
function normalizeSchemaSpec(value: unknown, path = 'parameters'): Record<string, unknown> {
function normalizeParameterSchemaSpec(value: unknown, path = 'parameters'): {
spec: Record<string, unknown>
rootAnnotations?: Record<string, unknown>
} {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec object`)
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec object`)
}
let entries = value
const requiredNames = new Set<unknown>()
if (value.type === 'object' && isPlainRecord(value.properties)) {
if (Array.isArray(value.required)) {
for (const name of value.required) requiredNames.add(name)
if (value.type === 'object') {
assertSchemaKeys(value, path, ['type', 'properties', 'required', 'additionalProperties', ...ANNOTATION_KEYS])
if (!isPlainRecord(value.properties)) {
throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
}
if (Object.hasOwn(value, 'additionalProperties') && value.additionalProperties !== true) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be true or omitted because the implicit parameter root is open`)
}
if (Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
const required = normalizeRequiredNames(value.required, value.properties, `${path}.required`)
const rootAnnotations: Record<string, unknown> = {}
copyAnnotations(value, rootAnnotations, path)
return {
spec: normalizePropertyMap(value.properties, path, required, true),
...(Object.keys(rootAnnotations).length === 0 ? {} : { rootAnnotations }),
}
entries = value.properties
}
return { spec: normalizePropertyMap(value, path, new Set(), false) }
}
/** Validate raw required names and return their lookup set. */
function normalizeRequiredNames(value: unknown, properties: Record<string, unknown>, path: string): Set<string> {
if (value === undefined) return new Set()
if (!Array.isArray(value) || value.some(name => typeof name !== 'string')) {
throw new Error(`harness.defineTool ${path} must be an array of declared property names`)
}
const names = new Set(value as string[])
for (const name of names) {
if (!Object.hasOwn(properties, name)) throw new Error(`harness.defineTool ${path} names undeclared property ${JSON.stringify(name)}`)
}
return names
}
/** Normalize one implicit property map. */
function normalizePropertyMap(
entries: Record<string, unknown>,
path: string,
requiredNames: ReadonlySet<string>,
raw: boolean,
): Record<string, unknown> {
const spec: Record<string, unknown> = {}
for (const [key, prop] of Object.entries(entries)) {
spec[key] = normalizeSchemaProp(prop, `${path}.${key}`, requiredNames.has(key))
spec[key] = normalizeValueSchema(prop, `${path}.${key}`, requiredNames.has(key), raw, true)
}
return spec
}
/** Normalize one property: `integer` → `number`, `required: false` → absent, nested wrappers unwrapped recursively. */
function normalizeSchemaProp(value: unknown, path: string, forceRequired = false): Record<string, unknown> {
/** Normalize one property or nested value schema into the host realm. */
function normalizeValueSchema(
value: unknown,
path: string,
forceRequired = false,
raw = false,
parameterProperty = false,
): Record<string, unknown> {
if (!isPlainRecord(value)) {
throw new Error(`harness.defineTool ${path} must be a SchemaSpec property object`)
throw new Error(`harness.defineTool ${path} must be a ParameterSchemaSpec property object`)
}
const type = value.type === 'integer' ? 'number' : value.type
if (!SCHEMA_TYPES.has(type)) {
const requiredKey = parameterProperty && !raw ? ['required'] : []
if (parameterProperty && raw && Object.hasOwn(value, 'required') && value.type !== 'object') {
throw new Error(`harness.defineTool ${path}.required belongs to the containing raw object schema`)
}
if (parameterProperty && !raw && Object.hasOwn(value, 'required') && value.required !== true) {
throw new Error(`harness.defineTool ${path}.required must be true when present`)
}
const prop: Record<string, unknown> = {}
if (forceRequired || value.required === true) prop.required = true
copyAnnotations(value, prop, path)
if (Object.hasOwn(value, 'oneOf')) {
assertSchemaKeys(value, path, ['oneOf', ...requiredKey, ...ANNOTATION_KEYS])
if (!Array.isArray(value.oneOf)) throw new Error(`harness.defineTool ${path}.oneOf must contain at least two schemas`)
prop.oneOf = value.oneOf.map((branch, index) => normalizeValueSchema(branch, `${path}.oneOf[${index}]`, false, raw))
return prop
}
if (raw && !Object.hasOwn(value, 'type')) {
assertSchemaKeys(value, path, ANNOTATION_KEYS)
prop.type = 'json'
return prop
}
if (!SCHEMA_TYPES.has(value.type) || raw && value.type === 'json') {
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES} (got ${JSON.stringify(value.type)})`)
}
// On an object property a JSON-Schema-style `required` ARRAY names required
// children (handled by the nested unwrap below); everywhere else `required`
// must be a boolean, and `false` means optional.
const nestedRequiredArray = type === 'object' && Array.isArray(value.required)
if (value.required !== undefined && typeof value.required !== 'boolean' && !nestedRequiredArray) {
throw new Error(`harness.defineTool ${path}.required must be a boolean when present`)
}
const prop: Record<string, unknown> = { type }
if (forceRequired || value.required === true) prop.required = true
if (typeof value.description === 'string') prop.description = value.description
if (Array.isArray(value.enum)) prop.enum = [...value.enum as unknown[]]
if (value.default !== undefined) prop.default = value.default
if (value.properties !== undefined) {
if (type !== 'object') {
throw new Error(`harness.defineTool ${path}.properties is only valid for type "object"`)
const type = value.type
prop.type = type
switch (type) {
case 'object': {
assertSchemaKeys(value, path, ['type', 'properties', 'additionalProperties', ...requiredKey, ...(raw ? ['required'] : []), ...ANNOTATION_KEYS])
if (!raw && (!Object.hasOwn(value, 'additionalProperties') || typeof value.additionalProperties !== 'boolean')) {
throw new Error(`harness.defineTool ${path}.additionalProperties must be explicitly true or false`)
}
if (raw && Object.hasOwn(value, 'additionalProperties') && typeof value.additionalProperties !== 'boolean') {
throw new Error(`harness.defineTool ${path}.additionalProperties must be a boolean`)
}
if (raw && Object.hasOwn(value, 'required') && value.required === undefined) {
throw new Error(`harness.defineTool ${path}.required must be an array of declared property names`)
}
prop.additionalProperties = raw ? value.additionalProperties ?? true : value.additionalProperties
if (Object.hasOwn(value, 'properties')) {
if (!isPlainRecord(value.properties)) throw new Error(`harness.defineTool ${path}.properties must be an object of schemas`)
const nestedRequired = raw ? normalizeRequiredNames(value.required, value.properties, `${path}.required`) : new Set<string>()
prop.properties = normalizePropertyMap(value.properties, `${path}.properties`, nestedRequired, raw)
} else if (raw && value.required !== undefined) {
normalizeRequiredNames(value.required, {}, `${path}.required`)
}
return prop
}
// Re-wrap so the nested unwrap applies a nested `required` array too.
prop.properties = normalizeSchemaSpec(
{ type: 'object', properties: value.properties, required: value.required },
`${path}.properties`,
)
case 'array':
assertSchemaKeys(value, path, ['type', 'items', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'items')) prop.items = normalizeValueSchema(value.items, `${path}.items`, false, raw)
return prop
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertSchemaKeys(value, path, ['type', 'enum', 'const', ...requiredKey, ...ANNOTATION_KEYS])
if (Object.hasOwn(value, 'enum')) {
prop.enum = Array.isArray(value.enum)
? value.enum.map((entry, index) => cloneJson(entry, `${path}.enum[${index}]`))
: value.enum
}
if (Object.hasOwn(value, 'const')) prop.const = cloneJson(value.const, `${path}.const`)
return prop
case 'json':
assertSchemaKeys(value, path, ['type', ...requiredKey, ...ANNOTATION_KEYS])
return prop
/* v8 ignore next 2 -- SCHEMA_TYPES narrows this closed switch before dispatch. */
default:
throw new Error(`harness.defineTool ${path} must declare a valid type: ${VALID_TYPES}`)
}
if (value.items !== undefined) {
if (type !== 'array') {
throw new Error(`harness.defineTool ${path}.items is only valid for type "array"`)
}
prop.items = normalizeSchemaProp(value.items, `${path}.items`)
}
return prop
}
function markDynamicTool(tool: ToolDefinition): DynamicToolDefinition {
@@ -160,19 +287,22 @@ function assertExecuteReturn(value: unknown): ToolExecuteReturn {
/**
* The `harness.defineTool` handed into the sandbox: the real DSL, with `parameters` normalized
* into a fresh host-realm SchemaSpec (JSON-Schema wrapper unwrapped, `integer` mapped,
* `required: false` dropped) and the tool's `execute` return normalized into the host realm
* into a fresh host-realm ParameterSchemaSpec (raw object wrappers unwrapped,
* required arrays mapped, and explicit DSL object openness enforced) and the tool's `execute` return normalized into the host realm
* via a JSON round-trip. Non-JSON or wrong-shape output fails that call instead of poisoning
* the session log.
* @param options - the standard `defineTool` options; `parameters` may be the SchemaSpec DSL or a JSON-Schema-style wrapper.
* @param options - the standard `defineTool` options; `parameters` may be the ParameterSchemaSpec DSL or a JSON-Schema-style wrapper.
* @returns the marker-tagged definition `harness.registerTool` (and the guarded `ctx.tools.register`) accepts.
*/
export function sandboxDefineTool(options: Parameters<typeof defineTool>[0]): ToolDefinition {
const parameters = normalizeSchemaSpec((options as { parameters?: unknown }).parameters)
const tool = defineTool({ ...options, parameters } as Parameters<typeof defineTool>[0])
const normalized = normalizeParameterSchemaSpec((options as { parameters?: unknown }).parameters)
const tool = defineTool({ ...options, parameters: normalized.spec } as Parameters<typeof defineTool>[0])
const parameters = { ...tool.parameters, ...normalized.rootAnnotations }
assertSupportedJsonSchema(parameters)
const execute = tool.execute.bind(tool)
return markDynamicTool({
...tool,
parameters,
async execute(args, exec) {
// JSON.stringify yields NO JSON for an undefined (or function/symbol)
// return despite its string-typed signature — route that into

View File

@@ -121,9 +121,9 @@ export function apply(ctx: Context, config: Config): void {
+ '`harness.registerTool(ctx, harness.defineTool({ name, description, parameters: '
+ '{ text: { type: \'string\', required: true } }, async execute(args) { … } }))` '
+ 'to give yourself a new tool — it becomes callable on your NEXT step. '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'boolean\'|\'object\'|\'array\', '
+ 'required?: true, description?, enum?, items?, properties? }; a JSON-Schema-style '
+ '{ type: \'object\', properties, required: […] } wrapper and type \'integer\' are also accepted and normalized. A '
+ 'Tool parameters: each key IS a property — { type: \'string\'|\'number\'|\'integer\'|\'boolean\'|\'null\'|\'object\'|\'array\'|\'json\', '
+ 'required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and '
+ 'oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: \'object\', properties, required?: […] } wrapper is also accepted with open-by-default objects. A '
+ 'tool\'s `execute` MUST return an ARRAY of content blocks, e.g. `return '
+ '[{ type: \'text\', text: someString }]` — never a bare string. '
+ 'Mounts can COMPOSE: one plugin may `ctx.provide(\'name\', value)` a service and '

View File

@@ -156,11 +156,14 @@ describe('cordis_mount', () => {
description: 'written in the JSON-Schema dialect',
parameters: {
type: 'object',
title: 'Raw parameters',
default: { text: 'default' },
examples: [{ text: 'example' }],
properties: {
text: { type: 'string', description: 'the text' },
count: { type: 'integer', default: 1 },
mode: { type: 'string', enum: ['fast', 'slow'] },
extra: { type: 'string', required: false },
extra: { type: 'string' },
},
required: ['text'],
},
@@ -173,14 +176,19 @@ describe('cordis_mount', () => {
expect(result.isError).toBe(false)
// The registered schema is canonical JSON Schema derived from the DSL:
// the required array survived, integer became number, extra is optional.
// the required array survived, integer stayed integer, extra is optional.
const schema = ctx.tools.schemas().find(s => s.name === 'json_schema_tool')!
const parameters = schema.parameters as {
properties: Record<string, { type: string; enum?: string[]; default?: unknown }>
required?: string[]
}
expect(parameters.required).toEqual(['text'])
expect(parameters.properties.count!.type).toBe('number')
expect(parameters).toMatchObject({
title: 'Raw parameters',
default: { text: 'default' },
examples: [{ text: 'example' }],
})
expect(parameters.properties.count!.type).toBe('integer')
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.
@@ -202,7 +210,10 @@ describe('cordis_mount', () => {
name: 'nested_json_schema_tool',
description: 'nested dialect',
parameters: {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
type: 'object',
properties: {
cfg: { type: 'object', properties: { label: { type: 'string' } }, required: ['label'] },
},
},
async execute(args) { return [{ type: 'text', text: args.cfg.label }] },
}))
@@ -217,14 +228,123 @@ describe('cordis_mount', () => {
expect(text(await call(ctx, 'nested_json_schema_tool', { cfg: { label: 'hi' } }))).toBe('hi')
})
it('normalizes every unified DSL node and lossless annotation shape across the sandbox realm', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'unified-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'unified_schema_tool',
description: 'all unified nodes',
parameters: {
any: {
type: 'json',
title: 'Any JSON',
default: { nested: [1, 'x', null] },
examples: [{ ok: true }],
},
choice: {
oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }],
required: true,
},
flags: { type: 'array' },
closed: { type: 'object', additionalProperties: false },
count: { type: 'number', enum: [1, 2], const: 1 },
},
async execute(args) { return [{ type: 'text', text: String(args.choice) }] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
const schema = ctx.tools.schemas().find(s => s.name === 'unified_schema_tool')!
expect(schema.parameters).toMatchObject({
properties: {
any: { title: 'Any JSON', default: { nested: [1, 'x', null] }, examples: [{ ok: true }] },
choice: { oneOf: [{ type: 'string', const: 'x' }, { type: 'null' }] },
flags: { type: 'array' },
closed: { type: 'object', additionalProperties: false },
count: { type: 'number', enum: [1, 2], const: 1 },
},
required: ['choice'],
})
})
it('normalizes unconstrained and closed nested nodes from a raw JSON Schema wrapper', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
return {
name: 'raw-unified-schema',
inject: ['tools'],
apply(ctx) {
harness.registerTool(ctx, harness.defineTool({
name: 'raw_unified_schema_tool',
description: 'raw unified nodes',
parameters: {
type: 'object',
additionalProperties: true,
properties: {
any: { description: 'unconstrained' },
cfg: {
type: 'object',
additionalProperties: false,
properties: { label: { type: 'string' } },
required: ['label'],
},
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
},
},
async execute() { return [] },
}))
},
}
`,
})
expect(result.isError).toBe(false)
expect(ctx.tools.schemas().find(s => s.name === 'raw_unified_schema_tool')!.parameters).toMatchObject({
properties: {
any: {},
cfg: { additionalProperties: false, required: ['label'] },
choice: { oneOf: [{ type: 'boolean' }, { type: 'null' }] },
},
})
})
it.each([
['parameters: 42', 'must be a SchemaSpec object'],
['parameters: { text: 42 }', 'parameters.text must be a SchemaSpec property object'],
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'boolean\' | \'object\' | \'array\' (got "str")'],
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be a boolean when present'],
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is only valid for type "object"'],
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is only valid for type "array"'],
])('rejects a malformed SchemaSpec (%s) with a teaching error', async (parameters, message) => {
['parameters: 42', 'must be a ParameterSchemaSpec object'],
['parameters: { text: 42 }', 'parameters.text must be a ParameterSchemaSpec property object'],
['parameters: { text: { type: \'str\' } }', 'parameters.text must declare a valid type: \'string\' | \'number\' | \'integer\' | \'boolean\' | \'null\' | \'object\' | \'array\' | \'json\' (got "str")'],
['parameters: { text: { type: \'string\', required: \'yes\' } }', 'parameters.text.required must be true when present'],
['parameters: { text: { type: \'string\', properties: {} } }', 'parameters.text.properties is not supported by the unified schema DSL'],
['parameters: { text: { type: \'string\', items: { type: \'string\' } } }', 'parameters.text.items is not supported by the unified schema DSL'],
['parameters: { text: { type: \'object\', properties: {} } }', 'parameters.text.additionalProperties must be explicitly true or false'],
['parameters: { text: { type: \'object\', additionalProperties: \'no\' } }', 'parameters.text.additionalProperties must be explicitly true or false'],
['parameters: { type: \'object\' }', 'parameters.properties must be an object of schemas'],
['parameters: { type: \'object\', properties: {}, additionalProperties: false }', 'parameters.additionalProperties must be true or omitted'],
['parameters: { type: \'object\', properties: {}, required: \'text\' }', 'parameters.required must be an array of declared property names'],
['parameters: { type: \'object\', properties: {}, required: undefined }', 'parameters.required must be an array of declared property names'],
['parameters: { type: \'object\', properties: {}, required: [\'text\'] }', 'parameters.required names undeclared property "text"'],
['parameters: { type: \'object\', properties: { text: { type: \'string\', required: true } } }', 'parameters.text.required belongs to the containing raw object schema'],
['parameters: { type: \'object\', properties: { text: { oneOf: \'bad\' } } }', 'parameters.text.oneOf must contain at least two schemas'],
['parameters: { type: \'object\', properties: { text: { type: \'json\' } } }', 'parameters.text must declare a valid type'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', additionalProperties: \'no\' } } }', 'parameters.cfg.additionalProperties must be a boolean'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', properties: 42 } } }', 'parameters.cfg.properties must be an object of schemas'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: [\'label\'] } } }', 'parameters.cfg.required names undeclared property "label"'],
['parameters: { type: \'object\', properties: { cfg: { type: \'object\', required: undefined } } }', 'parameters.cfg.required must be an array of declared property names'],
['parameters: { value: { oneOf: \'bad\' } }', 'parameters.value.oneOf must contain at least two schemas'],
['parameters: { value: { type: \'string\', enum: \'bad\' } }', 'enum must be a non-empty array'],
['parameters: { value: { type: \'json\', default: -0 } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Infinity } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: () => 1 } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: (() => { const v = {}; v.self = v; return v })() } }', 'parameters.value.default.self must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: Array(2) } }', 'parameters.value.default must be lossless JSON data'],
['parameters: { value: { type: \'json\', default: new Date(0) } }', 'parameters.value.default must be lossless JSON data'],
])('rejects a malformed ParameterSchemaSpec (%s) with a teaching error', async (parameters, message) => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -246,7 +366,7 @@ describe('cordis_mount', () => {
expect(text(result)).toContain(message)
})
it('accepts a nested object/array SchemaSpec (the DSL recursion)', async () => {
it('accepts a nested object/array ParameterSchemaSpec (the DSL recursion)', async () => {
const ctx = await setup()
const result = await call(ctx, 'cordis_mount', {
code: `
@@ -258,7 +378,7 @@ describe('cordis_mount', () => {
name: 'nested_schema_tool',
description: 'nested',
parameters: {
item: { type: 'object', required: true, properties: { label: { type: 'string', required: true } } },
item: { type: 'object', additionalProperties: true, required: true, properties: { label: { type: 'string', required: true } } },
tags: { type: 'array', items: { type: 'string' } },
},
async execute(args) { return [{ type: 'text', text: args.item.label }] },

View File

@@ -80,19 +80,19 @@ 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.
The unified schema DSL uses `ParameterSchemaSpec` for the implicit open parameter object and `ValueSchemaSpec` for any JSON-value root. It supports `string`, `number`, `integer`, `boolean`, `null`, `array`, `object`, author-only `json`, and exact-one `oneOf`; scalar `enum`/`const` values are type-correct. Every explicit DSL object declares `additionalProperties: true | false`, while the implicit parameter root and raw JSON Schema keep the standard open default.
A `defineTool` definition validates model arguments before execution and turns missing required values, wrong primitives, invalid enum members, and nested violations into `ToolArgsError` (`INVALID_ARGS`) for the normal error-result path. Extra keys are allowed, defaults are not applied, and object or array fields without `properties` or `items` receive only a type check. Raw-registered tools own their validation.
See `defineTool`, `validateArgs`, `ToolArgsError`, `SchemaSpec`, `InferArgs`, and `schemaSpecToJsonSchema` in the public API for details.
See `defineTool`, `validateArgs`, `ToolArgsError`, `ValueSchemaSpec`, `ParameterSchemaSpec`, `InferValue`, `InferArgs`, `valueSchemaSpecToJsonSchema`, and `parameterSchemaSpecToJsonSchema` in the public API for details.
Optional `timeoutMs` must be positive and finite; it is policy metadata, not model-visible schema.
Optional `isConcurrencySafe(args)` receives typed, softly validated arguments. Exact `true` permits concurrent dispatch/body execution; invalid input and all other outcomes remain exclusive. Opted-in bodies do not mutate parent-owned state, and shared-state races must commute or fail closed. The [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md) owns the full safety contract.
### Structured-output schema subset
### Enforced raw JSON Schema subset
`StructuredOutputSchema` is the object-rooted raw JSON Schema subset used by subagents and workflows for machine-readable results. It accepts one scalar `type`, object `properties`/`required`/boolean `additionalProperties`, array `items`, and scalar `enum`/`const`. The annotations `description`, `title`, `default`, and `examples` are ignored but must remain JSON data. Type arrays, undeclared required keys, and unsupported keywords fail through `OutputSchemaError` rather than being ignored; `validateStructuredValue()` returns path-qualified violations without throwing.
`JsonSchemaNode` is the raw counterpart shared by tool outputs, Code Mode generation, subagents, and workflows. It permits any JSON root, an annotation-only unconstrained JSON node, and exact-one `oneOf`; annotations must remain lossless JSON. `assertSupportedJsonSchema()` rejects unsupported constructs, while `validateJsonSchemaValue()` returns path-qualified violations. Subagents and workflows retain their caller-defined object-root requirement through `assertObjectJsonSchema()` and `ObjectJsonSchema`, not through a limitation in the shared vocabulary.
### Tool-owned UI presentation

View File

@@ -23,27 +23,42 @@ import { renderToolsSdk } from './ts-types.ts'
export {
defineTool,
schemaSpecToJsonSchema,
valueSchemaSpecToJsonSchema,
parameterSchemaSpecToJsonSchema,
validateArgs,
ToolArgsError,
type SchemaSpec,
type SchemaProp,
type SchemaType,
type ValueSchemaAnnotations,
type StringValueSchemaSpec,
type NumberValueSchemaSpec,
type IntegerValueSchemaSpec,
type BooleanValueSchemaSpec,
type NullValueSchemaSpec,
type ArrayValueSchemaSpec,
type ObjectValueSchemaSpec,
type JsonValueSchemaSpec,
type OneOfValueSchemaSpec,
type ValueSchemaSpec,
type ParameterPropertySpec,
type ParameterSchemaSpec,
type ParameterJsonSchema,
type InferValue,
type InferArgs,
type DefineToolOptions,
type JsonSchemaObject,
} from './schema.ts'
export {
assertSupportedOutputSchema,
validateStructuredValue,
OutputSchemaError,
type StructuredOutputSchema,
type StructuredSchemaNode,
type StructuredSchemaType,
type StructuredScalar,
assertSupportedJsonSchema,
assertObjectJsonSchema,
validateJsonSchemaValue,
JsonSchemaError,
type JsonSchemaNode,
type ObjectJsonSchema,
type JsonSchemaType,
type JsonSchemaScalar,
} from './json-schema.ts'
export type { JsonValue } from '@deepseek-ai/dsh-session'
export { CodeRunFailedError, RUN_CODE_NAME } from './code-mode.ts'
export { jsonSchemaToTs, renderToolsSdk } from './ts-types.ts'

View File

@@ -1,122 +1,124 @@
/**
* Structured-output JSON Schema subset for subagents and workflows. It supports
* one scalar `type`; object `properties`/`required`/boolean
* `additionalProperties`; array `items`; scalar `enum`/`const`; and JSON-valued
* annotations. Unsupported or misplaced keywords reject rather than being
* accepted without enforcement, and structured-output roots must be objects.
* Enforced JSON Schema subset shared by tool outputs, generated Code Mode
* types, subagents, and workflows. The subset accepts any JSON root, an
* annotation-only schema for unconstrained JSON, one scalar `type`, object
* `properties`/`required`/boolean `additionalProperties`, array `items`,
* type-correct scalar `enum`/`const`, and exact-one `oneOf`.
*
* Unsupported or misplaced keywords reject rather than being accepted without
* enforcement. Consumers that require an object root apply
* {@link assertObjectJsonSchema} at their own boundary.
* @module dsh-tools/json-schema
*/
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
/** The scalar values `enum`/`const` may carry (finite numbers only). */
export type StructuredScalar = string | number | boolean | null
/** Scalar JSON values supported by `enum` and `const`. */
export type JsonSchemaScalar = string | number | boolean | null
/** The `type` keywords the subset accepts. */
export type StructuredSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/** Single-type keywords accepted by the enforced subset. */
export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null'
/** Scalar-only schema types accepted by literal constraints. */
type JsonSchemaScalarType = Exclude<JsonSchemaType, 'object' | 'array'>
/**
* One node of the structured-output schema subset. Recursive via `properties`
* and `items`; see the module doc for the exact keyword semantics.
* One raw JSON Schema node in the enforced subset. The optional fields express
* the external wire shape; {@link assertSupportedJsonSchema} rejects invalid
* combinations before a caller treats the node as trusted.
*/
export interface StructuredSchemaNode {
type: StructuredSchemaType
export interface JsonSchemaNode {
/** Omit with no constraints for any JSON value, or use `oneOf`. */
type?: JsonSchemaType
/** Exactly one branch must validate; at least two branches are required. */
oneOf?: JsonSchemaNode[]
/** Nested property schemas (`type: 'object'` only). */
properties?: Record<string, StructuredSchemaNode>
properties?: Record<string, JsonSchemaNode>
/** Required property names; each must appear in `properties`. */
required?: string[]
/** `false` rejects undeclared keys; absent/`true` allows them (JSON Schema default). */
/** `false` rejects undeclared keys; absent/`true` follows JSON Schema's open default. */
additionalProperties?: boolean
/** Item schema (`type: 'array'` only); absent ⇒ any JSON items. */
items?: StructuredSchemaNode
/** Allowed values (scalar types only). */
enum?: StructuredScalar[]
/** The single allowed value (scalar types only). */
const?: StructuredScalar
/** Item schema (`type: 'array'` only); absent accepts any JSON item. */
items?: JsonSchemaNode
/** Allowed values for a scalar node. */
enum?: JsonSchemaScalar[]
/** The single allowed value for a scalar node. */
const?: JsonSchemaScalar
/** Annotation, ignored for validation. */
description?: string
/** Annotation, ignored for validation. */
title?: string
/** Annotation, ignored for validation (must still be JSON data). */
default?: unknown
/** Annotation, ignored for validation (must still be JSON data). */
examples?: unknown
/** Annotation, ignored for validation but required to be lossless JSON. */
default?: JsonValue
/** Annotation, ignored for validation but required to be lossless JSON. */
examples?: JsonValue
}
/** A structured-output schema: an OBJECT-rooted {@link StructuredSchemaNode}. */
export type StructuredOutputSchema = StructuredSchemaNode & { type: 'object' }
/** A consumer-constrained object-rooted schema. */
export type ObjectJsonSchema = JsonSchemaNode & { type: 'object' }
/**
* Thrown by {@link assertSupportedOutputSchema} when a schema falls outside the
* supported subset. Extends {@link HarnessError} (`code: 'UNSUPPORTED_SCHEMA'`)
* so seam code and tool results can route on it; `violations` lists every
* offending path, not just the first.
* Thrown when a raw schema falls outside the enforced subset. `violations`
* lists every offending path instead of stopping at the first author error.
*/
export class OutputSchemaError extends HarnessError {
/** The individual violation messages, in walk order. */
export class JsonSchemaError extends HarnessError {
/** Individual schema violations in walk order. */
readonly violations: string[]
constructor(violations: string[]) {
super(`unsupported output schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
this.name = 'OutputSchemaError'
super(`unsupported JSON schema: ${violations.join('; ')}`, 'UNSUPPORTED_SCHEMA')
this.name = 'JsonSchemaError'
this.violations = violations
}
}
/** The keywords the subset accepts, checked (`constraint`) or ignored (`annotation`). */
const CONSTRAINT_KEYWORDS = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'const'])
const CONSTRAINT_KEYWORDS = new Set([
'type',
'oneOf',
'properties',
'required',
'additionalProperties',
'items',
'enum',
'const',
])
const ANNOTATION_KEYWORDS = new Set(['description', 'title', 'default', 'examples'])
const SCHEMA_TYPES: readonly StructuredSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
const SCHEMA_TYPES: readonly JsonSchemaType[] = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null']
/**
* Whether a value is a PLAIN JSON object — non-null, non-array, and with a
* prototype chain of at most one link (`null`-proto, or any realm's
* `Object.prototype`, whose own prototype is `null`). Realm-agnostic on
* purpose: a schema materialized in another realm carries THAT realm's
* `Object.prototype`, which an identity check would wrongly reject. Exotic
* hosts (`Date`, `Map`, class instances) have longer chains and are rejected —
* they would serialize lossily (`Date` → string, `Map` → `{}`) instead of
* failing loud.
* Test for a realm-agnostic plain JSON record without accepting arrays or
* exotic objects.
* @param value - candidate record from any JavaScript realm.
* @returns Whether the value has a plain-object prototype chain.
*/
function isObjectLike(value: unknown): value is Record<string, unknown> {
export function isPlainJsonRecord(value: unknown): value is Record<string, unknown> {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
const proto: unknown = Object.getPrototypeOf(value)
return proto === null || Object.getPrototypeOf(proto) === null
}
/** Whether a value is a supported scalar (`enum`/`const` member): string, finite number, boolean, or null. */
function isStructuredScalar(value: unknown): value is StructuredScalar {
return value === null || typeof value === 'string' || typeof value === 'boolean'
|| (typeof value === 'number' && Number.isFinite(value))
/** Lossless finite JSON number, excluding negative zero. */
function isJsonNumber(value: unknown): value is number {
return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)
}
/**
* Whether a value is JSON data (annotation payloads only): scalars, arrays, and
* object-likes of such values. Realm-agnostic on purpose (no prototype check) —
* the schema may have been materialized from another realm; structural JSON-ness
* is what the wire needs. Cycles are rejected via `seen`.
*/
function isJsonData(value: unknown, seen: Set<object>): boolean {
if (isStructuredScalar(value)) return true
// The scalar check above already returned for null, so `object` here is a real object.
if (typeof value !== 'object') return false
if (seen.has(value)) return false
seen.add(value)
try {
if (Array.isArray(value)) return value.every(entry => isJsonData(entry, seen))
// A non-plain object (Date, Map, class instance) is NOT JSON data even when
// it has no enumerable values — it would serialize lossily, not loudly.
if (!isObjectLike(value)) return false
return Object.values(value).every(entry => isJsonData(entry, seen))
} finally {
seen.delete(value)
/** Whether a scalar is valid for one declared schema type. */
function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is JsonSchemaScalar {
switch (type) {
case 'string': return typeof value === 'string'
case 'number': return isJsonNumber(value)
case 'integer': return isJsonNumber(value) && Number.isInteger(value)
case 'boolean': return typeof value === 'boolean'
case 'null': return value === null
/* v8 ignore next -- JsonSchemaScalarType is closed; this retains compile-time exhaustiveness. */
default: return assertNever(type, 'JsonSchemaType')
}
}
/** Collect subset violations for one schema node (recursive walk). */
/** Collect every violation for one raw schema node. */
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
if (!isObjectLike(node)) {
if (!isPlainJsonRecord(node)) {
violations.push(`${path} must be a schema object`)
return
}
@@ -125,199 +127,258 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
return
}
seen.add(node)
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
if (!isJsonData(node[key], new Set())) violations.push(`${path}.${key} annotation must be JSON data`)
continue
try {
for (const key of Object.keys(node)) {
if (CONSTRAINT_KEYWORDS.has(key)) continue
if (ANNOTATION_KEYWORDS.has(key)) {
try {
if (!isJsonValue(node[key])) violations.push(`${path}.${key} annotation must be lossless JSON data`)
} catch {
violations.push(`${path}.${key} annotation must be lossless JSON data`)
}
continue
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/oneOf/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (node.description !== undefined && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (node.title !== undefined && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
violations.push(`${path}.${key} is not a supported keyword (subset: type/properties/required/additionalProperties/items/enum/const + annotations)`)
}
if (typeof node.description !== 'undefined' && typeof node.description !== 'string') {
violations.push(`${path}.description must be a string`)
}
if (typeof node.title !== 'undefined' && typeof node.title !== 'string') {
violations.push(`${path}.title must be a string`)
}
const type = node.type
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
const hasType = Object.hasOwn(node, 'type')
const hasOneOf = Object.hasOwn(node, 'oneOf')
if (hasType && hasOneOf) {
violations.push(`${path} cannot declare both type and oneOf`)
return
}
if (!hasType && !hasOneOf) {
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
}
return
}
if (hasOneOf) {
const oneOf = node.oneOf
if (!Array.isArray(oneOf) || oneOf.length < 2) {
violations.push(`${path}.oneOf must be an array of at least two schemas`)
} else {
for (let index = 0; index < oneOf.length; index++) {
checkSchemaNode(oneOf[index], `${path}.oneOf[${index}]`, violations, seen)
}
}
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} is not supported beside oneOf`)
}
return
}
const type = node.type
if (typeof type !== 'string' || !(SCHEMA_TYPES as readonly unknown[]).includes(type)) {
violations.push(Array.isArray(type)
? `${path}.type must be a single type string (type arrays are not supported)`
: `${path}.type must be one of ${SCHEMA_TYPES.join('/')}`)
return
}
const schemaType = type as JsonSchemaType
const allowedFor: Record<string, JsonSchemaType[]> = {
properties: ['object'],
required: ['object'],
additionalProperties: ['object'],
items: ['array'],
enum: ['string', 'number', 'integer', 'boolean', 'null'],
const: ['string', 'number', 'integer', 'boolean', 'null'],
}
for (const [key, types] of Object.entries(allowedFor)) {
if (Object.hasOwn(node, key) && !types.includes(schemaType)) {
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
}
}
switch (schemaType) {
case 'object': {
const properties = node.properties
if (Object.hasOwn(node, 'properties')) {
if (!isPlainJsonRecord(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
}
}
}
const required = node.required
if (Object.hasOwn(node, 'required')) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isPlainJsonRecord(properties) ? properties : {}
for (const key of required as string[]) {
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (Object.hasOwn(node, 'items')) checkSchemaNode(node.items, `${path}.items`, violations, seen)
break
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
if (Object.hasOwn(node, 'enum')) {
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => scalarMatches(schemaType, entry))) {
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
}
}
if (Object.hasOwn(node, 'const') && !scalarMatches(schemaType, node.const)) {
violations.push(`${path}.const must be a ${schemaType} value`)
}
break
}
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
default: assertNever(schemaType, 'JsonSchemaType')
}
} finally {
seen.delete(node)
return
}
const schemaType = type as StructuredSchemaType
// Keywords that only make sense on one type are rejected elsewhere — an
// `items` on an object (or `properties` on a string) is a schema-author bug
// the subset surfaces rather than ignores.
const allowedFor: Record<string, StructuredSchemaType[]> = {
properties: ['object'],
required: ['object'],
additionalProperties: ['object'],
items: ['array'],
enum: ['string', 'number', 'integer', 'boolean', 'null'],
const: ['string', 'number', 'integer', 'boolean', 'null'],
}
for (const [key, types] of Object.entries(allowedFor)) {
if (key in node && !types.includes(schemaType)) {
violations.push(`${path}.${key} is not supported on type "${schemaType}"`)
}
}
switch (schemaType) {
case 'object': {
const properties = node.properties
if (properties !== undefined) {
if (!isObjectLike(properties)) {
violations.push(`${path}.properties must be an object of schemas`)
} else {
for (const [key, child] of Object.entries(properties)) {
checkSchemaNode(child, `${path}.properties.${key}`, violations, seen)
}
}
}
const required = node.required
if (required !== undefined) {
if (!Array.isArray(required) || required.some(entry => typeof entry !== 'string')) {
violations.push(`${path}.required must be an array of strings`)
} else {
const declared = isObjectLike(properties) ? properties : {}
// The guard above proved every entry is a string.
for (const key of required as string[]) {
// Own-property check: `in` would let inherited names (`toString`)
// satisfy the declared-in-properties contract via the prototype.
if (!Object.hasOwn(declared, key)) violations.push(`${path}.required names "${key}" which is not in properties`)
}
}
}
if (node.additionalProperties !== undefined && typeof node.additionalProperties !== 'boolean') {
violations.push(`${path}.additionalProperties must be a boolean`)
}
break
}
case 'array': {
if (node.items !== undefined) checkSchemaNode(node.items, `${path}.items`, violations, seen)
break
}
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null': {
const allowed = node.enum
if (allowed !== undefined) {
if (!Array.isArray(allowed) || allowed.length === 0 || !allowed.every(entry => isStructuredScalar(entry))) {
violations.push(`${path}.enum must be a non-empty array of scalars`)
}
}
if ('const' in node && !isStructuredScalar(node.const)) {
violations.push(`${path}.const must be a scalar`)
}
break
}
/* v8 ignore start -- defensive: schemaType was membership-checked against SCHEMA_TYPES above, so no runtime value reaches here */
default:
assertNever(schemaType, 'assertSupportedOutputSchema')
/* v8 ignore stop */
}
seen.delete(node)
}
/**
* Assert `schema` is a supported {@link StructuredOutputSchema} — object-rooted
* and entirely within the enforced subset. Throws {@link OutputSchemaError}
* (`UNSUPPORTED_SCHEMA`) listing EVERY violation; returns (and narrows) on
* success. Call this at the seam boundary, before any child is created.
* @param schema - the caller-supplied schema (unknown until asserted).
* @returns nothing — the assertion signature narrows `schema` to
* {@link StructuredOutputSchema} in the caller's scope on normal return.
* Assert that an arbitrary raw schema uses only the enforced subset.
* Annotation-only schemas are accepted as the standard unconstrained-JSON
* form; callers that require an object root use {@link assertObjectJsonSchema}.
* @param schema - untrusted raw JSON Schema.
* @returns Assertion that the schema belongs to the supported subset.
*/
export function assertSupportedOutputSchema(schema: unknown): asserts schema is StructuredOutputSchema {
export function assertSupportedJsonSchema(schema: unknown): asserts schema is JsonSchemaNode {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as StructuredSchemaNode).type !== 'object') {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new OutputSchemaError(violations)
if (violations.length > 0) throw new JsonSchemaError(violations)
}
/** Collect violations for one value against an (already asserted) schema node. */
function checkValue(node: StructuredSchemaNode, value: unknown, path: string): string[] {
/**
* Assert the enforced subset plus the object-root constraint retained by
* subagent and workflow structured outputs.
* @param schema - untrusted caller-supplied schema.
* @returns Assertion that the schema belongs to the supported subset and has an object root.
*/
export function assertObjectJsonSchema(schema: unknown): asserts schema is ObjectJsonSchema {
const violations: string[] = []
checkSchemaNode(schema, 'schema', violations, new Set())
if (violations.length === 0 && (schema as JsonSchemaNode).type !== 'object') {
violations.push('schema.type must be "object" (structured output is object-rooted)')
}
if (violations.length > 0) throw new JsonSchemaError(violations)
}
/** Safely test the lossless JSON boundary when a getter may throw. */
function safelyIsJsonValue(value: unknown): boolean {
try {
return isJsonValue(value)
} catch {
return false
}
}
/** Root-aware diagnostic path for the parameter validator's empty sentinel. */
function diagnosticPath(path: string): string {
return path === '' ? 'arguments' : path
}
/** Append one object property without a leading dot at an implicit root. */
function propertyPath(path: string, key: string): string {
return path === '' ? key : `${path}.${key}`
}
/** Collect value violations for one trusted schema node. */
function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
if (node.oneOf !== undefined) {
const matches = node.oneOf.filter(branch => checkValue(branch, value, path).length === 0).length
return matches === 1 ? [] : [`"${diagnosticPath(path)}" must match exactly one oneOf branch (matched ${matches})`]
}
if (node.type === undefined) {
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON value`]
}
switch (node.type) {
case 'object': {
if (!isObjectLike(value)) return [`"${path}" must be an object`]
if (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`]
const violations: string[] = []
const properties = node.properties ?? {}
// Own-property discipline throughout: JSON carries own enumerable
// properties only, so an inherited `toString` must not satisfy
// `required`, dodge `additionalProperties: false`, or be validated as if
// the value carried it.
for (const key of node.required ?? []) {
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${path}.${key}"`)
if (!Object.hasOwn(value, key) || value[key] === undefined) violations.push(`missing required property "${propertyPath(path, key)}"`)
}
for (const [key, child] of Object.entries(properties)) {
if (!Object.hasOwn(value, key) || value[key] === undefined) continue
violations.push(...checkValue(child, value[key], `${path}.${key}`))
violations.push(...checkValue(child, value[key], propertyPath(path, key)))
}
if (node.additionalProperties === false) {
for (const key of Object.keys(value)) {
if (!Object.hasOwn(properties, key)) violations.push(`"${path}.${key}" is not a declared property (additionalProperties: false)`)
if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`)
}
}
return violations
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`]
}
case 'array': {
if (!Array.isArray(value)) return [`"${path}" must be an array`]
if (!node.items) return []
if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return [`"${diagnosticPath(path)}" must be an array`]
const items = node.items
return value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
const violations = items === undefined
? []
: value.flatMap((entry, index) => checkValue(items, entry, `${path}[${index}]`))
if (violations.length > 0) return violations
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a dense lossless JSON array`]
}
case 'string': {
if (typeof value !== 'string') return [`"${path}" must be a string`]
if (typeof value !== 'string') return [`"${diagnosticPath(path)}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number' || !Number.isFinite(value)) return [`"${path}" must be a finite number`]
if (typeof value !== 'number') return [`"${diagnosticPath(path)}" must be a number`]
if (!isJsonNumber(value)) return [`"${diagnosticPath(path)}" must be a finite JSON number`]
break
}
case 'integer': {
if (typeof value !== 'number' || !Number.isInteger(value)) return [`"${path}" must be an integer`]
if (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`]
break
}
case 'null': {
if (value !== null) return [`"${path}" must be null`]
if (value !== null) return [`"${diagnosticPath(path)}" must be null`]
break
}
default:
return assertNever(node.type, 'validateStructuredValue')
default: return assertNever(node.type, 'JsonSchemaType')
}
// Scalar constraint checks, shared by every scalar branch above.
if (node.enum && !node.enum.includes(value)) {
return [`"${path}" must be one of ${JSON.stringify(node.enum)}`]
if (node.enum !== undefined && !node.enum.includes(value)) {
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
}
if ('const' in node && value !== node.const) {
return [`"${path}" must be ${JSON.stringify(node.const)}`]
if (Object.hasOwn(node, 'const') && value !== node.const) {
return [`"${diagnosticPath(path)}" must be ${JSON.stringify(node.const)}`]
}
return []
}
/**
* Validate a value against an (already {@link assertSupportedOutputSchema}-
* asserted) schema. Returns human-readable, path-qualified violation messages
* — empty means valid. Total: never throws, however malformed the value.
* @param schema - the asserted schema to check against.
* @param value - the candidate value (e.g. parsed tool-call arguments).
* @returns every violation found, in walk order (empty = valid).
* Validate a candidate value against an asserted raw schema. The function is
* total for arbitrary values and returns path-qualified violations.
* @param schema - a schema accepted by {@link assertSupportedJsonSchema}.
* @param value - the candidate JSON value.
* @param path - root label used in diagnostics.
* @returns All violations in walk order; empty means valid.
*/
export function validateStructuredValue(schema: StructuredOutputSchema, value: unknown): string[] {
return checkValue(schema, value, 'value')
export function validateJsonSchemaValue(schema: JsonSchemaNode, value: unknown, path = 'value'): string[] {
return checkValue(schema, value, path)
}

View File

@@ -1,173 +1,314 @@
/** Typed tool-parameter DSL with argument inference and JSON Schema output. @module dsh-tools/schema */
/** Unified JSON-value schema DSL, inference, compilation, and typed tool helper. @module dsh-tools/schema */
import { assertNever, HarnessError } from '@deepseek-ai/dsh-llm'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { JsonValue } from '@deepseek-ai/dsh-session'
import type { ToolDefinition, ToolExecuteReturn, ToolRunContext, ToolResult } from './index.ts'
import { assertSupportedJsonSchema, isPlainJsonRecord, JsonSchemaError, validateJsonSchemaValue } from './json-schema.ts'
import type { JsonSchemaNode, JsonSchemaScalar, ObjectJsonSchema } from './json-schema.ts'
import type { ToolCallView, ToolResultView } from './presentation.ts'
// ---------------------------------------------------------------------------
// SchemaSpec — the author-facing per-property type
// ---------------------------------------------------------------------------
/** Valid JSON Schema primitive types for tool parameters. */
export type SchemaType = 'string' | 'number' | 'boolean' | 'object' | 'array'
/** One schema-spec property entry. */
export interface SchemaProp {
type: SchemaType
/** Per-property required flag (NOT the JSON Schema top-level required array). */
required?: true
/** Human-readable description, surfaced in the JSON Schema as well. */
/** Annotation keywords shared by every author-facing schema node. */
export interface ValueSchemaAnnotations {
/** Human-readable description projected into JSON Schema and generated types. */
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'. */
items?: SchemaProp
/** Human-readable title projected into JSON Schema. */
title?: string
/** Non-validating default annotation; it must be lossless JSON data. */
default?: JsonValue
/** Non-validating examples annotation; it must be lossless JSON data. */
examples?: JsonValue
}
/** String value schema with type-correct literal constraints. */
export interface StringValueSchemaSpec extends ValueSchemaAnnotations {
type: 'string'
enum?: readonly string[]
const?: string
}
/** Finite JSON-number schema with type-correct literal constraints. */
export interface NumberValueSchemaSpec extends ValueSchemaAnnotations {
type: 'number'
enum?: readonly number[]
const?: number
}
/** Integer schema with type-correct literal constraints. */
export interface IntegerValueSchemaSpec extends ValueSchemaAnnotations {
type: 'integer'
enum?: readonly number[]
const?: number
}
/** Boolean value schema with type-correct literal constraints. */
export interface BooleanValueSchemaSpec extends ValueSchemaAnnotations {
type: 'boolean'
enum?: readonly boolean[]
const?: boolean
}
/** Null value schema with type-correct literal constraints. */
export interface NullValueSchemaSpec extends ValueSchemaAnnotations {
type: 'null'
enum?: readonly null[]
const?: null
}
/** Array value schema; omitted `items` accepts any lossless JSON item. */
export interface ArrayValueSchemaSpec extends ValueSchemaAnnotations {
type: 'array'
items?: ValueSchemaSpec
}
/**
* The author-facing parameter schema: a shallow map of property name to
* {@link SchemaProp}. Required-ness is a per-property boolean (`required:
* true`), not a separate array.
* Explicit object value schema. Openness is mandatory so a nested or output
* object never acquires an accidental JSON Schema default.
*/
export type SchemaSpec = Record<string, SchemaProp>
export interface ObjectValueSchemaSpec extends ValueSchemaAnnotations {
type: 'object'
properties?: ParameterSchemaSpec
additionalProperties: boolean
}
// ---------------------------------------------------------------------------
// InferArgs — type-level mapping from SchemaSpec to TS argument type
// ---------------------------------------------------------------------------
/** Author-only unconstrained lossless JSON node. */
export interface JsonValueSchemaSpec extends ValueSchemaAnnotations {
type: 'json'
}
/** Map a {@link SchemaType} to its TS primitive type. */
type TypeOf<T extends SchemaType> =
T extends 'string' ? string :
T extends 'number' ? number :
T extends 'boolean' ? boolean :
T extends 'object' ? Record<string, unknown> :
T extends 'array' ? unknown[] :
never
/** Exact-one union schema; at least two branches are required. */
export interface OneOfValueSchemaSpec extends ValueSchemaAnnotations {
oneOf: readonly [ValueSchemaSpec, ValueSchemaSpec, ...ValueSchemaSpec[]]
}
/** One author-facing schema for any lossless JSON value root. */
export type ValueSchemaSpec =
| StringValueSchemaSpec
| NumberValueSchemaSpec
| IntegerValueSchemaSpec
| BooleanValueSchemaSpec
| NullValueSchemaSpec
| ArrayValueSchemaSpec
| ObjectValueSchemaSpec
| JsonValueSchemaSpec
| OneOfValueSchemaSpec
/** One implicit parameter-root property, optionally required. */
export type ParameterPropertySpec = ValueSchemaSpec & { required?: true }
/**
* Tool parameter schema. The map itself is an implicit open object root;
* requiredness remains a per-property `required: true` annotation.
*/
export type ParameterSchemaSpec = Record<string, ParameterPropertySpec>
/** Raw JSON Schema projection of the implicit parameter object. */
export interface ParameterJsonSchema extends ObjectJsonSchema {
properties: Record<string, JsonSchemaNode>
}
/** Flatten an intersection into one object type for readable hovers. */
type Simplify<T> = { [K in keyof T]: T[K] } & {}
/** Keys of `S` whose prop is marked `required: true`. */
type RequiredKeys<S extends SchemaSpec> =
{ [K in keyof S]: S[K] extends { required: true } ? K : never }[keyof S]
/** Keys of a property map marked `required: true`. */
type RequiredKeys<S extends ParameterSchemaSpec> = {
[K in keyof S]: S[K] extends { required: true } ? K : never
}[keyof S]
/**
* The VALUE type of one {@link SchemaProp} — optionality is handled at the
* key level by {@link InferArgs}, never here.
* - `properties` on 'object' → recurse into the nested SchemaSpec
* - `items` on 'array' → recurse into the item prop (arrays of objects work)
* - otherwise → the primitive for `type`
*/
type InferPropValue<P extends SchemaProp> =
P extends { type: 'object'; properties: infer Sub extends SchemaSpec } ? InferArgs<Sub> :
P extends { type: 'array'; items: infer Item extends SchemaProp } ? InferPropValue<Item>[] :
TypeOf<P['type']>
/** Infer the declared value of one parameter property without key optionality. */
type InferProperty<P extends ParameterPropertySpec> = P extends ValueSchemaSpec ? InferValue<P> : never
/**
* Infer the TS argument type for a complete {@link SchemaSpec}.
*
* Properties marked `required: true` are required keys; all others are
* genuinely optional keys (`?`), so callers may omit them entirely.
*
* Example:
* ```ts
* type Args = InferArgs<{ path: { type: 'string'; required: true }; limit: { type: 'number' } }>
* // → { path: string; limit?: number }
* ```
*/
export type InferArgs<S extends SchemaSpec> = Simplify<
& { [K in RequiredKeys<S>]: InferPropValue<S[K]> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferPropValue<S[K]> }
/** Infer an implicit property map into required and optional object keys. */
type InferProperties<S extends ParameterSchemaSpec> = Simplify<
& { [K in RequiredKeys<S>]: InferProperty<S[K]> }
& { [K in Exclude<keyof S, RequiredKeys<S>>]?: InferProperty<S[K]> }
>
// ---------------------------------------------------------------------------
// Runtime conversion: SchemaSpec → JSON Schema
// ---------------------------------------------------------------------------
/** Infer an explicit object node, including its declared openness. */
type InferObject<S extends ObjectValueSchemaSpec> =
S extends { properties: infer P extends ParameterSchemaSpec }
? S['additionalProperties'] extends true
? InferProperties<P> & Record<string, JsonValue>
: InferProperties<P>
: S['additionalProperties'] extends true
? Record<string, JsonValue>
: Record<string, never>
/** Infer a scalar node's literal constraint before its broad primitive type. */
type InferScalar<S, Fallback> =
S extends { const: infer C } ? C :
S extends { enum: readonly (infer E)[] } ? E :
Fallback
/**
* Convert a single {@link SchemaProp} to its JSON Schema `properties` entry.
* The per-property `required` flag is collected; the caller builds the
* top-level `required` array.
* Infer the TypeScript value accepted by an author-facing value schema.
* Output schemas may therefore infer object, array, scalar, or null roots.
*/
function propToJsonSchema(prop: SchemaProp): { schema: Record<string, unknown>; required: boolean } {
const result: Record<string, unknown> = { 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
export type InferValue<S extends ValueSchemaSpec> =
S extends StringValueSchemaSpec ? InferScalar<S, string> :
S extends NumberValueSchemaSpec | IntegerValueSchemaSpec ? InferScalar<S, number> :
S extends BooleanValueSchemaSpec ? InferScalar<S, boolean> :
S extends NullValueSchemaSpec ? null :
S extends ArrayValueSchemaSpec
? S extends { items: infer I extends ValueSchemaSpec } ? InferValue<I>[] : JsonValue[]
: S extends ObjectValueSchemaSpec ? InferObject<S> :
S extends JsonValueSchemaSpec ? JsonValue :
S extends OneOfValueSchemaSpec ? InferValue<S['oneOf'][number]> :
never
const required = prop.required === true
/** Infer the TypeScript argument object for an implicit parameter schema. */
export type InferArgs<S extends ParameterSchemaSpec> = InferProperties<S>
if (prop.type === 'object' && prop.properties) {
const nested = schemaSpecToJsonSchema(prop.properties)
result.properties = nested.properties
if (nested.required && nested.required.length > 0) {
result.required = nested.required
const ANNOTATION_KEYS = ['description', 'title', 'default', 'examples'] as const
/** Throw one author-schema violation through the shared schema error type. */
function authorError(message: string): never {
throw new JsonSchemaError([message])
}
/** Copy own annotation fields for validation by the raw-schema boundary. */
function copyAnnotations(source: Record<string, unknown>, target: JsonSchemaNode): void {
if (Object.hasOwn(source, 'description')) target.description = source.description as string
if (Object.hasOwn(source, 'title')) target.title = source.title as string
if (Object.hasOwn(source, 'default')) target.default = source.default as JsonValue
if (Object.hasOwn(source, 'examples')) target.examples = source.examples as JsonValue
}
/** Reject author-only keys outside one node's declared vocabulary. */
function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed: readonly string[]): void {
for (const key of Object.keys(source)) {
if (!allowed.includes(key)) authorError(`${path}.${key} is not supported by the value schema DSL`)
}
}
/** Compile one implicit property map, collecting per-property requiredness. */
function compilePropertyMap(
input: unknown,
path: string,
seen: Set<object>,
): { properties: Record<string, JsonSchemaNode>; required?: string[] } {
if (!isPlainJsonRecord(input)) authorError(`${path} must be an object of value schemas`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const properties: Record<string, JsonSchemaNode> = {}
const required: string[] = []
for (const [key, property] of Object.entries(input)) {
if (!isPlainJsonRecord(property)) authorError(`${path}.${key} must be a value schema object`)
if (Object.hasOwn(property, 'required') && property.required !== true) {
authorError(`${path}.${key}.required must be true when present`)
}
properties[key] = compileValueSchema(property, `${path}.${key}`, seen, true)
if (property.required === true) required.push(key)
}
return required.length > 0 ? { properties, required } : { properties }
} finally {
seen.delete(input)
}
if (prop.type === 'array' && prop.items) {
const { schema: itemsSchema } = propToJsonSchema(prop.items)
result.items = itemsSchema
}
return { schema: result, required }
}
/** The return type of {@link schemaSpecToJsonSchema}. */
export interface JsonSchemaObject {
type: 'object'
properties: Record<string, unknown>
required?: string[]
/** Compile one author node without applying any consumer root restriction. */
function compileValueSchema(
input: unknown,
path: string,
seen: Set<object>,
allowRequired = false,
): JsonSchemaNode {
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
if (seen.has(input)) authorError(`${path} is circular`)
seen.add(input)
try {
const authorKeys = [...ANNOTATION_KEYS, ...(allowRequired ? ['required'] : [])]
const node: JsonSchemaNode = {}
if (Object.hasOwn(input, 'oneOf')) {
assertAuthorKeys(input, path, [...authorKeys, 'oneOf', 'type'])
if (Object.hasOwn(input, 'type')) authorError(`${path} cannot declare both type and oneOf`)
if (!Array.isArray(input.oneOf)) authorError(`${path}.oneOf must be an array of at least two value schemas`)
node.oneOf = input.oneOf.map((branch, index) => compileValueSchema(branch, `${path}.oneOf[${index}]`, seen))
copyAnnotations(input, node)
return node
}
switch (input.type) {
case 'json':
assertAuthorKeys(input, path, [...authorKeys, 'type'])
copyAnnotations(input, node)
return node
case 'object': {
assertAuthorKeys(input, path, [...authorKeys, 'type', 'properties', 'additionalProperties'])
if (!Object.hasOwn(input, 'additionalProperties') || typeof input.additionalProperties !== 'boolean') {
authorError(`${path}.additionalProperties must be explicitly true or false`)
}
node.type = 'object'
copyAnnotations(input, node)
node.additionalProperties = input.additionalProperties
if (Object.hasOwn(input, 'properties')) {
const compiled = compilePropertyMap(input.properties, `${path}.properties`, seen)
node.properties = compiled.properties
if (compiled.required !== undefined) node.required = compiled.required
}
return node
}
case 'array':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'items'])
node.type = 'array'
copyAnnotations(input, node)
if (Object.hasOwn(input, 'items')) node.items = compileValueSchema(input.items, `${path}.items`, seen)
return node
case 'string':
case 'number':
case 'integer':
case 'boolean':
case 'null':
assertAuthorKeys(input, path, [...authorKeys, 'type', 'enum', 'const'])
node.type = input.type
copyAnnotations(input, node)
if (Object.hasOwn(input, 'enum')) {
node.enum = Array.isArray(input.enum)
? Array.from(input.enum as unknown[], entry => entry as JsonSchemaScalar)
: input.enum as JsonSchemaScalar[]
}
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
return node
default:
return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
}
} finally {
seen.delete(input)
}
}
/**
* Convert a {@link SchemaSpec} to standard JSON Schema (`type: 'object'`,
* `properties`, `required` array).
*
* This is a plain function — no schemastery or other framework dependency.
* @param spec - the author-facing per-property schema to convert.
* @returns the wire-format JSON Schema; the top-level `required` array is
* omitted entirely when no property is marked required.
* Compile one author-facing value schema to the enforced raw JSON Schema
* subset. The author-only `json` node becomes an annotation-only schema.
* @param spec - schema for any JSON-value root.
* @returns The asserted raw schema projection.
*/
export function schemaSpecToJsonSchema(spec: SchemaSpec): JsonSchemaObject {
const properties: Record<string, unknown> = {}
const required: string[] = []
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
const schema = compileValueSchema(spec, 'schema', new Set())
assertSupportedJsonSchema(schema)
return schema
}
for (const [key, prop] of Object.entries(spec)) {
const { schema, required: isRequired } = propToJsonSchema(prop)
properties[key] = schema
if (isRequired) required.push(key)
}
const result: JsonSchemaObject = {
/**
* Compile the implicit open parameter object into raw JSON Schema.
* @param spec - per-property parameter definitions.
* @returns An object-rooted raw schema with no implicit-root openness override.
*/
export function parameterSchemaSpecToJsonSchema(spec: ParameterSchemaSpec): ParameterJsonSchema {
const compiled = compilePropertyMap(spec, 'parameters', new Set())
const schema: ParameterJsonSchema = {
type: 'object',
properties,
properties: compiled.properties,
...(compiled.required === undefined ? {} : { required: compiled.required }),
}
if (required.length > 0) result.required = required
return result
assertSupportedJsonSchema(schema)
return schema
}
// ---------------------------------------------------------------------------
// Runtime validation: model-generated args ↔ SchemaSpec
// ---------------------------------------------------------------------------
/**
* Thrown by a {@link defineTool} tool when the model-generated arguments don't
* match the declared {@link SchemaSpec}. Extends {@link HarnessError}
* (`code: 'INVALID_ARGS'`); the registry's execution pipeline catches it and
* returns an `isError` ToolExecutionResult carrying the structured error, so
* the model can self-correct and downstream plugins can route on the code.
*/
/** Invalid model-generated arguments for a typed tool. */
export class ToolArgsError extends HarnessError {
/** The individual violation messages, in declaration order. */
/** Individual violations in schema-walk order. */
readonly violations: string[]
constructor(violations: string[]) {
@@ -177,152 +318,63 @@ export class ToolArgsError extends HarnessError {
}
}
/** Whether a value is a non-null, non-array object (a JSON Schema `object`). */
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Collect violations for one property value against its {@link SchemaProp}. */
function checkValue(prop: SchemaProp, value: unknown, path: string): string[] {
switch (prop.type) {
case 'string': {
if (typeof value !== 'string') return [`"${path}" must be a string`]
break
}
case 'number': {
if (typeof value !== 'number') return [`"${path}" must be a number`]
break
}
case 'boolean': {
if (typeof value !== 'boolean') return [`"${path}" must be a boolean`]
break
}
case 'object': {
if (!isPlainObject(value)) return [`"${path}" must be an object`]
// Mirror the converter: an object without `properties` only type-checks.
return prop.properties ? checkSpec(prop.properties, value, path) : []
}
case 'array': {
if (!Array.isArray(value)) return [`"${path}" must be an array`]
// Mirror the converter: an array without `items` only type-checks.
if (!prop.items) return []
const items = prop.items
return value.flatMap((el, i) => checkValue(items, el, `${path}[${i}]`))
}
default: return assertNever(prop.type, 'validateArgs')
}
// Enum membership, checked uniformly: the converter emits `enum` for any
// type ([prop.enum]), so the validator must too. `enum` is `string[]`, so a
// non-string value can never be a member — it falls out here, consistent
// with the schema the model was given.
if (prop.enum && !(prop.enum as unknown[]).includes(value)) {
return [`"${path}" must be one of ${JSON.stringify(prop.enum)}`]
}
return []
}
/** Collect violations for an object value against a {@link SchemaSpec}. */
function checkSpec(spec: SchemaSpec, value: unknown, path: string): string[] {
if (!isPlainObject(value)) return [`"${path || 'arguments'}" must be an object`]
const violations: string[] = []
for (const [key, prop] of Object.entries(spec)) {
const propPath = path ? `${path}.${key}` : key
const v = value[key]
if (v === undefined) {
// A required key absent OR present-but-undefined is a violation; an
// optional absent key is fine. `default` is NOT applied (validation only).
if (prop.required === true) violations.push(`missing required property "${propPath}"`)
continue
}
violations.push(...checkValue(prop, v, propPath))
}
return violations
}
/**
* Validate model-generated `args` against a {@link SchemaSpec}, returning a
* list of human-readable violation messages (empty = valid). Total — never
* throws, regardless of how malformed `args` is.
*
* Semantics mirror {@link schemaSpecToJsonSchema} exactly: the top level must
* be a non-array object; required keys come only from `required: true`; extra
* keys are allowed (no `additionalProperties: false`); `default` is not
* applied; an `object`/`array` prop without `properties`/`items` only
* type-checks; `enum` is membership (strings only).
* @param spec - the declared parameter schema to validate against.
* @param args - the model-generated arguments, however malformed.
* @returns the violation messages in declaration order; empty means valid.
* Validate model-generated arguments against an implicit parameter schema.
* @param spec - declared parameter schema.
* @param args - candidate arguments, however malformed.
* @returns Path-qualified violations; empty means valid.
*/
export function validateArgs(spec: SchemaSpec, args: unknown): string[] {
return checkSpec(spec, args, '')
export function validateArgs(spec: ParameterSchemaSpec, args: unknown): string[] {
return validateJsonSchemaValue(parameterSchemaSpecToJsonSchema(spec), args, '')
}
// ---------------------------------------------------------------------------
// defineTool — typed helper for first-party plugin authors
// ---------------------------------------------------------------------------
/** Options for {@link defineTool}. */
export interface DefineToolOptions<S extends SchemaSpec> {
export interface DefineToolOptions<S extends ParameterSchemaSpec> {
/** Tool name (must be unique). */
readonly name: string
/** Human-readable description sent to the model. */
readonly description: string
/**
* Parameter schema using the per-property-required DSL. Converted to
* standard JSON Schema at runtime.
*/
/** Per-property parameter schema compiled to an implicit open object root. */
readonly parameters: S
/**
* Optional cooperative tool-call timeout budget in milliseconds. When given it
* must be a positive finite number; it is attached to the produced
* {@link ToolDefinition} for `@deepseek-ai/dsh-timeout-policy` to enforce and
* is never sent to the model.
*/
/** Optional positive cooperative timeout budget in milliseconds. */
readonly timeoutMs?: number
/**
* Optional pure synchronous classifier for sibling overlap. It receives typed
* arguments after soft validation; invalid input returns `false` without
* invoking it. See {@link ToolDefinition.isConcurrencySafe}.
* Pure classifier for sibling overlap.
* @param args - typed validated arguments.
* @returns whether this call may join a parallel group.
* @returns Whether the call may join a parallel group.
*/
isConcurrencySafe?(args: InferArgs<S>): boolean
/**
* Tool execution function. `args` is typed as {@link InferArgs<S>} — zero
* casts needed. Returns either a bare {@link ContentBlock}`[]` (model-facing
* content only) or a `{ content, meta }` object to also attach a tool-private
* presentation payload (see {@link ToolExecuteReturn}).
* Execute the tool after argument validation.
* @param args - typed validated arguments.
* @param exec - execution identity, caller, cancellation, and nesting data.
* @returns Model-facing content and optional presentation metadata.
*/
execute(args: InferArgs<S>, exec: ToolRunContext): Promise<ToolExecuteReturn>
/**
* Optional: how to present the PENDING state of one call in a UI (an editor
* tool-call card, a CLI log line). `args` is the typed, schema-validated
* argument shape — zero casts. Pure and side-effect-free: a UI may call it
* during live streaming AND a session-log replay, so depend only on `args`.
* The tool owns its presentation so a UI never special-cases tool names. See
* {@link ToolCallView}.
* Pure pending-state presenter.
* @param args - typed validated arguments.
* @returns Tool-owned render intent, or `undefined` for the generic card.
*/
presentCall?(args: InferArgs<S>): ToolCallView | undefined
/**
* Optional: how to present the COMPLETED state, given the typed `args` and the
* `result`. Use it to reformat result content for a UI distinctly from the
* model-facing text (e.g. a fenced ```console block). Pure and side-effect-
* free for the same replay reason. See {@link ToolResultView}.
* Pure completed-state presenter.
* @param args - typed validated arguments.
* @param result - final model-facing tool result.
* @returns Tool-owned render intent, or `undefined` for the generic card.
*/
presentResult?(args: InferArgs<S>, result: ToolResult): ToolResultView | undefined
}
/**
* Define a first-party tool whose execution and presentation arguments are
* inferred from its per-property schema. Raw JSON-Schema definitions remain
* valid inputs to {@link ToolRegistry.register}; this helper is authoring sugar.
* @param options - the tool's name, description, typed parameter schema,
* execute body, and optional presenters.
* @returns a registry-ready definition with strict execution validation and
* soft presenter and classifier validation for replay compatibility.
* Define a first-party tool with inferred arguments and strict execution
* validation. Replay-only presenters validate softly and fall back to generic
* rendering for obsolete logged arguments.
* @param options - typed definition and optional presenters.
* @returns A registry-ready definition.
*/
export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal execute methods don't use `this`; the reference is safe.
export function defineTool<S extends ParameterSchemaSpec>(options: DefineToolOptions<S>): ToolDefinition {
// Object-literal methods do not use `this`; retaining references is safe.
// eslint-disable-next-line @typescript-eslint/unbound-method
const userExecute = options.execute
// eslint-disable-next-line @typescript-eslint/unbound-method
@@ -334,41 +386,34 @@ export function defineTool<S extends SchemaSpec>(options: DefineToolOptions<S>):
if (options.timeoutMs !== undefined && (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0)) {
throw new Error(`defineTool(${options.name}): timeoutMs must be a positive finite number`)
}
const parameters = parameterSchemaSpecToJsonSchema(options.parameters)
const validate = (args: unknown): string[] => validateJsonSchemaValue(parameters, args, '')
const tool: ToolDefinition = {
name: options.name,
description: options.description,
parameters: schemaSpecToJsonSchema(options.parameters) as unknown as Record<string, unknown>,
parameters: parameters as unknown as Record<string, unknown>,
...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
async execute(args: unknown, exec: ToolRunContext): Promise<ToolExecuteReturn> {
// Validate the model-generated args before the typed body runs. On
// mismatch we throw ToolArgsError; the registry turns it into an
// isError result so the model can self-correct. After this guard, the
// cast to InferArgs<S> reflects the validated shape.
const violations = validateArgs(options.parameters, args)
const violations = validate(args)
if (violations.length > 0) throw new ToolArgsError(violations)
return userExecute(args as InferArgs<S>, exec)
},
}
// Presentation is display-only and may run on REPLAY of arbitrary logged args
// (possibly from an older schema), so it must never throw: validate softly and
// fall back to `undefined` (a generic UI presentation) on any mismatch, rather
// than the hard `ToolArgsError` the execute path raises.
if (userPresentCall) {
tool.presentCall = (args: unknown): ToolCallView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
if (validate(args).length > 0) return undefined
return userPresentCall(args as InferArgs<S>)
}
}
if (userPresentResult) {
tool.presentResult = (args: unknown, result: ToolResult): ToolResultView | undefined => {
if (validateArgs(options.parameters, args).length > 0) return undefined
if (validate(args).length > 0) return undefined
return userPresentResult(args as InferArgs<S>, result)
}
}
// Invalid arguments fail closed without invoking the typed classifier.
if (userIsConcurrencySafe) {
tool.isConcurrencySafe = (args: unknown): boolean => {
if (validateArgs(options.parameters, args).length > 0) return false
if (validate(args).length > 0) return false
return userIsConcurrencySafe(args as InferArgs<S>)
}
}

View File

@@ -7,6 +7,8 @@
*/
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { assertSupportedJsonSchema } from './json-schema.ts'
import type { JsonSchemaScalar } from './json-schema.ts'
/** Property names that are valid bare TS identifiers; anything else is quoted. */
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/
@@ -30,47 +32,72 @@ function docLines(description: unknown, indent: number): string[] {
return [`${pad(indent)}/** ${collapsed.replaceAll('*/', String.raw`*\/`)} */`]
}
/** Render one scalar already validated by the unified schema boundary. */
function renderScalar(value: JsonSchemaScalar): string {
return JSON.stringify(value)
}
/** Render a validated scalar `const`/`enum`, falling back to the broad type. */
function renderConstrainedScalar(node: Record<string, unknown>, type: string): string {
const broad = type === 'integer' ? 'number' : type
if (Object.hasOwn(node, 'const')) return renderScalar(node.const as JsonSchemaScalar)
if (Object.hasOwn(node, 'enum')) {
return (node.enum as JsonSchemaScalar[]).map(renderScalar).join(' | ')
}
return broad
}
/** Parenthesize a union or object intersection before applying `[]`. */
function arrayItem(type: string): string {
return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]`
}
/**
* Map one JSON-Schema node to a TypeScript type literal. Handles exactly the
* subset the `defineTool` DSL emits — `object` (`properties` + `required`),
* `string` (with `enum` → a literal union), `number`, `boolean`, `array`
* (`items`) — and returns `unknown` for anything else, without throwing.
* Map one enforced JSON-Schema node to a TypeScript type literal. Supports
* every unified schema construct and returns `unknown` for malformed or
* unsupported inputs without throwing.
* @param schema - the JSON-Schema node (any shape; hostile inputs degrade).
* @param indent - the indentation level for nested object members.
* @returns the TS type text (multi-line for objects with properties).
*/
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
if (typeof schema !== 'object' || schema === null) return 'unknown'
try {
assertSupportedJsonSchema(schema)
} catch {
return 'unknown'
}
const node = schema as Record<string, unknown>
if (Object.hasOwn(node, 'oneOf')) {
return (node.oneOf as unknown[]).map(branch => jsonSchemaToTs(branch, indent)).join(' | ')
}
if (!Object.hasOwn(node, 'type')) return 'JsonValue'
switch (node.type) {
case 'string': {
if (Array.isArray(node.enum) && node.enum.length > 0 && node.enum.every(value => typeof value === 'string')) {
return node.enum.map(value => JSON.stringify(value)).join(' | ')
}
return 'string'
}
case 'number': return 'number'
case 'boolean': return 'boolean'
case 'string': return renderConstrainedScalar(node, 'string')
case 'number': return renderConstrainedScalar(node, 'number')
case 'integer': return renderConstrainedScalar(node, 'integer')
case 'boolean': return renderConstrainedScalar(node, 'boolean')
case 'null': return renderConstrainedScalar(node, 'null')
case 'array': {
const item = jsonSchemaToTs(node.items, indent)
// Parenthesize a union item type so `('a' | 'b')[]` parses as intended.
return item.includes('|') ? `(${item})[]` : `${item}[]`
return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue')
}
case 'object': {
const properties = node.properties
if (typeof properties !== 'object' || properties === null) return 'Record<string, unknown>'
const open = node.additionalProperties !== false
if (properties === undefined) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const entries = Object.entries(properties as Record<string, unknown>)
if (entries.length === 0) return 'Record<string, unknown>'
const required = new Set(Array.isArray(node.required) ? node.required.filter(name => typeof name === 'string') : [])
if (entries.length === 0) return open ? 'Record<string, JsonValue>' : 'Record<string, never>'
const required = new Set(node.required as string[] | undefined)
const lines: string[] = ['{']
for (const [name, prop] of entries) {
const description = typeof prop === 'object' && prop !== null ? (prop as Record<string, unknown>).description : undefined
const description = (prop as Record<string, unknown>).description
lines.push(...docLines(description, indent + 1))
lines.push(`${pad(indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: ${jsonSchemaToTs(prop, indent + 1)};`)
}
lines.push(`${pad(indent)}}`)
return lines.join('\n')
const declared = lines.join('\n')
return open ? `${declared} & Record<string, JsonValue>` : declared
}
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
default: return 'unknown'
}
}
@@ -106,5 +133,6 @@ export function renderToolsSdk(schemas: ToolSchema[]): string {
const declaration = members.length > 0
? `declare const tools: {\n${members.join('\n')}\n}`
: 'declare const tools: {}'
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${declaration}\n\`\`\``
const jsonValue = 'type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }'
return `${SDK_INSTRUCTIONS}\n\n\`\`\`ts\n${jsonValue}\n\n${declaration}\n\`\`\``
}

View File

@@ -1,304 +1,326 @@
import { describe, expect, it } from 'vitest'
import {
assertSupportedOutputSchema,
OutputSchemaError,
validateStructuredValue,
type StructuredOutputSchema,
} from '../src/json-schema.ts'
assertObjectJsonSchema,
assertSupportedJsonSchema,
JsonSchemaError,
validateJsonSchemaValue,
type JsonSchemaNode,
type ObjectJsonSchema,
} from '../src/index.ts'
/** Assert-and-narrow helper: the asserted schema, typed. */
function asserted(schema: unknown): StructuredOutputSchema {
assertSupportedOutputSchema(schema)
function asserted(schema: unknown): JsonSchemaNode {
assertSupportedJsonSchema(schema)
return schema
}
/** The violations OutputSchemaError carries for a bad schema (throws if it passes). */
function violationsOf(schema: unknown): string[] {
try {
assertSupportedOutputSchema(schema)
} catch (error: unknown) {
if (error instanceof OutputSchemaError) return error.violations
throw error
}
throw new Error('expected the schema to be rejected')
function assertedObject(schema: unknown): ObjectJsonSchema {
assertObjectJsonSchema(schema)
return schema
}
describe('assertSupportedOutputSchema', () => {
it('accepts a representative subset schema (all supported keywords)', () => {
const schema = asserted({
type: 'object',
description: 'a finding',
title: 'Finding',
properties: {
file: { type: 'string', description: 'path' },
line: { type: 'integer' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
tags: { type: 'array', items: { type: 'string' } },
nested: {
type: 'object',
properties: { x: { type: 'number', default: 3, examples: [1, 2] } },
additionalProperties: false,
function violationsOf(schema: unknown, objectRoot = false): string[] {
try {
if (objectRoot) assertObjectJsonSchema(schema)
else assertSupportedJsonSchema(schema)
} catch (error: unknown) {
if (error instanceof JsonSchemaError) return error.violations
throw error
}
throw new Error('expected schema rejection')
}
describe('the enforced raw JSON Schema subset', () => {
it('accepts every JSON root and every supported node', () => {
for (const schema of [
{ type: 'string' },
{ type: 'number' },
{ type: 'integer' },
{ type: 'boolean' },
{ type: 'null' },
{ type: 'array', items: { type: 'string' } },
{
type: 'object',
properties: {
nested: { type: 'object', properties: {}, additionalProperties: false },
free: {},
},
anything: { type: 'array' },
required: ['nested'],
additionalProperties: true,
},
required: ['file', 'line'],
additionalProperties: true,
})
expect(schema.type).toBe('object')
{ oneOf: [{ type: 'string' }, { type: 'number' }] },
{ description: 'any JSON', title: 'JSON', default: null, examples: [1, 'x'] },
]) {
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
}
})
it('rejects a non-object root (scalar/array-rooted schemas)', () => {
expect(violationsOf({ type: 'string' })).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
expect(violationsOf({ type: 'array', items: { type: 'string' } }))
.toContain('schema.type must be "object" (structured output is object-rooted)')
it('retains an object-root guard only at consumers that need it', () => {
expect(assertedObject({ type: 'object' }).type).toBe('object')
for (const schema of [{}, { type: 'string' }, { type: 'array' }, { oneOf: [{ type: 'string' }, { type: 'null' }] }]) {
expect(violationsOf(schema, true)).toEqual(['schema.type must be "object" (structured output is object-rooted)'])
}
})
it('rejects non-object schema nodes and missing/unknown type', () => {
expect(violationsOf('nope')).toEqual(['schema must be a schema object'])
it('rejects non-schema nodes, unknown types, and type arrays', () => {
expect(violationsOf(null)).toEqual(['schema must be a schema object'])
expect(violationsOf([])).toEqual(['schema must be a schema object'])
expect(violationsOf({})).toEqual(['schema.type must be one of object/array/string/number/integer/boolean/null'])
expect(violationsOf('no')).toEqual(['schema must be a schema object'])
expect(violationsOf({ type: 'tuple' })[0]).toMatch(/type must be one of/)
expect(violationsOf({ type: 'object', properties: { a: 'str' } })).toEqual(['schema.properties.a must be a schema object'])
})
it('rejects type ARRAYS with a dedicated message', () => {
expect(violationsOf({ type: ['string', 'null'] }))
.toEqual(['schema.type must be a single type string (type arrays are not supported)'])
})
it('rejects unsupported constraint keywords loudly (never accepted-then-ignored)', () => {
for (const keyword of ['oneOf', 'anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
const bad = violationsOf({ type: 'object', [keyword]: [] })
expect(bad.some(v => v.includes(`schema.${keyword} is not a supported keyword`))).toBe(true)
}
it('enforces oneOf vocabulary and its minimum branch count', () => {
expect(violationsOf({ oneOf: [] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
expect(violationsOf({ oneOf: [{}] })).toEqual(['schema.oneOf must be an array of at least two schemas'])
expect(violationsOf({ oneOf: 'x' })).toEqual(['schema.oneOf must be an array of at least two schemas'])
expect(violationsOf({ type: 'string', oneOf: [{}, {}] }))
.toEqual(['schema cannot declare both type and oneOf'])
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'number' }], items: {} }))
.toEqual(['schema.items is not supported beside oneOf'])
expect(violationsOf({ oneOf: [{ type: 'string' }, { type: 'weird' }] })[0])
.toContain('schema.oneOf[1].type')
})
it('reports EVERY violation, not just the first', () => {
const bad = violationsOf({
it('rejects unknown and misplaced keywords without accepted-then-ignored behavior', () => {
for (const keyword of ['anyOf', 'allOf', 'not', 'pattern', 'minimum', 'maxLength', '$ref']) {
expect(violationsOf({ type: 'object', [keyword]: [] })[0]).toContain(`schema.${keyword} is not a supported keyword`)
}
expect(violationsOf({ type: 'object', items: {} }))
.toEqual(['schema.items is not supported on type "object"'])
expect(violationsOf({ type: 'array', properties: {} }))
.toEqual(['schema.properties is not supported on type "array"'])
expect(violationsOf({ type: 'object', enum: ['x'] }))
.toEqual(['schema.enum is not supported on type "object"'])
expect(violationsOf({ type: 'array', const: null }))
.toEqual(['schema.const is not supported on type "array"'])
expect(violationsOf({ properties: {}, required: [], additionalProperties: true, items: {}, enum: [], const: null }))
.toEqual([
'schema.properties requires type or oneOf',
'schema.required requires type or oneOf',
'schema.additionalProperties requires type or oneOf',
'schema.items requires type or oneOf',
'schema.enum requires type or oneOf',
'schema.const requires type or oneOf',
])
})
it('reports every independent schema violation', () => {
expect(violationsOf({
type: 'object',
pattern: 'x',
properties: { a: { type: 'weird' }, b: { type: 'string', minimum: 1 } },
})
expect(bad.length).toBe(3)
})).toHaveLength(3)
})
it('rejects keywords on the wrong type (items on object, properties on string, enum on object)', () => {
expect(violationsOf({ type: 'object', items: { type: 'string' } }))
.toEqual(['schema.items is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', properties: {} } } }))
.toEqual(['schema.properties.a.properties is not supported on type "string"'])
expect(violationsOf({ type: 'object', enum: [1] }))
.toEqual(['schema.enum is not supported on type "object"'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'array', const: 1 } } }))
.toEqual(['schema.properties.a.const is not supported on type "array"'])
})
it('validates required: must be string[] naming declared properties', () => {
expect(violationsOf({ type: 'object', required: 'file' }))
it('validates object properties, required names, and openness', () => {
expect(violationsOf({ type: 'object', properties: [] }))
.toEqual(['schema.properties must be an object of schemas'])
expect(violationsOf({ type: 'object', properties: { a: 'x' } }))
.toEqual(['schema.properties.a must be a schema object'])
expect(violationsOf({ type: 'object', required: 'a' }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', required: [1] }))
.toEqual(['schema.required must be an array of strings'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string' } }, required: ['b'] }))
.toEqual(['schema.required names "b" which is not in properties'])
expect(violationsOf({ type: 'object', required: ['a'] }))
.toEqual(['schema.required names "a" which is not in properties'])
})
it('validates additionalProperties must be boolean and enum/const must be scalars', () => {
expect(violationsOf({ type: 'object', additionalProperties: {} }))
expect(violationsOf({ type: 'object', properties: {}, required: ['missing'] }))
.toEqual(['schema.required names "missing" which is not in properties'])
expect(violationsOf({ type: 'object', additionalProperties: 'yes' }))
.toEqual(['schema.additionalProperties must be a boolean'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: [{}] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', enum: 'x' } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'number', enum: [Number.NaN] } } }))
.toEqual(['schema.properties.a.enum must be a non-empty array of scalars'])
expect(violationsOf({ type: 'object', properties: { a: { type: 'string', const: {} } } }))
.toEqual(['schema.properties.a.const must be a scalar'])
expect(violationsOf({ type: 'object', properties: undefined }))
.toEqual(['schema.properties must be an object of schemas'])
expect(violationsOf({ type: 'object', properties: undefined, required: ['missing'] }))
.toEqual([
'schema.properties must be an object of schemas',
'schema.required names "missing" which is not in properties',
])
})
it('rejects non-string description/title and non-JSON annotation payloads', () => {
expect(violationsOf({ type: 'object', description: 7 }))
.toEqual(['schema.description must be a string'])
expect(violationsOf({ type: 'object', title: 7 }))
.toEqual(['schema.title must be a string'])
expect(violationsOf({ type: 'object', default: () => 1 }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [undefined] }))
.toEqual(['schema.examples annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [Number.POSITIVE_INFINITY] }))
.toEqual(['schema.examples annotation must be JSON data'])
// A cyclic annotation payload is caught by the JSON-data walk.
const cyclicAnnotation: Record<string, unknown> = {}
cyclicAnnotation.self = cyclicAnnotation
expect(violationsOf({ type: 'object', default: cyclicAnnotation }))
.toEqual(['schema.default annotation must be JSON data'])
// Object/array annotations that ARE JSON data pass.
asserted({ type: 'object', default: { a: [1, 'x', null, true] } })
it('requires type-correct scalar enum and const values', () => {
for (const schema of [
{ type: 'string', enum: ['a'], const: 'a' },
{ type: 'number', enum: [1.5], const: 1.5 },
{ type: 'integer', enum: [1], const: 1 },
{ type: 'boolean', enum: [true], const: true },
{ type: 'null', enum: [null], const: null },
]) {
expect(() => { assertSupportedJsonSchema(schema) }, JSON.stringify(schema)).not.toThrow()
}
expect(violationsOf({ type: 'string', enum: [] }))
.toEqual(['schema.enum must be a non-empty array of string values'])
expect(violationsOf({ type: 'number', enum: ['1'] }))
.toEqual(['schema.enum must be a non-empty array of number values'])
expect(violationsOf({ type: 'integer', enum: [1.5] }))
.toEqual(['schema.enum must be a non-empty array of integer values'])
expect(violationsOf({ type: 'number', enum: [Number.NaN] }))
.toEqual(['schema.enum must be a non-empty array of number values'])
expect(violationsOf({ type: 'number', const: -0 }))
.toEqual(['schema.const must be a number value'])
expect(violationsOf({ type: 'boolean', const: 1 }))
.toEqual(['schema.const must be a boolean value'])
expect(violationsOf({ type: 'string', enum: undefined }))
.toEqual(['schema.enum must be a non-empty array of string values'])
})
it('rejects a circular schema instead of recursing forever', () => {
const node: Record<string, unknown> = { type: 'object' }
node.properties = { self: node }
expect(violationsOf(node)).toEqual(['schema.properties.self is circular'])
it('validates annotation types and lossless JSON payloads', () => {
expect(violationsOf({ description: 1 })).toEqual(['schema.description must be a string'])
expect(violationsOf({ title: 1 })).toEqual(['schema.title must be a string'])
for (const [key, value] of [
['default', undefined],
['examples', [undefined]],
['default', Number.POSITIVE_INFINITY],
['examples', new Date(0)],
] as const) {
expect(violationsOf({ [key]: value })).toEqual([`schema.${key} annotation must be lossless JSON data`])
}
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(violationsOf({ default: cyclic }))
.toEqual(['schema.default annotation must be lossless JSON data'])
const explosive = new Proxy({}, {
ownKeys() { throw new Error('annotation trap') },
})
expect(violationsOf({ examples: explosive }))
.toEqual(['schema.examples annotation must be lossless JSON data'])
})
it('accepts the same subschema object reused in two SIBLING positions (a DAG, not a cycle)', () => {
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
const cyclic: Record<string, unknown> = { type: 'object' }
cyclic.properties = { self: cyclic }
expect(violationsOf(cyclic)).toEqual(['schema.properties.self is circular'])
const leaf = { type: 'string' }
asserted({ type: 'object', properties: { a: leaf, b: leaf } })
})
it('required cannot be satisfied by INHERITED names — `toString` is not a declared property', () => {
// `'toString' in {}` is true via Object.prototype; the declared-property
// contract must be an own-property check.
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
})
it('rejects exotic host objects where the subset expects plain JSON structure', () => {
// A Map as `properties` has no own enumerable entries: structurally it
// would read as "no properties" and serialize to {} — lossy, not loud.
expect(() => { assertSupportedJsonSchema({ type: 'object', properties: { a: leaf, b: leaf } }) }).not.toThrow()
expect(violationsOf({ type: 'object', properties: new Map() }))
.toEqual(['schema.properties must be an object of schemas'])
// A Date node is not a schema object even though Object.values(date) is [].
expect(violationsOf({ type: 'object', properties: { at: new Date(0) } }))
.toEqual(['schema.properties.at must be a schema object'])
})
it('rejects exotic annotation payloads that would serialize lossily', () => {
expect(violationsOf({ type: 'object', default: new Date(0) }))
.toEqual(['schema.default annotation must be JSON data'])
expect(violationsOf({ type: 'object', examples: [new Map()] }))
.toEqual(['schema.examples annotation must be JSON data'])
it('uses own-property semantics for required declarations', () => {
expect(violationsOf({ type: 'object', properties: {}, required: ['toString'] }))
.toEqual(['schema.required names "toString" which is not in properties'])
})
})
describe('validateStructuredValue', () => {
const schema = asserted({
type: 'object',
properties: {
file: { type: 'string' },
line: { type: 'integer' },
score: { type: 'number' },
confirmed: { type: 'boolean' },
parent: { type: 'null' },
severity: { type: 'string', enum: ['low', 'high'] },
kind: { type: 'string', const: 'bug' },
tags: { type: 'array', items: { type: 'string' } },
free: { type: 'array' },
nested: { type: 'object', properties: { x: { type: 'number' } }, required: ['x'], additionalProperties: false },
},
required: ['file'],
describe('validateJsonSchemaValue', () => {
it('validates scalar, array, object, and null roots', () => {
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 'x')).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'number' }), 1.5)).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 2)).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), true)).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'null' }), null)).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'array', items: { type: 'string' } }), ['x'])).toEqual([])
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: 1 })).toEqual([])
})
it('accepts a fully valid value (empty violations)', () => {
expect(validateStructuredValue(schema, {
file: 'a.ts', line: 3, score: 0.5, confirmed: true, parent: null,
severity: 'high', kind: 'bug', tags: ['x'], free: [1, { any: true }], nested: { x: 1 },
})).toEqual([])
it('rejects wrong scalar types and lossy numbers', () => {
expect(validateJsonSchemaValue(asserted({ type: 'string' }), 1)).toEqual(['"value" must be a string'])
expect(validateJsonSchemaValue(asserted({ type: 'number' }), '1')).toEqual(['"value" must be a number'])
expect(validateJsonSchemaValue(asserted({ type: 'number' }), Number.NaN)).toEqual(['"value" must be a finite JSON number'])
expect(validateJsonSchemaValue(asserted({ type: 'number' }), -0)).toEqual(['"value" must be a finite JSON number'])
expect(validateJsonSchemaValue(asserted({ type: 'integer' }), 1.5)).toEqual(['"value" must be an integer'])
expect(validateJsonSchemaValue(asserted({ type: 'boolean' }), 'true')).toEqual(['"value" must be a boolean'])
expect(validateJsonSchemaValue(asserted({ type: 'null' }), 0)).toEqual(['"value" must be null'])
})
it('reports missing required and wrong root type', () => {
expect(validateStructuredValue(schema, {})).toEqual(['missing required property "value.file"'])
expect(validateStructuredValue(schema, 'nope')).toEqual(['"value" must be an object'])
expect(validateStructuredValue(schema, [])).toEqual(['"value" must be an object'])
it('enforces scalar enum and const together', () => {
const schema = asserted({ type: 'string', enum: ['a', 'b'], const: 'a' })
expect(validateJsonSchemaValue(schema, 'a')).toEqual([])
expect(validateJsonSchemaValue(schema, 'c')).toEqual(['"value" must be one of ["a","b"]'])
expect(validateJsonSchemaValue(schema, 'b')).toEqual(['"value" must be "a"'])
})
it('type-checks every scalar branch with path-qualified messages', () => {
expect(validateStructuredValue(schema, { file: 1 })).toEqual(['"value.file" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', line: 1.5 })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', line: 'x' })).toEqual(['"value.line" must be an integer'])
expect(validateStructuredValue(schema, { file: 'a', score: 'x' })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', score: Number.NaN })).toEqual(['"value.score" must be a finite number'])
expect(validateStructuredValue(schema, { file: 'a', confirmed: 'yes' })).toEqual(['"value.confirmed" must be a boolean'])
expect(validateStructuredValue(schema, { file: 'a', parent: 0 })).toEqual(['"value.parent" must be null'])
it('validates object requiredness, nested values, and raw open defaults', () => {
const open = asserted({
type: 'object',
properties: {
file: { type: 'string' },
nested: {
type: 'object',
properties: { line: { type: 'integer' } },
required: ['line'],
additionalProperties: false,
},
},
required: ['file'],
})
expect(validateJsonSchemaValue(open, { file: 'a', extra: [1], nested: { line: 2 } })).toEqual([])
expect(validateJsonSchemaValue(open, { nested: { line: 1 } }))
.toEqual(['missing required property "value.file"'])
expect(validateJsonSchemaValue(open, { file: 1, nested: {} })).toEqual([
'"value.file" must be a string',
'missing required property "value.nested.line"',
])
expect(validateJsonSchemaValue(open, { file: 'a', nested: { line: 1, extra: true } }))
.toEqual(['"value.nested.extra" is not a declared property (additionalProperties: false)'])
expect(validateJsonSchemaValue(open, 'x')).toEqual(['"value" must be an object'])
})
it('enforces enum membership and const equality', () => {
expect(validateStructuredValue(schema, { file: 'a', severity: 'mid' }))
.toEqual(['"value.severity" must be one of ["low","high"]'])
expect(validateStructuredValue(schema, { file: 'a', kind: 'feature' }))
.toEqual(['"value.kind" must be "bug"'])
it('treats present undefined as missing when required, then rejects other lossy objects', () => {
const required = asserted({ type: 'object', properties: { x: {} }, required: ['x'] })
expect(validateJsonSchemaValue(required, { x: undefined }))
.toEqual(['missing required property "value.x"'])
expect(validateJsonSchemaValue(asserted({ type: 'object' }), { x: undefined }))
.toEqual(['"value" must be a lossless JSON object'])
expect(validateJsonSchemaValue(asserted({ type: 'object' }), new Date(0)))
.toEqual(['"value" must be an object'])
})
it('checks arrays per index; an items-less array accepts anything', () => {
expect(validateStructuredValue(schema, { file: 'a', tags: 'x' })).toEqual(['"value.tags" must be an array'])
expect(validateStructuredValue(schema, { file: 'a', tags: ['ok', 2] })).toEqual(['"value.tags[1]" must be a string'])
expect(validateStructuredValue(schema, { file: 'a', free: [{ deep: [1] }, null] })).toEqual([])
it('validates dense arrays per index and rejects lossy arrays', () => {
const schema = asserted({ type: 'array', items: { type: 'integer' } })
expect(validateJsonSchemaValue(schema, [1, 2])).toEqual([])
expect(validateJsonSchemaValue(schema, [1, 1.5])).toEqual(['"value[1]" must be an integer'])
expect(validateJsonSchemaValue(schema, 'x')).toEqual(['"value" must be an array'])
const sparse: number[] = []
sparse.length = 2
sparse[0] = 1
expect(validateJsonSchemaValue(schema, sparse)).toEqual(['"value" must be a dense lossless JSON array'])
})
it('recurses into nested objects: required + additionalProperties: false', () => {
expect(validateStructuredValue(schema, { file: 'a', nested: {} }))
.toEqual(['missing required property "value.nested.x"'])
expect(validateStructuredValue(schema, { file: 'a', nested: { x: 1, y: 2 } }))
.toEqual(['"value.nested.y" is not a declared property (additionalProperties: false)'])
expect(validateStructuredValue(schema, { file: 'a', nested: 3 }))
.toEqual(['"value.nested" must be an object'])
it('validates exact-one oneOf semantics, including overlap', () => {
const disjoint = asserted({ oneOf: [{ type: 'string' }, { type: 'number' }] })
expect(validateJsonSchemaValue(disjoint, 'x')).toEqual([])
expect(validateJsonSchemaValue(disjoint, null))
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
const overlap = asserted({ oneOf: [{ type: 'number' }, { type: 'integer' }] })
expect(validateJsonSchemaValue(overlap, 1))
.toEqual(['"value" must match exactly one oneOf branch (matched 2)'])
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
})
it('a required key present-but-undefined counts as missing', () => {
expect(validateStructuredValue(schema, { file: undefined })).toEqual(['missing required property "value.file"'])
it('an unconstrained schema accepts only lossless JSON values', () => {
const anyJson = asserted({})
for (const value of [null, true, 1, 'x', [1], { x: null }]) {
expect(validateJsonSchemaValue(anyJson, value), JSON.stringify(value)).toEqual([])
}
for (const value of [undefined, () => 1, Number.POSITIVE_INFINITY, -0, new Map()]) {
expect(validateJsonSchemaValue(anyJson, value)).toEqual(['"value" must be a lossless JSON value'])
}
const cyclic: Record<string, unknown> = {}
cyclic.self = cyclic
expect(validateJsonSchemaValue(anyJson, cyclic)).toEqual(['"value" must be a lossless JSON value'])
const explosive = new Proxy({}, {
ownKeys() { throw new Error('value trap') },
})
expect(validateJsonSchemaValue(anyJson, explosive)).toEqual(['"value" must be a lossless JSON value'])
})
it('inherited properties satisfy nothing: required, additionalProperties, and recursion are own-property only', () => {
// required: ['toString'] must NOT be satisfied by Object.prototype.toString.
expect(validateStructuredValue(
it('uses own properties for requiredness, recursion, and closed-object checks', () => {
expect(validateJsonSchemaValue(
asserted({ type: 'object', properties: { toString: { type: 'string' } }, required: ['toString'] }),
{},
)).toEqual(['missing required property "value.toString"'])
// additionalProperties: false must flag an OWN `toString` key even though
// `'toString' in properties` is true via the prototype.
expect(validateStructuredValue(
asserted({ type: 'object', additionalProperties: false }),
{ toString: 1 },
)).toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
// A declared property the value does NOT carry must not be validated
// against the value's INHERITED member (constructor is a function on
// every plain object's prototype, not a carried property).
expect(validateStructuredValue(
expect(validateJsonSchemaValue(asserted({ type: 'object', additionalProperties: false }), { toString: 1 }))
.toEqual(['"value.toString" is not a declared property (additionalProperties: false)'])
expect(validateJsonSchemaValue(
asserted({ type: 'object', properties: { constructor: { type: 'string' } } }),
{},
)).toEqual([])
})
it('a non-plain object value is not an object in the JSON sense', () => {
expect(validateStructuredValue(asserted({ type: 'object' }), new Date(0)))
.toEqual(['"value" must be an object'])
})
it('collects multiple violations across branches in one pass', () => {
expect(validateStructuredValue(schema, { line: 'x', severity: 'mid' })).toEqual([
'missing required property "value.file"',
'"value.line" must be an integer',
'"value.severity" must be one of ["low","high"]',
])
})
it('null-typed const/enum work through the scalar path', () => {
const nullish = asserted({ type: 'object', properties: { a: { type: 'null', const: null } } })
expect(validateStructuredValue(nullish, { a: null })).toEqual([])
})
it('rejects a non-object properties value in the schema walk', () => {
expect(violationsOf({ type: 'object', properties: [] }))
.toEqual(['schema.properties must be an object of schemas'])
})
it('an object schema without properties/required only type-checks its value', () => {
const bare = asserted({ type: 'object' })
expect(validateStructuredValue(bare, { any: ['thing'] })).toEqual([])
expect(validateStructuredValue(bare, 7)).toEqual(['"value" must be an object'])
})
it('validateStructuredValue throws on a type the assert would never let through (assertNever backstop)', () => {
const forged = { type: 'tuple' } as unknown as StructuredOutputSchema
expect(() => validateStructuredValue(forged, 1)).toThrow(/tuple/)
it('keeps assertNever as a forged-schema backstop', () => {
const forged = { type: 'tuple' } as unknown as JsonSchemaNode
expect(() => validateJsonSchemaValue(forged, 1)).toThrow(/tuple/)
})
})

View File

@@ -1,61 +1,91 @@
/**
* Property-based tests for the tool-schema DSL (the property-testing Agent Note), including
* the the property-testing ↔ runtime-validation composition composition: generated args that satisfy a SchemaSpec must
* the property-testing ↔ runtime-validation composition: generated args that satisfy a ParameterSchemaSpec must
* pass validateArgs, and targeted corruptions must be rejected. This closes the
* validator/InferArgs drift risk noted in the arg-validation Agent Note.
*/
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { schemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
import type { SchemaProp, SchemaSpec } from '@deepseek-ai/dsh-tools'
import { parameterSchemaSpecToJsonSchema, validateArgs } from '@deepseek-ai/dsh-tools'
import type { ParameterPropertySpec, ParameterSchemaSpec, ValueSchemaSpec } from '@deepseek-ai/dsh-tools'
/** Remove parameter-only requiredness before nesting a schema as an array item. */
function asValueSchema(prop: ParameterPropertySpec): ValueSchemaSpec {
const { required: _required, ...schema } = prop
return schema
}
// A leaf prop arbitrary (no nesting) with optional required/enum.
function leafPropArb(): fc.Arbitrary<SchemaProp> {
function leafPropArb(): fc.Arbitrary<ParameterPropertySpec> {
return fc.oneof(
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'string', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'number', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): SchemaProp => ({ type: 'boolean', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'string', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'number', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'integer', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'boolean', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'null', ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() }).map(({ required }): ParameterPropertySpec => ({ type: 'json', ...required ? { required: true } : {} })),
fc.record({ values: fc.uniqueArray(fc.string({ minLength: 1 }), { minLength: 1, maxLength: 3 }), required: fc.boolean() })
.map(({ values, required }): SchemaProp => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
.map(({ values, required }): ParameterPropertySpec => ({ type: 'string', enum: values, ...required ? { required: true } : {} })),
fc.record({ value: fc.string(), required: fc.boolean() })
.map(({ value, required }): ParameterPropertySpec => ({ type: 'string', const: value, ...required ? { required: true } : {} })),
fc.record({ required: fc.boolean() })
.map(({ required }): ParameterPropertySpec => ({
oneOf: [{ type: 'string' }, { type: 'null' }],
...required ? { required: true } : {},
})),
)
}
/** A prop arbitrary up to `depth` levels of nesting (objects and arrays). */
function propArb(depth: number): fc.Arbitrary<SchemaProp> {
function propArb(depth: number): fc.Arbitrary<ParameterPropertySpec> {
if (depth <= 0) return leafPropArb()
return fc.oneof(
{ weight: 3, arbitrary: leafPropArb() },
{
weight: 1,
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean() })
.map(({ properties, required }): SchemaProp => ({ type: 'object', properties, ...required ? { required: true } : {} })),
arbitrary: fc.record({ properties: specArb(depth - 1), required: fc.boolean(), additionalProperties: fc.boolean() })
.map(({ properties, required, additionalProperties }): ParameterPropertySpec => ({
type: 'object',
additionalProperties,
properties,
...required ? { required: true } : {},
})),
},
{
weight: 1,
arbitrary: fc.record({ items: propArb(depth - 1), required: fc.boolean() })
.map(({ items, required }): SchemaProp => ({ type: 'array', items, ...required ? { required: true } : {} })),
.map(({ items, required }): ParameterPropertySpec => ({
type: 'array',
items: asValueSchema(items),
...required ? { required: true } : {},
})),
},
)
}
function specArb(depth: number): fc.Arbitrary<SchemaSpec> {
function specArb(depth: number): fc.Arbitrary<ParameterSchemaSpec> {
return fc.dictionary(fc.string({ minLength: 1, maxLength: 6 }), propArb(depth), { maxKeys: 4 })
}
/** Generate a value that satisfies a prop (used to build valid args). */
function valueForProp(prop: SchemaProp): fc.Arbitrary<unknown> {
function valueForProp(prop: ParameterPropertySpec): fc.Arbitrary<unknown> {
if ('oneOf' in prop) return fc.oneof(...prop.oneOf.map(valueForProp))
if ('const' in prop) return fc.constant(prop.const)
switch (prop.type) {
case 'string': return prop.enum ? fc.constantFrom(...prop.enum) : fc.string()
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true })
case 'number': return fc.double({ noNaN: true, noDefaultInfinity: true }).filter(value => !Object.is(value, -0))
case 'integer': return fc.integer()
case 'boolean': return fc.boolean()
case 'null': return fc.constant(null)
case 'object': return prop.properties ? validArgsForSpec(prop.properties) : fc.constant({})
case 'array': return prop.items ? fc.array(valueForProp(prop.items), { maxLength: 3 }) : fc.constant([])
case 'json': return fc.jsonValue()
}
}
/** Generate args satisfying every required key of a spec (optionals included randomly). */
function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown>> {
function validArgsForSpec(spec: ParameterSchemaSpec): fc.Arbitrary<Record<string, unknown>> {
const entries = Object.entries(spec)
return fc.tuple(...entries.map(([key, prop]) =>
fc.tuple(
@@ -76,29 +106,29 @@ function validArgsForSpec(spec: SchemaSpec): fc.Arbitrary<Record<string, unknown
}
/** Collect the `required: true` keys at the top level of a spec. */
function requiredKeys(spec: SchemaSpec): string[] {
function requiredKeys(spec: ParameterSchemaSpec): string[] {
return Object.entries(spec).filter(([, p]) => p.required === true).map(([k]) => k)
}
describe('schema DSL properties', () => {
it('JSON Schema `required` equals the required:true keys at every level', () => {
fc.assert(fc.property(specArb(2), (spec) => {
const checkLevel = (s: SchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
const checkLevel = (s: ParameterSchemaSpec, json: { required?: string[]; properties: Record<string, unknown> }) => {
expect(new Set(json.required ?? [])).toEqual(new Set(requiredKeys(s)))
for (const [key, prop] of Object.entries(s)) {
const propJson = json.properties[key] as Record<string, unknown>
if (prop.type === 'object' && prop.properties) {
if ('type' in prop && prop.type === 'object' && prop.properties) {
checkLevel(prop.properties, propJson as { required?: string[]; properties: Record<string, unknown> })
}
}
}
checkLevel(spec, schemaSpecToJsonSchema(spec))
checkLevel(spec, parameterSchemaSpecToJsonSchema(spec))
}))
})
it('conversion is total (never throws) for any spec', () => {
fc.assert(fc.property(specArb(3), (spec) => {
expect(() => schemaSpecToJsonSchema(spec)).not.toThrow()
expect(() => parameterSchemaSpecToJsonSchema(spec)).not.toThrow()
}))
})

View File

@@ -0,0 +1,138 @@
import { describe, expect, expectTypeOf, it } from 'vitest'
import {
JsonSchemaError,
parameterSchemaSpecToJsonSchema,
valueSchemaSpecToJsonSchema,
type InferArgs,
type InferValue,
type JsonValue,
type ParameterSchemaSpec,
type ValueSchemaSpec,
} from '../src/index.ts'
describe('the unified author schema DSL', () => {
it('compiles every value root and the author-only json node', () => {
expect(valueSchemaSpecToJsonSchema({ type: 'string', enum: ['a', 'b'], const: 'a' }))
.toEqual({ type: 'string', enum: ['a', 'b'], const: 'a' })
expect(valueSchemaSpecToJsonSchema({ type: 'number' })).toEqual({ type: 'number' })
expect(valueSchemaSpecToJsonSchema({ type: 'integer' })).toEqual({ type: 'integer' })
expect(valueSchemaSpecToJsonSchema({ type: 'boolean' })).toEqual({ type: 'boolean' })
expect(valueSchemaSpecToJsonSchema({ type: 'null' })).toEqual({ type: 'null' })
expect(valueSchemaSpecToJsonSchema({ type: 'array', items: { type: 'json' } }))
.toEqual({ type: 'array', items: {} })
expect(valueSchemaSpecToJsonSchema({ type: 'object', additionalProperties: false, properties: {} }))
.toEqual({ type: 'object', additionalProperties: false, properties: {} })
expect(valueSchemaSpecToJsonSchema({
type: 'json',
description: 'anything',
title: 'Any JSON',
default: null,
examples: [{ nested: true }],
})).toEqual({ description: 'anything', title: 'Any JSON', default: null, examples: [{ nested: true }] })
expect(valueSchemaSpecToJsonSchema({ oneOf: [{ type: 'string' }, { type: 'null' }] }))
.toEqual({ oneOf: [{ type: 'string' }, { type: 'null' }] })
})
it('keeps the implicit parameter root open while preserving explicit object openness', () => {
expect(parameterSchemaSpecToJsonSchema({
closed: {
type: 'object',
additionalProperties: false,
required: true,
properties: { id: { type: 'integer', required: true } },
},
open: { type: 'object', additionalProperties: true },
})).toEqual({
type: 'object',
properties: {
closed: {
type: 'object',
additionalProperties: false,
properties: { id: { type: 'integer' } },
required: ['id'],
},
open: { type: 'object', additionalProperties: true },
},
required: ['closed'],
})
})
it('rejects runtime-forged author forms rather than compiling them lossily', () => {
for (const schema of [
{ type: 'object' },
{ oneOf: [{ type: 'string' }] },
{ type: 'number', enum: ['1'] },
{ type: 'integer', const: 1.5 },
{ type: 'json', default: undefined },
{ type: 'array', items: { type: 'string', required: true } },
{ type: 'array', items: 42 },
{ type: 'string', extra: true },
{ type: 'string', oneOf: [{ type: 'string' }, { type: 'null' }] },
{ oneOf: 'not-an-array' },
{ type: 'string', enum: 'a' },
null,
]) {
expect(() => valueSchemaSpecToJsonSchema(schema as ValueSchemaSpec), JSON.stringify(schema)).toThrow(JsonSchemaError)
}
expect(() => parameterSchemaSpecToJsonSchema({
value: { type: 'string', required: false },
} as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema(null as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
expect(() => parameterSchemaSpecToJsonSchema({ bad: 42 } as unknown as ParameterSchemaSpec)).toThrow(JsonSchemaError)
})
it('rejects cyclic author schemas', () => {
const schema: Record<string, unknown> = { type: 'array' }
schema.items = schema
expect(() => valueSchemaSpecToJsonSchema(schema as unknown as ValueSchemaSpec)).toThrow(/circular/)
const properties: Record<string, unknown> = {}
properties.self = { type: 'object', additionalProperties: true, properties }
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
})
it('infers scalar literals, arrays, objects, json, and exact-one unions', () => {
expectTypeOf<InferValue<{ type: 'string'; enum: readonly ['a', 'b'] }>>().toEqualTypeOf<'a' | 'b'>()
expectTypeOf<InferValue<{ type: 'number'; const: 1 }>>().toEqualTypeOf<1>()
expectTypeOf<InferValue<{ type: 'integer' }>>().toEqualTypeOf<number>()
expectTypeOf<InferValue<{ type: 'boolean'; enum: readonly [true] }>>().toEqualTypeOf<true>()
expectTypeOf<InferValue<{ type: 'null' }>>().toEqualTypeOf<null>()
expectTypeOf<InferValue<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>()
expectTypeOf<InferValue<{ type: 'array' }>>().toEqualTypeOf<JsonValue[]>()
expectTypeOf<InferValue<{ type: 'json' }>>().toEqualTypeOf<JsonValue>()
expectTypeOf<InferValue<{ oneOf: readonly [{ type: 'string' }, { type: 'null' }] }>>()
.toEqualTypeOf<string | null>()
expectTypeOf<InferValue<{
type: 'object'
additionalProperties: false
properties: { id: { type: 'integer'; required: true }; label: { type: 'string' } }
}>>().toEqualTypeOf<{ id: number; label?: string }>()
expectTypeOf<InferValue<{
type: 'object'
additionalProperties: true
properties: { id: { type: 'integer'; required: true } }
}>>().toEqualTypeOf<{ id: number } & Record<string, JsonValue>>()
})
it('infers required and optional parameter keys', () => {
expectTypeOf<InferArgs<{
path: { type: 'string'; required: true }
offset: { type: 'integer' }
data: { type: 'json' }
}>>().toEqualTypeOf<{ path: string; offset?: number; data?: JsonValue }>()
})
it('makes invalid author forms compile-time errors', () => {
const invalidObjects = {
// @ts-expect-error explicit object schemas require an openness decision
object: { type: 'object' } satisfies ValueSchemaSpec,
// @ts-expect-error oneOf requires at least two branches
oneOf: { oneOf: [{ type: 'string' }] } satisfies ValueSchemaSpec,
// @ts-expect-error scalar enum values must match the node type
enum: { type: 'number', enum: ['1'] } satisfies ValueSchemaSpec,
// @ts-expect-error parameter requiredness is true-or-absent
required: { value: { type: 'string', required: false } } satisfies ParameterSchemaSpec,
}
expect(Object.keys(invalidObjects)).toHaveLength(4)
})
})

View File

@@ -5,8 +5,8 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ApprovalService, { type ApprovalOutcome, type ApprovalRequest } from '@deepseek-ai/dsh-user-approval'
import ToolRegistry, {
defineTool, schemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type SchemaSpec, type PreToolDecision, type PostToolDecision,
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
@@ -775,13 +775,13 @@ describe('ToolRegistry', () => {
})
describe('defineTool / schema DSL', () => {
it('converts SchemaSpec to standard JSON Schema with required array', () => {
it('converts ParameterSchemaSpec to standard JSON Schema with required array', () => {
const spec = {
path: { type: 'string', required: true, description: 'Absolute path' },
offset: { type: 'number' },
limit: { type: 'number', description: 'Max lines' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
@@ -794,7 +794,7 @@ describe('defineTool / schema DSL', () => {
})
it('handles empty spec (no properties, no required)', () => {
expect(schemaSpecToJsonSchema({})).toEqual({
expect(parameterSchemaSpecToJsonSchema({})).toEqual({
type: 'object',
properties: {},
})
@@ -804,19 +804,21 @@ describe('defineTool / schema DSL', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
required: true,
properties: {
host: { type: 'string', required: true },
port: { type: 'number' },
},
},
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema).toEqual({
type: 'object',
properties: {
config: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
@@ -958,8 +960,8 @@ describe('schema DSL edge cases', () => {
it('emits enum values in JSON Schema property', () => {
const spec = {
color: { type: 'string', enum: ['red', 'green', 'blue'], description: 'Color choice' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['color']).toMatchObject({
type: 'string',
enum: ['red', 'green', 'blue'],
@@ -970,8 +972,8 @@ 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)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['limit']).toMatchObject({
type: 'number',
default: 25,
@@ -981,8 +983,8 @@ describe('schema DSL edge cases', () => {
it('handles array items without nested properties (plain type array)', () => {
const spec = {
tags: { type: 'array', items: { type: 'string' } },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['tags']).toEqual({
type: 'array',
items: { type: 'string' },
@@ -992,8 +994,8 @@ describe('schema DSL edge cases', () => {
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)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['level']).toMatchObject({
type: 'string',
enum: ['low', 'high'],
@@ -1004,8 +1006,8 @@ describe('schema DSL edge cases', () => {
it('omits description, enum, default keys when not specified', () => {
const spec = {
bare: { type: 'string' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
const prop = jsonSchema.properties['bare'] as Record<string, unknown>
expect(prop).toEqual({ type: 'string' })
expect('description' in prop).toBe(false)
@@ -1016,8 +1018,8 @@ describe('schema DSL edge cases', () => {
it('handles array with no items (items omitted)', () => {
const spec = {
raw: { type: 'array' },
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['raw']).toEqual({
type: 'array',
})
@@ -1027,13 +1029,14 @@ describe('schema DSL edge cases', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
},
},
} satisfies SchemaSpec
const jsonSchema = schemaSpecToJsonSchema(spec)
} satisfies ParameterSchemaSpec
const jsonSchema = parameterSchemaSpecToJsonSchema(spec)
expect(jsonSchema.properties['config']).toMatchObject({
type: 'object',
properties: {
@@ -1064,6 +1067,7 @@ describe('schema DSL optional and nested contracts', () => {
type: 'array'
items: {
type: 'object'
additionalProperties: true
properties: {
host: { type: 'string'; required: true }
port: { type: 'number' }
@@ -1073,7 +1077,7 @@ describe('schema DSL optional and nested contracts', () => {
}>
expectTypeOf<Args>().toEqualTypeOf<{
names: string[]
servers?: { host: string; port?: number }[]
servers?: ({ host: string; port?: number } & Record<string, JsonValue>)[]
}>()
})
@@ -1083,20 +1087,22 @@ describe('schema DSL optional and nested contracts', () => {
type: 'array',
items: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string', required: true },
port: { type: 'number' },
},
},
},
} satisfies SchemaSpec
expect(schemaSpecToJsonSchema(spec)).toEqual({
} satisfies ParameterSchemaSpec
expect(parameterSchemaSpecToJsonSchema(spec)).toEqual({
type: 'object',
properties: {
servers: {
type: 'array',
items: {
type: 'object',
additionalProperties: true,
properties: {
host: { type: 'string' },
port: { type: 'number' },
@@ -1178,7 +1184,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
path: { type: 'string', required: true },
limit: { type: 'number' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { path: '/tmp' })).toEqual([])
expect(validateArgs(spec, { path: '/tmp', limit: 5 })).toEqual([])
// never throws regardless of shape
@@ -1188,18 +1194,18 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
})
it('flags a missing required key and a required key present as undefined', () => {
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, {})).toEqual(['missing required property "path"'])
expect(validateArgs(spec, { path: undefined })).toEqual(['missing required property "path"'])
})
it('allows extra keys (no additionalProperties:false) and omitted optionals', () => {
const spec = { path: { type: 'string', required: true } } satisfies SchemaSpec
const spec = { path: { type: 'string', required: true } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { path: '/tmp', extra: 1 })).toEqual([])
})
it('does not apply defaults (validation only)', () => {
const spec = { limit: { type: 'number', default: 25 } } satisfies SchemaSpec
const spec = { limit: { type: 'number', default: 25 } } satisfies ParameterSchemaSpec
// absent optional is valid, and validation does not synthesize the default
expect(validateArgs(spec, {})).toEqual([])
})
@@ -1209,39 +1215,41 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
s: { type: 'string' },
n: { type: 'number' },
b: { type: 'boolean' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { s: 1 })).toEqual(['"s" must be a string'])
expect(validateArgs(spec, { n: 'x' })).toEqual(['"n" must be a number'])
expect(validateArgs(spec, { b: 'x' })).toEqual(['"b" must be a boolean'])
})
it('checks enum membership', () => {
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies SchemaSpec
const spec = { color: { type: 'string', enum: ['red', 'green'] } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { color: 'red' })).toEqual([])
expect(validateArgs(spec, { color: 'blue' })).toEqual(['"color" must be one of ["red","green"]'])
})
it('checks enum uniformly with the converter (enum on a non-string prop)', () => {
// The converter emits `enum` regardless of type; the validator must agree.
// `enum` is string[], so a number value can never be a member.
const spec = { n: { type: 'number', enum: ['1', '2'] } } as unknown as SchemaSpec
expect(validateArgs(spec, { n: 1 })).toEqual(['"n" must be one of ["1","2"]'])
it('enforces type-correct scalar enum declarations', () => {
const spec = { n: { type: 'number', enum: [1, 2] } } satisfies ParameterSchemaSpec
expect(validateArgs(spec, { n: 1 })).toEqual([])
expect(validateArgs(spec, { n: 3 })).toEqual(['"n" must be one of [1,2]'])
const invalid = { n: { type: 'number', enum: ['1', '2'] } } as unknown as ParameterSchemaSpec
expect(() => validateArgs(invalid, { n: 1 })).toThrow(JsonSchemaError)
})
it('rejects an unknown SchemaType at runtime (assertNever guard)', () => {
const spec = { x: { type: 'weird' } } as unknown as SchemaSpec
expect(() => validateArgs(spec, { x: 1 })).toThrow(/unreachable variant.*validateArgs/)
it('rejects an unknown schema type at the author boundary', () => {
const spec = { x: { type: 'weird' } } as unknown as ParameterSchemaSpec
expect(() => validateArgs(spec, { x: 1 })).toThrow(JsonSchemaError)
})
it('recurses into nested objects (and an object without properties only type-checks)', () => {
const spec = {
config: {
type: 'object',
additionalProperties: true,
required: true,
properties: { host: { type: 'string', required: true }, port: { type: 'number' } },
},
bag: { type: 'object' },
} satisfies SchemaSpec
bag: { type: 'object', additionalProperties: true },
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { config: { host: 'h' }, bag: { anything: true } })).toEqual([])
expect(validateArgs(spec, { config: { port: 9 }, bag: 5 })).toEqual([
'missing required property "config.host"',
@@ -1253,7 +1261,7 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
tags: { type: 'array', items: { type: 'string' } },
raw: { type: 'array' },
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { tags: ['a', 'b'], raw: [1, {}, 'x'] })).toEqual([])
expect(validateArgs(spec, { tags: ['a', 2] })).toEqual(['"tags[1]" must be a string'])
// a non-array value for an array-typed prop
@@ -1264,9 +1272,9 @@ describe('validateArgs (the runtime-validation Agent Note, part 1)', () => {
const spec = {
servers: {
type: 'array',
items: { type: 'object', properties: { host: { type: 'string', required: true } } },
items: { type: 'object', additionalProperties: true, properties: { host: { type: 'string', required: true } } },
},
} satisfies SchemaSpec
} satisfies ParameterSchemaSpec
expect(validateArgs(spec, { servers: [{ host: 'a' }, {}] })).toEqual([
'missing required property "servers[1].host"',
])

View File

@@ -1,20 +1,37 @@
import { describe, expect, it } from 'vitest'
import { jsonSchemaToTs, renderToolsSdk } from '@deepseek-ai/dsh-tools/src/ts-types.ts'
import { schemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import { parameterSchemaSpecToJsonSchema } from '@deepseek-ai/dsh-tools'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
describe('jsonSchemaToTs', () => {
it('maps the defineTool DSL subset', () => {
it('maps every unified schema construct', () => {
const cases: [unknown, string][] = [
[{ type: 'string' }, 'string'],
[{ type: 'number' }, 'number'],
[{ type: 'integer' }, 'number'],
[{ type: 'boolean' }, 'boolean'],
[{ type: 'null' }, 'null'],
[{ type: 'string', enum: ['a', 'b'] }, '"a" | "b"'],
[{ type: 'number', enum: [1, 2] }, '1 | 2'],
[{ type: 'integer', const: 2 }, '2'],
[{ type: 'boolean', const: true }, 'true'],
[{ type: 'null', const: null }, 'null'],
[{ type: 'string', enum: ['a', 'b'], const: 'a' }, '"a"'],
[{ oneOf: [{ type: 'string' }, { type: 'null' }] }, 'string | null'],
[{ type: 'array', items: { type: 'number' } }, 'number[]'],
[{ type: 'array', items: { type: 'string', enum: ['x', 'y'] } }, '("x" | "y")[]'],
[{ type: 'array' }, 'unknown[]'],
[{ type: 'object' }, 'Record<string, unknown>'],
[{ type: 'object', properties: {} }, 'Record<string, unknown>'],
[{ type: 'array' }, 'JsonValue[]'],
[{ type: 'object' }, 'Record<string, JsonValue>'],
[{ type: 'object', additionalProperties: false }, 'Record<string, never>'],
[{ type: 'object', properties: {} }, 'Record<string, JsonValue>'],
[{ type: 'object', properties: {}, additionalProperties: false }, 'Record<string, never>'],
[{
type: 'object',
additionalProperties: false,
properties: { id: { type: 'integer' }, label: { type: 'string' } },
required: ['id'],
}, ['{', ' id: number;', ' label?: string;', '}'].join('\n')],
[{}, 'JsonValue'],
]
for (const [schema, expected] of cases) {
expect(jsonSchemaToTs(schema), JSON.stringify(schema)).toBe(expected)
@@ -22,11 +39,12 @@ describe('jsonSchemaToTs', () => {
})
it('renders objects with required/optional keys, nested shapes, and per-property docs', () => {
const schema = schemaSpecToJsonSchema({
const schema = parameterSchemaSpecToJsonSchema({
path: { type: 'string', required: true, description: 'Absolute file path' },
limit: { type: 'number' },
opts: {
type: 'object',
additionalProperties: true,
properties: { deep: { type: 'boolean', required: true } },
},
})
@@ -37,8 +55,8 @@ describe('jsonSchemaToTs', () => {
' limit?: number;',
' opts?: {',
' deep: boolean;',
' };',
'}',
' } & Record<string, JsonValue>;',
'} & Record<string, JsonValue>',
].join('\n'))
})
@@ -48,9 +66,6 @@ describe('jsonSchemaToTs', () => {
null,
42,
'string-schema',
{},
{ type: 'integer' },
{ type: 'null' },
{ oneOf: [{ type: 'string' }] },
{ $ref: '#/defs/x' },
{ type: 'object', properties: 7 },
@@ -61,18 +76,13 @@ describe('jsonSchemaToTs', () => {
for (const schema of cases) {
expect(() => jsonSchemaToTs(schema), JSON.stringify(schema)).not.toThrow()
}
expect(jsonSchemaToTs({ type: 'integer' })).toBe('unknown')
expect(jsonSchemaToTs({ oneOf: [] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('Record<string, unknown>')
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toContain('bad: unknown;')
// A non-string-only enum degrades to plain string; an empty one too.
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('string')
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('string')
// A hostile required list only accepts string members.
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toContain('a?: string;')
// A property VALUE that is not an object degrades to unknown (and can
// carry no description).
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toContain('weird?: unknown;')
expect(jsonSchemaToTs({ type: 'object', properties: 7 })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { bad: { $ref: 'x' } }, required: ['bad'] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'string', enum: [1, 2] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'string', enum: [] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { a: { type: 'string' } }, required: [7] })).toBe('unknown')
expect(jsonSchemaToTs({ type: 'object', properties: { weird: 42 } })).toBe('unknown')
})
it('escapes a comment-closer inside a description so the generated JSDoc cannot end early', () => {
@@ -89,17 +99,18 @@ describe('renderToolsSdk', () => {
const bash: ToolSchema = {
name: 'bash',
description: 'Run a shell command.',
parameters: schemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
parameters: parameterSchemaSpecToJsonSchema({ command: { type: 'string', required: true } }) as unknown as Record<string, unknown>,
}
const exotic: ToolSchema = {
name: 'my-mcp.tool',
description: 'Exotic name.',
parameters: schemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record<string, unknown>,
}
it('declares every tool in lexicographic order with quoted keys for exotic names', () => {
const text = renderToolsSdk([exotic, bash])
expect(text).toContain('declare const tools: {')
expect(text).toContain('type JsonValue = null | boolean | number | string')
expect(text.indexOf('bash(args:')).toBeGreaterThan(0)
expect(text).toContain('"my-mcp.tool"(args:')
expect(text.indexOf('bash(args:')).toBeLessThan(text.indexOf('"my-mcp.tool"(args:'))

View File

@@ -14,7 +14,7 @@ import type { Context } from 'cordis'
import type { ContinuationStop } from '@deepseek-ai/dsh-agent'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import type { ToolExecution } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateStructuredValue, type StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
/** The model-facing tool name a structured child must call to finish. */
export const STRUCTURED_OUTPUT_TOOL = 'structured_output'
@@ -44,10 +44,10 @@ export interface StructuredAttachment {
* its creation window. Child disposal removes every registration.
* @param childCtx - the child agent's scope context (`setup`'s argument).
* @param schema - the trusted, already-asserted schema subset to enforce (see
* `assertSupportedOutputSchema` in dsh-tools).
* `assertObjectJsonSchema` in dsh-tools).
* @returns the attachment handle (read `captured()` after the child settles).
*/
export function attachStructuredRuntime(childCtx: Context, schema: StructuredOutputSchema): StructuredAttachment {
export function attachStructuredRuntime(childCtx: Context, schema: ObjectJsonSchema): StructuredAttachment {
/**
* Validated values staged by the capture tool body, awaiting THEIR OWN
* authoritative `tools/result` notification. The execution object's identity
@@ -75,7 +75,7 @@ export function attachStructuredRuntime(childCtx: Context, schema: StructuredOut
childCtx.tools.register({
...schemaEntry,
execute(args: unknown, exec: ToolExecution): Promise<ContentBlock[]> {
const violations = validateStructuredValue(schema, args)
const violations = validateJsonSchemaValue(schema, args)
// ToolArgsError → isError result with INVALID_ARGS: the model retries
// within the same turn, exactly like a schema-validated defineTool call.
if (violations.length > 0) throw new ToolArgsError(violations)

View File

@@ -7,7 +7,7 @@ import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit'
import * as Invariants from '@deepseek-ai/dsh-invariants'
import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent'
import type { Config as ToolConfig, StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { Config as ToolConfig, ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools'
import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts'
import { startInProcessRun } from '../src/index.ts'
@@ -27,7 +27,7 @@ interface SetupOptions {
codeRun?: (request: CodeRunRequestLike) => Promise<{ logs: never[]; value?: unknown }>
}
const SCHEMA: StructuredOutputSchema = {
const SCHEMA: ObjectJsonSchema = {
type: 'object',
properties: { answer: { type: 'number' }, note: { type: 'string' } },
required: ['answer'],
@@ -320,17 +320,17 @@ describe('in-process structured output', () => {
it('rejects a schema outside the subset loud, before any child exists', async () => {
const { ctx, parent } = await setup([])
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', oneOf: [] } as unknown as StructuredOutputSchema,
}))).rejects.toThrow(/unsupported output schema/)
outputSchema: { type: 'object', oneOf: [] } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema/)
expect(ctx.agents.get(SessionId('parent'))).toBeDefined()
})
it('a schema carrying non-JSON values fails as OutputSchemaError at the validation boundary', async () => {
it('a schema carrying non-JSON values fails as JsonSchemaError at the validation boundary', async () => {
const { ctx, parent } = await setup([])
// Semantic assertion runs before provider startup.
await expect(ctx.subagents.start('spawn', structuredRequest(parent, {
outputSchema: { type: 'object', default: () => {} } as unknown as StructuredOutputSchema,
}))).rejects.toThrow(/unsupported output schema.*annotation must be JSON data/)
outputSchema: { type: 'object', default: () => {} } as unknown as ObjectJsonSchema,
}))).rejects.toThrow(/unsupported JSON schema.*annotation must be lossless JSON data/)
})
it('a post-execute BLOCK on the capture call denies the capture: log and result agree on failure', async () => {
@@ -547,7 +547,7 @@ describe('in-process structured output', () => {
})
it('two concurrent structured children each see their OWN schema', async () => {
const otherSchema: StructuredOutputSchema = {
const otherSchema: ObjectJsonSchema = {
type: 'object',
properties: { verdict: { type: 'string', enum: ['real', 'bogus'] } },
required: ['verdict'],

View File

@@ -32,7 +32,7 @@ import { randomUUID } from 'node:crypto'
import { Context, Service } from 'cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import { assertSupportedOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { HarnessError } from '@deepseek-ai/dsh-llm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
@@ -242,7 +242,7 @@ export class SubagentService extends Service {
}
this.assertCapabilities(provider, request)
assertSubagentMaxDepth(request.maxDepth)
if (request.outputSchema !== undefined) assertSupportedOutputSchema(request.outputSchema)
if (request.outputSchema !== undefined) assertObjectJsonSchema(request.outputSchema)
const parent = request.parent
const run = await provider.start(request)

View File

@@ -10,7 +10,7 @@ import type { Agent, AgentOptions } from '@deepseek-ai/dsh-agent'
import type { Branded } from '@deepseek-ai/dsh-brand'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { StructuredOutputSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
import type { ObjectJsonSchema, ToolRestriction } from '@deepseek-ai/dsh-tools'
/** Identifies one accepted subagent run across its lifecycle event pair. */
export type SubagentRunId = Branded<'SubagentRunId'>
@@ -70,11 +70,11 @@ export interface SubagentStartRequest {
/** Per-child agent options (model and plugin-defined extension fields). */
readonly agentOptions?: AgentOptions
/**
* Object-rooted JSON Schema within `assertSupportedOutputSchema`'s enforced subset. Start rejects
* Object-rooted JSON Schema within `assertObjectJsonSchema`'s enforced subset. Start rejects
* unsupported schemas or providers without the capability. Data must be plain host-realm JSON;
* a successful child returns the matching value as {@link SubagentResult.structured}.
*/
readonly outputSchema?: StructuredOutputSchema
readonly outputSchema?: ObjectJsonSchema
/**
* Optional absolute delegation-depth cap for the child being started: its
* computed depth must be less than or equal to this non-negative safe

View File

@@ -41,7 +41,7 @@ export function statusLine(snapshot: TaskSnapshot): string {
: `[status: ${snapshot.status}]`
}
/** Validate the non-empty constraint that SchemaSpec cannot express. */
/** Validate the non-empty constraint that ParameterSchemaSpec cannot express. */
function validateTaskId(value: string): TaskId {
if (value.length === 0) {
throw new Error(`invalid task_id: expected a non-empty string, got ${JSON.stringify(value)}`)

View File

@@ -28,7 +28,7 @@ const DESCRIPTION =
+ '(not started), `in_progress` (being worked on now), `completed` (finished).'
/**
* Validate the value constraints the SchemaSpec can't express and build the canonical {@link
* Validate the value constraints the ParameterSchemaSpec can't express and build the canonical {@link
* TodoItem}[]: trimmed non-empty unique content and at most one in-progress item. The registry
* has already enforced the status enum; the cast below records that guarantee.
*/
@@ -67,6 +67,7 @@ export function apply(ctx: Context): void {
description: 'The COMPLETE task list, replacing any previous list.',
items: {
type: 'object',
additionalProperties: true,
properties: {
content: { type: 'string', required: true, description: 'What the task is — a short imperative line.' },
status: {

View File

@@ -27,6 +27,7 @@ export function apply(ctx: Context): void {
description: 'Questions to ask the user before continuing.',
items: {
type: 'object',
additionalProperties: true,
properties: {
id: { type: 'string', required: true, description: 'Stable id for this question; echoed in the answer.' },
question: { type: 'string', required: true, description: 'The specific question to ask the user.' },
@@ -39,6 +40,7 @@ export function apply(ctx: Context): void {
description: 'Optional choices to show the user. If you recommend one, put it first and append "(Recommended)" to that label.',
items: {
type: 'object',
additionalProperties: true,
properties: {
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },

View File

@@ -131,6 +131,7 @@ export function apply(ctx: Context, config: Config): void {
},
meta: {
type: 'object',
additionalProperties: true,
required: true,
description: 'The workflow identity block (plain JSON — never code).',
properties: {
@@ -142,6 +143,7 @@ export function apply(ctx: Context, config: Config): void {
description: 'Optional phase declarations matched by phase() calls.',
items: {
type: 'object',
additionalProperties: true,
properties: {
title: { type: 'string', required: true, description: 'The phase title phase() calls match by exact string.' },
detail: { type: 'string', description: 'Optional one-line description of the phase.' },
@@ -154,6 +156,7 @@ export function apply(ctx: Context, config: Config): void {
},
args: {
type: 'object',
additionalProperties: true,
description: 'Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {"files": [...]}).',
},
},

View File

@@ -15,8 +15,8 @@
import * as vm from 'node:vm'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { assertSupportedOutputSchema, OutputSchemaError } from '@deepseek-ai/dsh-tools'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import { assertObjectJsonSchema, JsonSchemaError } from '@deepseek-ai/dsh-tools'
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import { isFatalWorkflowError, WorkflowError } from '@deepseek-ai/dsh-workflow'
import type {
WorkflowAgentEndInfo,
@@ -350,7 +350,7 @@ export class WorkflowExecution {
phase?: string
provider?: string
model?: string
schema?: StructuredOutputSchema
schema?: ObjectJsonSchema
} {
if (rawOpts === undefined) return {}
let opts: unknown
@@ -377,14 +377,14 @@ export class WorkflowExecution {
throw new WorkflowError(`agent() option "${key}" must be a string`, 'INVALID_ARGUMENT')
}
}
let schema: StructuredOutputSchema | undefined
let schema: ObjectJsonSchema | undefined
if (record.schema !== undefined) {
try {
assertSupportedOutputSchema(record.schema)
assertObjectJsonSchema(record.schema)
schema = record.schema
} catch (error: unknown) {
/* v8 ignore next -- defensive rethrow arm: assertSupportedOutputSchema only throws OutputSchemaError */
if (!(error instanceof OutputSchemaError)) throw error
/* v8 ignore next -- defensive rethrow arm: assertObjectJsonSchema only throws JsonSchemaError */
if (!(error instanceof JsonSchemaError)) throw error
throw new WorkflowError(`agent() schema is outside the supported subset — ${error.message}`, 'UNSUPPORTED_SCHEMA', { cause: error })
}
}

View File

@@ -6,7 +6,7 @@
*/
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { StructuredOutputSchema } from '@deepseek-ai/dsh-tools'
import type { ObjectJsonSchema } from '@deepseek-ai/dsh-tools'
import type { WorkflowMeta } from '@deepseek-ai/dsh-workflow'
/**
@@ -41,7 +41,7 @@ export interface ChildStartRequest {
/** The child's prompt text. */
prompt: string
/** The structured-output schema, if the call passed one (already subset-checked). */
schema?: StructuredOutputSchema
schema?: ObjectJsonSchema
/** The per-child provider override, if the call passed one. */
provider?: string
/** The per-child model override, if the call passed one. */