feat: unify JSON value schema DSL
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/],
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '
|
||||
|
||||
@@ -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 }] },
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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>)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\`\`\``
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
})
|
||||
|
||||
|
||||
138
packages/core/tools/tests/schema.spec.ts
Normal file
138
packages/core/tools/tests/schema.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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"',
|
||||
])
|
||||
|
||||
@@ -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:'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`)
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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.' },
|
||||
|
||||
@@ -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": [...]}).',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user