feat: unify JSON value schema DSL

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

View File

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

View File

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

View File

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

View File

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