feat: unify JSON value schema DSL
This commit is contained in:
@@ -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 }] },
|
||||
|
||||
Reference in New Issue
Block a user