Merge refreshed canonical outputs into typed Code Mode results
# Conflicts: # docs/module-graph.md # examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md # examples/acp-agent/tests/snapshots/both-mode-turn/system-prompt.expected.md # examples/acp-agent/tests/snapshots/code-mode-turn/system-prompt.expected.md # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/system-prompt.expected.md # packages/core/tools/src/ts-types.ts
This commit is contained in:
@@ -87,7 +87,7 @@ ctx.tools.register(defineTool({
|
||||
}))
|
||||
```
|
||||
|
||||
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.
|
||||
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. Compilation, validation, registry detachment, and schema-to-TypeScript rendering use explicit work stacks, so valid deep schemas are memory-bounded rather than call-stack-bounded.
|
||||
|
||||
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. It also infers the body return and pure output projectors from `output.schema`; the registry snapshots and validates the returned lossless JSON before presentation. The implicit parameter root is open; an explicit object accepts extra keys only with `additionalProperties: true`, and a closed object with no declared properties accepts only `{}`. Raw JSON Schema objects remain open unless they explicitly set `additionalProperties: false`. Defaults are not applied; open objects without `properties` and arrays without `items` receive only a container type check. Raw-registered tools own input validation but still declare and receive registry-enforced output.
|
||||
|
||||
|
||||
@@ -873,10 +873,14 @@ export class ToolRegistry extends Service {
|
||||
/** Project one definition onto the model-facing schema fields. */
|
||||
private schemaOf(definition: ToolDefinition, detachParameters: boolean): ToolSchema {
|
||||
const { name, description, parameters } = definition
|
||||
const detached = detachParameters ? snapshotJsonValue(parameters) : parameters
|
||||
if (detached === undefined) {
|
||||
throw new Error(`tool "${name}" parameters must be lossless JSON before schema projection`)
|
||||
}
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
parameters: detachParameters ? structuredClone(parameters) : parameters,
|
||||
parameters: detached,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,18 +116,70 @@ function scalarMatches(type: JsonSchemaScalarType, value: unknown): value is Jso
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect every violation for one raw schema node. */
|
||||
function checkSchemaNode(node: unknown, path: string, violations: string[], seen: Set<object>): void {
|
||||
if (!isPlainJsonRecord(node)) {
|
||||
violations.push(`${path} must be a schema object`)
|
||||
return
|
||||
/** Deferred work for the stack-safe raw-schema walk. */
|
||||
type SchemaWalkTask =
|
||||
| { kind: 'enter'; node: unknown; path: string }
|
||||
| { kind: 'leave'; node: object }
|
||||
| { kind: 'one-of-tail'; node: Record<string, unknown>; path: string }
|
||||
| { kind: 'object-tail'; node: Record<string, unknown>; path: string; properties: unknown }
|
||||
|
||||
/** Keywords that are invalid beside `oneOf`. */
|
||||
const ONE_OF_SIBLING_KEYWORDS = ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const'] as const
|
||||
|
||||
/** Validate object-only fields after its property schemas have been visited. */
|
||||
function checkObjectSchemaTail(
|
||||
node: Record<string, unknown>,
|
||||
path: string,
|
||||
properties: unknown,
|
||||
violations: string[],
|
||||
): void {
|
||||
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 (seen.has(node)) {
|
||||
violations.push(`${path} is circular`)
|
||||
return
|
||||
if (Object.hasOwn(node, 'additionalProperties') && typeof node.additionalProperties !== 'boolean') {
|
||||
violations.push(`${path}.additionalProperties must be a boolean`)
|
||||
}
|
||||
seen.add(node)
|
||||
try {
|
||||
}
|
||||
|
||||
/** Collect every violation for one raw schema tree without using the JavaScript call stack. */
|
||||
function checkSchemaNode(root: unknown, rootPath: string, violations: string[], seen: Set<object>): void {
|
||||
const tasks: SchemaWalkTask[] = [{ kind: 'enter', node: root, path: rootPath }]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
seen.delete(task.node)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'one-of-tail') {
|
||||
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
||||
if (Object.hasOwn(task.node, key)) violations.push(`${task.path}.${key} is not supported beside oneOf`)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'object-tail') {
|
||||
checkObjectSchemaTail(task.node, task.path, task.properties, violations)
|
||||
continue
|
||||
}
|
||||
|
||||
const { node, path } = task
|
||||
if (!isPlainJsonRecord(node)) {
|
||||
violations.push(`${path} must be a schema object`)
|
||||
continue
|
||||
}
|
||||
if (seen.has(node)) {
|
||||
violations.push(`${path} is circular`)
|
||||
continue
|
||||
}
|
||||
seen.add(node)
|
||||
tasks.push({ kind: 'leave', node })
|
||||
|
||||
for (const key of Object.keys(node)) {
|
||||
if (CONSTRAINT_KEYWORDS.has(key)) continue
|
||||
if (ANNOTATION_KEYWORDS.has(key)) {
|
||||
@@ -151,28 +203,26 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
||||
const hasOneOf = Object.hasOwn(node, 'oneOf')
|
||||
if (hasType && hasOneOf) {
|
||||
violations.push(`${path} cannot declare both type and oneOf`)
|
||||
return
|
||||
continue
|
||||
}
|
||||
if (!hasType && !hasOneOf) {
|
||||
for (const key of ['properties', 'required', 'additionalProperties', 'items', 'enum', 'const']) {
|
||||
for (const key of ONE_OF_SIBLING_KEYWORDS) {
|
||||
if (Object.hasOwn(node, key)) violations.push(`${path}.${key} requires type or oneOf`)
|
||||
}
|
||||
return
|
||||
continue
|
||||
}
|
||||
|
||||
if (hasOneOf) {
|
||||
const oneOf = node.oneOf
|
||||
tasks.push({ kind: 'one-of-tail', node, path })
|
||||
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 (let index = oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({ kind: 'enter', node: oneOf[index], path: `${path}.oneOf[${index}]` })
|
||||
}
|
||||
}
|
||||
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
|
||||
continue
|
||||
}
|
||||
|
||||
const type = node.type
|
||||
@@ -180,7 +230,7 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
||||
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
|
||||
continue
|
||||
}
|
||||
const schemaType = type as JsonSchemaType
|
||||
const allowedFor: Record<string, JsonSchemaType[]> = {
|
||||
@@ -200,33 +250,24 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
||||
switch (schemaType) {
|
||||
case 'object': {
|
||||
const properties = node.properties
|
||||
tasks.push({ kind: 'object-tail', node, path, 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 entries = Object.entries(properties)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({ kind: 'enter', node: entry[1], path: `${path}.properties.${entry[0]}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
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)
|
||||
if (Object.hasOwn(node, 'items')) tasks.push({ kind: 'enter', node: node.items, path: `${path}.items` })
|
||||
break
|
||||
}
|
||||
case 'string':
|
||||
@@ -238,10 +279,8 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
||||
const enumValid = Array.isArray(allowed)
|
||||
&& allowed.length > 0
|
||||
&& allowed.every(entry => scalarMatches(schemaType, entry))
|
||||
if (Object.hasOwn(node, 'enum')) {
|
||||
if (!enumValid) {
|
||||
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
||||
}
|
||||
if (Object.hasOwn(node, 'enum') && !enumValid) {
|
||||
violations.push(`${path}.enum must be a non-empty array of ${schemaType} values`)
|
||||
}
|
||||
const constValid = scalarMatches(schemaType, node.const)
|
||||
if (Object.hasOwn(node, 'const')) {
|
||||
@@ -256,8 +295,6 @@ function checkSchemaNode(node: unknown, path: string, violations: string[], seen
|
||||
/* v8 ignore next -- schemaType was narrowed from the closed SCHEMA_TYPES table above. */
|
||||
default: assertNever(schemaType, 'JsonSchemaType')
|
||||
}
|
||||
} finally {
|
||||
seen.delete(node)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,81 +345,57 @@ function propertyPath(path: string, key: string): string {
|
||||
return path === '' ? key : `${path}.${key}`
|
||||
}
|
||||
|
||||
/** Contain hostile getters/proxies so validation remains total for arbitrary values. */
|
||||
function checkValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||
if (node.type !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(node.type)) {
|
||||
return checkValueUnchecked(node, value, path)
|
||||
}
|
||||
try {
|
||||
return checkValueUnchecked(node, value, path)
|
||||
} catch {
|
||||
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
|
||||
/** One child evaluation deferred by a container or exact-one union frame. */
|
||||
interface ValueChild {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly value: unknown
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** Explicit call frame for stack-safe schema-value validation. */
|
||||
interface ValueFrame {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly value: unknown
|
||||
readonly path: string
|
||||
catches: boolean
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'object' | 'array'
|
||||
children: ValueChild[]
|
||||
childIndex: number
|
||||
violations: string[]
|
||||
tailViolations: string[]
|
||||
matches: number
|
||||
}
|
||||
|
||||
/** The generic exception-containment diagnostic owned by one valid schema node. */
|
||||
function losslessValueViolation(path: string): string[] {
|
||||
return [`"${diagnosticPath(path)}" must be a lossless JSON value`]
|
||||
}
|
||||
|
||||
/** Append diagnostics without spreading a potentially wide child result as call arguments. */
|
||||
function appendViolations(target: string[], source: readonly string[]): void {
|
||||
for (const violation of source) target.push(violation)
|
||||
}
|
||||
|
||||
/** Initialize one validation frame with empty aggregation state. */
|
||||
function valueFrame(node: JsonSchemaNode, value: unknown, path: string): ValueFrame {
|
||||
return {
|
||||
node,
|
||||
value,
|
||||
path,
|
||||
catches: false,
|
||||
phase: 'start',
|
||||
children: [],
|
||||
childIndex: 0,
|
||||
violations: [],
|
||||
tailViolations: [],
|
||||
matches: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/** Collect value violations for one trusted schema node after the exception boundary. */
|
||||
function checkValueUnchecked(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 (!isPlainJsonRecord(value)) return [`"${diagnosticPath(path)}" must be an object`]
|
||||
const violations: string[] = []
|
||||
const properties = node.properties ?? {}
|
||||
for (const key of node.required ?? []) {
|
||||
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], propertyPath(path, key)))
|
||||
}
|
||||
if (node.additionalProperties === false) {
|
||||
for (const key of Object.keys(value)) {
|
||||
if (!Object.hasOwn(properties, key)) violations.push(`"${propertyPath(path, key)}" is not a declared property (additionalProperties: false)`)
|
||||
}
|
||||
}
|
||||
if (violations.length > 0) return violations
|
||||
return safelyIsJsonValue(value) ? [] : [`"${diagnosticPath(path)}" must be a lossless JSON object`]
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(value)) return [`"${diagnosticPath(path)}" must be an array`]
|
||||
const items = node.items
|
||||
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 [`"${diagnosticPath(path)}" must be a string`]
|
||||
break
|
||||
}
|
||||
case '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 (!isJsonNumber(value) || !Number.isInteger(value)) return [`"${diagnosticPath(path)}" must be an integer`]
|
||||
break
|
||||
}
|
||||
case 'boolean': {
|
||||
if (typeof value !== 'boolean') return [`"${diagnosticPath(path)}" must be a boolean`]
|
||||
break
|
||||
}
|
||||
case 'null': {
|
||||
if (value !== null) return [`"${diagnosticPath(path)}" must be null`]
|
||||
break
|
||||
}
|
||||
default: return assertNever(node.type, 'JsonSchemaType')
|
||||
}
|
||||
if (node.enum !== undefined && !node.enum.includes(value)) {
|
||||
/** Validate one scalar node after its primitive type check. */
|
||||
function checkScalarValue(node: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||
if (node.enum !== undefined && !node.enum.includes(value as JsonSchemaScalar)) {
|
||||
return [`"${diagnosticPath(path)}" must be one of ${JSON.stringify(node.enum)}`]
|
||||
}
|
||||
if (Object.hasOwn(node, 'const') && value !== node.const) {
|
||||
@@ -391,6 +404,165 @@ function checkValueUnchecked(node: JsonSchemaNode, value: unknown, path: string)
|
||||
return []
|
||||
}
|
||||
|
||||
/** Validate one trusted schema/value pair with explicit frames rather than recursive calls. */
|
||||
function checkValue(schema: JsonSchemaNode, value: unknown, path: string): string[] {
|
||||
const frames: ValueFrame[] = [valueFrame(schema, value, path)]
|
||||
let rootResult: string[] | undefined
|
||||
|
||||
const receive = (result: string[]): void => {
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) {
|
||||
rootResult = result
|
||||
return
|
||||
}
|
||||
if (parent.kind === 'oneOf') {
|
||||
if (result.length === 0) parent.matches++
|
||||
} else {
|
||||
appendViolations(parent.violations, result)
|
||||
}
|
||||
}
|
||||
const finish = (result: string[]): void => {
|
||||
frames.pop()
|
||||
receive(result)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
try {
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing schema-value child frame')
|
||||
frame.childIndex++
|
||||
frames.push(valueFrame(child.node, child.value, child.path))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
finish(frame.matches === 1 ? [] : [`"${diagnosticPath(frame.path)}" must match exactly one oneOf branch (matched ${frame.matches})`])
|
||||
continue
|
||||
}
|
||||
appendViolations(frame.violations, frame.tailViolations)
|
||||
if (frame.violations.length > 0) {
|
||||
finish(frame.violations)
|
||||
} else if (frame.kind === 'object') {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a lossless JSON object`])
|
||||
} else {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : [`"${diagnosticPath(frame.path)}" must be a dense lossless JSON array`])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const nodeType = frame.node.type
|
||||
frame.catches = !(nodeType !== undefined && !(SCHEMA_TYPES as readonly unknown[]).includes(nodeType))
|
||||
const oneOf = frame.node.oneOf
|
||||
if (oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = Array.from(oneOf, branch => ({ node: branch, value: frame.value, path: frame.path }))
|
||||
frame.childIndex = 0
|
||||
frame.matches = 0
|
||||
frame.phase = 'children'
|
||||
continue
|
||||
}
|
||||
if (nodeType === undefined) {
|
||||
finish(safelyIsJsonValue(frame.value) ? [] : losslessValueViolation(frame.path))
|
||||
continue
|
||||
}
|
||||
|
||||
switch (nodeType) {
|
||||
case 'object': {
|
||||
if (!isPlainJsonRecord(frame.value)) {
|
||||
finish([`"${diagnosticPath(frame.path)}" must be an object`])
|
||||
break
|
||||
}
|
||||
const properties = frame.node.properties ?? {}
|
||||
const violations: string[] = []
|
||||
for (const key of frame.node.required ?? []) {
|
||||
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) {
|
||||
violations.push(`missing required property "${propertyPath(frame.path, key)}"`)
|
||||
}
|
||||
}
|
||||
const children: ValueChild[] = []
|
||||
for (const [key, child] of Object.entries(properties)) {
|
||||
if (!Object.hasOwn(frame.value, key) || frame.value[key] === undefined) continue
|
||||
children.push({ node: child, value: frame.value[key], path: propertyPath(frame.path, key) })
|
||||
}
|
||||
const tailViolations: string[] = []
|
||||
if (frame.node.additionalProperties === false) {
|
||||
for (const key of Object.keys(frame.value)) {
|
||||
if (!Object.hasOwn(properties, key)) {
|
||||
tailViolations.push(`"${propertyPath(frame.path, key)}" is not a declared property (additionalProperties: false)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
frame.kind = 'object'
|
||||
frame.children = children
|
||||
frame.childIndex = 0
|
||||
frame.violations = violations
|
||||
frame.tailViolations = tailViolations
|
||||
frame.phase = 'children'
|
||||
break
|
||||
}
|
||||
case 'array': {
|
||||
if (!Array.isArray(frame.value)) {
|
||||
finish([`"${diagnosticPath(frame.path)}" must be an array`])
|
||||
break
|
||||
}
|
||||
const items = frame.node.items
|
||||
const children = items === undefined
|
||||
? []
|
||||
: frame.value.flatMap((entry, index): ValueChild[] => [{ node: items, value: entry, path: `${frame.path}[${index}]` }])
|
||||
frame.kind = 'array'
|
||||
frame.children = children
|
||||
frame.childIndex = 0
|
||||
frame.violations = []
|
||||
frame.phase = 'children'
|
||||
break
|
||||
}
|
||||
case 'string':
|
||||
finish(typeof frame.value === 'string'
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be a string`])
|
||||
break
|
||||
case 'number':
|
||||
finish(typeof frame.value !== 'number'
|
||||
? [`"${diagnosticPath(frame.path)}" must be a number`]
|
||||
: !isJsonNumber(frame.value)
|
||||
? [`"${diagnosticPath(frame.path)}" must be a finite JSON number`]
|
||||
: checkScalarValue(frame.node, frame.value, frame.path))
|
||||
break
|
||||
case 'integer':
|
||||
finish(!isJsonNumber(frame.value) || !Number.isInteger(frame.value)
|
||||
? [`"${diagnosticPath(frame.path)}" must be an integer`]
|
||||
: checkScalarValue(frame.node, frame.value, frame.path))
|
||||
break
|
||||
case 'boolean':
|
||||
finish(typeof frame.value === 'boolean'
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be a boolean`])
|
||||
break
|
||||
case 'null':
|
||||
finish(frame.value === null
|
||||
? checkScalarValue(frame.node, frame.value, frame.path)
|
||||
: [`"${diagnosticPath(frame.path)}" must be null`])
|
||||
break
|
||||
default:
|
||||
finish(assertNever(nodeType, 'JsonSchemaType'))
|
||||
}
|
||||
} catch (error) {
|
||||
let failed = frames.pop()
|
||||
while (failed !== undefined && !failed.catches) failed = frames.pop()
|
||||
if (failed === undefined) throw error
|
||||
receive(losslessValueViolation(failed.path))
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next -- every root frame finishes or throws. */
|
||||
return rootResult ?? losslessValueViolation(path)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a candidate value against an asserted raw schema. The function is
|
||||
* total for arbitrary values and returns path-qualified violations.
|
||||
|
||||
@@ -186,66 +186,172 @@ function assertAuthorKeys(source: Record<string, unknown>, path: string, allowed
|
||||
}
|
||||
}
|
||||
|
||||
/** 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`)
|
||||
}
|
||||
Object.defineProperty(properties, key, {
|
||||
value: compileValueSchema(property, `${path}.${key}`, seen, true),
|
||||
/** Compiled form of one implicit property map. */
|
||||
interface CompiledPropertyMap {
|
||||
properties: Record<string, JsonSchemaNode>
|
||||
required?: string[]
|
||||
}
|
||||
|
||||
/** Mutable holder used only while an iterative compilation root is unresolved. */
|
||||
interface CompileRoot<T> {
|
||||
value?: T
|
||||
}
|
||||
|
||||
/** Where one compiled value node is installed. */
|
||||
type NodeDestination =
|
||||
| { kind: 'root'; holder: CompileRoot<JsonSchemaNode> }
|
||||
| { kind: 'property'; target: Record<string, JsonSchemaNode>; key: string }
|
||||
| { kind: 'item'; target: JsonSchemaNode }
|
||||
| { kind: 'one-of'; target: JsonSchemaNode[]; index: number }
|
||||
|
||||
/** Where one compiled property map is installed. */
|
||||
type PropertyMapDestination =
|
||||
| { kind: 'root'; holder: CompileRoot<CompiledPropertyMap> }
|
||||
| { kind: 'object'; target: JsonSchemaNode }
|
||||
|
||||
/** Deferred work for stack-safe author-schema compilation. */
|
||||
type CompileTask =
|
||||
| { kind: 'value'; input: unknown; path: string; allowRequired: boolean; destination: NodeDestination }
|
||||
| { kind: 'property-map'; input: unknown; path: string; destination: PropertyMapDestination }
|
||||
| {
|
||||
kind: 'property'
|
||||
property: unknown
|
||||
path: string
|
||||
key: string
|
||||
properties: Record<string, JsonSchemaNode>
|
||||
required: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'property-map-tail'
|
||||
compiled: CompiledPropertyMap
|
||||
required: string[]
|
||||
destination: PropertyMapDestination
|
||||
}
|
||||
| { kind: 'leave'; input: object }
|
||||
|
||||
/** Install a compiled node without giving `__proto__` assignment semantics. */
|
||||
function assignCompiledNode(destination: NodeDestination, node: JsonSchemaNode): void {
|
||||
switch (destination.kind) {
|
||||
case 'root':
|
||||
destination.holder.value = node
|
||||
break
|
||||
case 'property':
|
||||
Object.defineProperty(destination.target, destination.key, {
|
||||
value: node,
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
})
|
||||
if (property.required === true) required.push(key)
|
||||
}
|
||||
return required.length > 0 ? { properties, required } : { properties }
|
||||
} finally {
|
||||
seen.delete(input)
|
||||
break
|
||||
case 'item':
|
||||
destination.target.items = node
|
||||
break
|
||||
case 'one-of':
|
||||
destination.target[destination.index] = node
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/** 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'] : [])]
|
||||
/** Install a compiled property map at its root or containing object node. */
|
||||
function assignCompiledPropertyMap(destination: PropertyMapDestination, compiled: CompiledPropertyMap): void {
|
||||
if (destination.kind === 'root') {
|
||||
destination.holder.value = compiled
|
||||
} else {
|
||||
destination.target.properties = compiled.properties
|
||||
}
|
||||
}
|
||||
|
||||
/** Execute an author-schema compilation task graph without recursive descent. */
|
||||
function runSchemaCompiler(initial: CompileTask): void {
|
||||
const seen = new Set<object>()
|
||||
const tasks: CompileTask[] = [initial]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (task.kind === 'leave') {
|
||||
seen.delete(task.input)
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property-map-tail') {
|
||||
if (task.required.length > 0) {
|
||||
task.compiled.required = task.required
|
||||
if (task.destination.kind === 'object') task.destination.target.required = task.required
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property') {
|
||||
if (!isPlainJsonRecord(task.property)) authorError(`${task.path} must be a value schema object`)
|
||||
if (Object.hasOwn(task.property, 'required') && task.property.required !== true) {
|
||||
authorError(`${task.path}.required must be true when present`)
|
||||
}
|
||||
if (task.property.required === true) task.required.push(task.key)
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: task.property,
|
||||
path: task.path,
|
||||
allowRequired: true,
|
||||
destination: { kind: 'property', target: task.properties, key: task.key },
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (task.kind === 'property-map') {
|
||||
if (!isPlainJsonRecord(task.input)) authorError(`${task.path} must be an object of value schemas`)
|
||||
if (seen.has(task.input)) authorError(`${task.path} is circular`)
|
||||
seen.add(task.input)
|
||||
const compiled: CompiledPropertyMap = { properties: {} }
|
||||
const required: string[] = []
|
||||
assignCompiledPropertyMap(task.destination, compiled)
|
||||
tasks.push({ kind: 'leave', input: task.input })
|
||||
tasks.push({ kind: 'property-map-tail', compiled, required, destination: task.destination })
|
||||
const entries = Object.entries(task.input)
|
||||
for (let index = entries.length - 1; index >= 0; index--) {
|
||||
const entry = entries[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured entry count. */
|
||||
if (entry === undefined) continue
|
||||
tasks.push({
|
||||
kind: 'property',
|
||||
property: entry[1],
|
||||
path: `${task.path}.${entry[0]}`,
|
||||
key: entry[0],
|
||||
properties: compiled.properties,
|
||||
required,
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const { input, path } = task
|
||||
if (!isPlainJsonRecord(input)) authorError(`${path} must be a value schema object`)
|
||||
if (seen.has(input)) authorError(`${path} is circular`)
|
||||
seen.add(input)
|
||||
const authorKeys = [...ANNOTATION_KEYS, ...(task.allowRequired ? ['required'] : [])]
|
||||
const node: JsonSchemaNode = {}
|
||||
assignCompiledNode(task.destination, node)
|
||||
tasks.push({ kind: 'leave', input })
|
||||
|
||||
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))
|
||||
const branches: JsonSchemaNode[] = []
|
||||
node.oneOf = branches
|
||||
copyAnnotations(input, node)
|
||||
return node
|
||||
for (let index = input.oneOf.length - 1; index >= 0; index--) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: input.oneOf[index],
|
||||
path: `${path}.oneOf[${index}]`,
|
||||
allowRequired: false,
|
||||
destination: { kind: 'one-of', target: branches, index },
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
switch (input.type) {
|
||||
case 'json':
|
||||
assertAuthorKeys(input, path, [...authorKeys, 'type'])
|
||||
copyAnnotations(input, node)
|
||||
return node
|
||||
case 'object': {
|
||||
break
|
||||
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`)
|
||||
@@ -254,18 +360,28 @@ function compileValueSchema(
|
||||
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
|
||||
tasks.push({
|
||||
kind: 'property-map',
|
||||
input: input.properties,
|
||||
path: `${path}.properties`,
|
||||
destination: { kind: 'object', target: node },
|
||||
})
|
||||
}
|
||||
return node
|
||||
}
|
||||
break
|
||||
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
|
||||
if (Object.hasOwn(input, 'items')) {
|
||||
tasks.push({
|
||||
kind: 'value',
|
||||
input: input.items,
|
||||
path: `${path}.items`,
|
||||
allowRequired: false,
|
||||
destination: { kind: 'item', target: node },
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
@@ -280,15 +396,29 @@ function compileValueSchema(
|
||||
: input.enum as JsonSchemaScalar[]
|
||||
}
|
||||
if (Object.hasOwn(input, 'const')) node.const = input.const as JsonSchemaScalar
|
||||
return node
|
||||
break
|
||||
default:
|
||||
return authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
|
||||
authorError(`${path}.type must be string/number/integer/boolean/null/array/object/json, or use oneOf`)
|
||||
}
|
||||
} finally {
|
||||
seen.delete(input)
|
||||
}
|
||||
}
|
||||
|
||||
/** Compile one implicit property map, collecting per-property requiredness. */
|
||||
function compilePropertyMap(input: unknown, path: string): CompiledPropertyMap {
|
||||
const holder: CompileRoot<CompiledPropertyMap> = {}
|
||||
runSchemaCompiler({ kind: 'property-map', input, path, destination: { kind: 'root', holder } })
|
||||
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
|
||||
return holder.value ?? authorError(`${path} did not compile`)
|
||||
}
|
||||
|
||||
/** Compile one author node without applying any consumer root restriction. */
|
||||
function compileValueSchema(input: unknown, path: string): JsonSchemaNode {
|
||||
const holder: CompileRoot<JsonSchemaNode> = {}
|
||||
runSchemaCompiler({ kind: 'value', input, path, allowRequired: false, destination: { kind: 'root', holder } })
|
||||
/* v8 ignore next -- the root task assigns before scheduling any descendants. */
|
||||
return holder.value ?? authorError(`${path} did not compile`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compile one author-facing value schema to the enforced raw JSON Schema
|
||||
* subset. The author-only `json` node becomes an annotation-only schema.
|
||||
@@ -296,7 +426,7 @@ function compileValueSchema(
|
||||
* @returns The asserted raw schema projection.
|
||||
*/
|
||||
export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNode {
|
||||
const schema = compileValueSchema(spec, 'schema', new Set())
|
||||
const schema = compileValueSchema(spec, 'schema')
|
||||
assertSupportedJsonSchema(schema)
|
||||
return schema
|
||||
}
|
||||
@@ -307,7 +437,7 @@ export function valueSchemaSpecToJsonSchema(spec: ValueSchemaSpec): JsonSchemaNo
|
||||
* @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 compiled = compilePropertyMap(spec, 'parameters')
|
||||
const schema: ParameterJsonSchema = {
|
||||
type: 'object',
|
||||
properties: compiled.properties,
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import { assertSupportedJsonSchema } from './json-schema.ts'
|
||||
import type { JsonSchemaNode, JsonSchemaScalar } from './json-schema.ts'
|
||||
|
||||
/** Internal Code Mode projection: the model-facing schema plus the canonical output schema. */
|
||||
export interface ToolSdkSchema extends ToolSchema {
|
||||
/** Validated canonical value returned by the tool binding. */
|
||||
@@ -53,9 +52,181 @@ function renderConstrainedScalar(node: Record<string, unknown>, type: string): s
|
||||
return broad
|
||||
}
|
||||
|
||||
/** Parenthesize a union or object intersection before applying `[]`. */
|
||||
function arrayItem(type: string): string {
|
||||
return type.includes('|') || type.includes('&') ? `(${type})[]` : `${type}[]`
|
||||
/** A composable type document that can be flattened without recursive string concatenation. */
|
||||
interface TypeDocument {
|
||||
readonly parts: readonly (string | TypeDocument)[]
|
||||
readonly containsUnionOrIntersection: boolean
|
||||
}
|
||||
|
||||
/** Build one document from captured parts while retaining the legacy array-parenthesization test. */
|
||||
function typeDocumentFrom(parts: readonly (string | TypeDocument)[]): TypeDocument {
|
||||
return {
|
||||
parts,
|
||||
containsUnionOrIntersection: parts.some(part => typeof part === 'string'
|
||||
? part.includes('|') || part.includes('&')
|
||||
: part.containsUnionOrIntersection),
|
||||
}
|
||||
}
|
||||
|
||||
/** Build a small document without an intermediate array at each call site. */
|
||||
function typeDocument(...parts: (string | TypeDocument)[]): TypeDocument {
|
||||
return typeDocumentFrom(parts)
|
||||
}
|
||||
|
||||
/** Flatten a nested document with an explicit work stack. */
|
||||
function flattenTypeDocument(document: TypeDocument): string {
|
||||
const chunks: string[] = []
|
||||
const tasks: (string | TypeDocument)[] = [document]
|
||||
for (let task = tasks.pop(); task !== undefined; task = tasks.pop()) {
|
||||
if (typeof task === 'string') {
|
||||
chunks.push(task)
|
||||
continue
|
||||
}
|
||||
for (let index = task.parts.length - 1; index >= 0; index--) {
|
||||
const part = task.parts[index]
|
||||
/* v8 ignore next -- the loop is bounded by the captured part count. */
|
||||
if (part !== undefined) tasks.push(part)
|
||||
}
|
||||
}
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
/** One explicit call frame for stack-safe schema-to-TypeScript rendering. */
|
||||
interface SchemaRenderFrame {
|
||||
readonly node: JsonSchemaNode
|
||||
readonly indent: number
|
||||
phase: 'start' | 'children'
|
||||
kind?: 'oneOf' | 'array' | 'object'
|
||||
children: { node: JsonSchemaNode; indent: number }[]
|
||||
childIndex: number
|
||||
childDocuments: TypeDocument[]
|
||||
entries: [string, JsonSchemaNode][]
|
||||
}
|
||||
|
||||
/** Initialize one schema-render frame with empty aggregation state. */
|
||||
function schemaRenderFrame(node: JsonSchemaNode, indent: number): SchemaRenderFrame {
|
||||
return { node, indent, phase: 'start', children: [], childIndex: 0, childDocuments: [], entries: [] }
|
||||
}
|
||||
|
||||
/** Render an already asserted schema to a composable document. */
|
||||
function renderSupportedSchema(schema: JsonSchemaNode, indent: number): TypeDocument {
|
||||
const frames: SchemaRenderFrame[] = [schemaRenderFrame(schema, indent)]
|
||||
let rootDocument: TypeDocument | undefined
|
||||
const finish = (document: TypeDocument): void => {
|
||||
frames.pop()
|
||||
const parent = frames.at(-1)
|
||||
if (parent === undefined) rootDocument = document
|
||||
else parent.childDocuments.push(document)
|
||||
}
|
||||
|
||||
while (frames.length > 0) {
|
||||
const frame = frames.at(-1)
|
||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||
if (frame === undefined) break
|
||||
if (frame.phase === 'children') {
|
||||
if (frame.childIndex < frame.children.length) {
|
||||
const child = frame.children[frame.childIndex]
|
||||
/* v8 ignore next -- childIndex is bounded by children.length. */
|
||||
if (child === undefined) throw new Error('missing schema render child')
|
||||
frame.childIndex++
|
||||
frames.push(schemaRenderFrame(child.node, child.indent))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'oneOf') {
|
||||
const parts: (string | TypeDocument)[] = []
|
||||
for (let index = 0; index < frame.childDocuments.length; index++) {
|
||||
if (index > 0) parts.push(' | ')
|
||||
const child = frame.childDocuments[index]
|
||||
/* v8 ignore next -- child documents correspond one-to-one with children. */
|
||||
if (child !== undefined) parts.push(child)
|
||||
}
|
||||
finish(typeDocumentFrom(parts))
|
||||
continue
|
||||
}
|
||||
if (frame.kind === 'array') {
|
||||
const child = frame.childDocuments[0]
|
||||
/* v8 ignore next -- array frames always schedule exactly one child. */
|
||||
if (child === undefined) throw new Error('missing array item type')
|
||||
finish(child.containsUnionOrIntersection
|
||||
? typeDocument('(', child, ')[]')
|
||||
: typeDocument(child, '[]'))
|
||||
continue
|
||||
}
|
||||
|
||||
const required = new Set(frame.node.required)
|
||||
const parts: (string | TypeDocument)[] = ['{']
|
||||
for (let index = 0; index < frame.entries.length; index++) {
|
||||
const entry = frame.entries[index]
|
||||
const child = frame.childDocuments[index]
|
||||
/* v8 ignore next -- object entries and child documents have the same length. */
|
||||
if (entry === undefined || child === undefined) throw new Error('missing object property type')
|
||||
const [name, prop] = entry
|
||||
for (const line of docLines(prop.description, frame.indent + 1)) parts.push('\n', line)
|
||||
parts.push('\n', `${pad(frame.indent + 1)}${renderKey(name)}${required.has(name) ? '' : '?'}: `, child, ';')
|
||||
}
|
||||
parts.push('\n', `${pad(frame.indent)}}`)
|
||||
const declared = typeDocumentFrom(parts)
|
||||
finish(frame.node.additionalProperties === false
|
||||
? declared
|
||||
: typeDocument(declared, ' & Record<string, JsonValue>'))
|
||||
continue
|
||||
}
|
||||
|
||||
const node = frame.node
|
||||
if (node.oneOf !== undefined) {
|
||||
frame.kind = 'oneOf'
|
||||
frame.children = Array.from(node.oneOf, child => ({ node: child, indent: frame.indent }))
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
continue
|
||||
}
|
||||
if (node.type === undefined) {
|
||||
finish(typeDocument('JsonValue'))
|
||||
continue
|
||||
}
|
||||
switch (node.type) {
|
||||
case 'string':
|
||||
case 'number':
|
||||
case 'integer':
|
||||
case 'boolean':
|
||||
case 'null':
|
||||
finish(typeDocument(renderConstrainedScalar(node as Record<string, unknown>, node.type)))
|
||||
break
|
||||
case 'array':
|
||||
if (node.items === undefined) {
|
||||
finish(typeDocument('JsonValue[]'))
|
||||
} else {
|
||||
frame.kind = 'array'
|
||||
frame.children = [{ node: node.items, indent: frame.indent }]
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
}
|
||||
break
|
||||
case 'object': {
|
||||
const open = node.additionalProperties !== false
|
||||
const entries = Object.entries(node.properties ?? {})
|
||||
if (entries.length === 0) {
|
||||
finish(typeDocument(open ? 'Record<string, JsonValue>' : 'Record<string, never>'))
|
||||
} else {
|
||||
frame.kind = 'object'
|
||||
frame.entries = entries
|
||||
frame.children = entries.map(([, child]) => ({ node: child, indent: frame.indent + 1 }))
|
||||
frame.childIndex = 0
|
||||
frame.childDocuments = []
|
||||
frame.phase = 'children'
|
||||
}
|
||||
break
|
||||
}
|
||||
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||
default:
|
||||
finish(typeDocument('unknown'))
|
||||
}
|
||||
}
|
||||
|
||||
/* v8 ignore next -- every root frame produces one document. */
|
||||
return rootDocument ?? typeDocument('unknown')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,43 +240,10 @@ function arrayItem(type: string): string {
|
||||
export function jsonSchemaToTs(schema: unknown, indent = 0): string {
|
||||
try {
|
||||
assertSupportedJsonSchema(schema)
|
||||
return flattenTypeDocument(renderSupportedSchema(schema, indent))
|
||||
} 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': 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': {
|
||||
return arrayItem(Object.hasOwn(node, 'items') ? jsonSchemaToTs(node.items, indent) : 'JsonValue')
|
||||
}
|
||||
case 'object': {
|
||||
const properties = node.properties
|
||||
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 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 = (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)}}`)
|
||||
const declared = lines.join('\n')
|
||||
return open ? `${declared} & Record<string, JsonValue>` : declared
|
||||
}
|
||||
/* v8 ignore next -- assertSupportedJsonSchema narrowed this closed type union. */
|
||||
default: return 'unknown'
|
||||
}
|
||||
}
|
||||
|
||||
/** The fixed model-facing usage contract rendered above the declarations (see the Code Mode Agent Note's "What the model sees"). */
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => {
|
||||
it('boots every shipped tool package and harvests its model-facing schemas', async () => {
|
||||
const catalog = await collectToolCatalog()
|
||||
const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort()
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
expect(names).toEqual(['ask_user_question', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'lsp', 'ralph', 'read', 'run_code', 'skill', 'subagent', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write'])
|
||||
// Every tool carries a JSON-Schema `parameters` object (what the model sees).
|
||||
for (const entry of catalog) {
|
||||
for (const schema of entry.schemas) {
|
||||
|
||||
@@ -214,6 +214,14 @@ describe('the enforced raw JSON Schema subset', () => {
|
||||
.toEqual(['schema.properties.at must be a schema object'])
|
||||
})
|
||||
|
||||
it('asserts deeply nested raw unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||
})
|
||||
|
||||
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'])
|
||||
@@ -322,6 +330,17 @@ describe('validateJsonSchemaValue', () => {
|
||||
expect(validateJsonSchemaValue(overlap, 1.5)).toEqual([])
|
||||
})
|
||||
|
||||
it('validates deeply nested exact-one unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
assertSupportedJsonSchema(schema)
|
||||
|
||||
expect(validateJsonSchemaValue(schema, 'leaf')).toEqual([])
|
||||
expect(validateJsonSchemaValue(schema, 42))
|
||||
.toEqual(['"value" must match exactly one oneOf branch (matched 0)'])
|
||||
})
|
||||
|
||||
it('an unconstrained schema accepts only lossless JSON values', () => {
|
||||
const anyJson = asserted({})
|
||||
for (const value of [null, true, 1, 'x', [1], { x: null }]) {
|
||||
|
||||
@@ -92,6 +92,23 @@ describe('the unified author schema DSL', () => {
|
||||
expect(() => parameterSchemaSpecToJsonSchema(properties as ParameterSchemaSpec)).toThrow(/circular/)
|
||||
})
|
||||
|
||||
it('compiles deeply nested author unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let spec: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) spec = { oneOf: [spec, { type: 'null' }] }
|
||||
|
||||
const compiled = valueSchemaSpecToJsonSchema(spec as ValueSchemaSpec)
|
||||
|
||||
let cursor = compiled
|
||||
let layers = 0
|
||||
while (cursor.oneOf !== undefined) {
|
||||
cursor = cursor.oneOf[0]!
|
||||
layers++
|
||||
}
|
||||
expect(layers).toBe(depth)
|
||||
expect(cursor).toEqual({ type: 'string' })
|
||||
})
|
||||
|
||||
it('preserves a property literally named __proto__ as schema data', () => {
|
||||
const properties = Object.create(null) as ParameterSchemaSpec
|
||||
properties.__proto__ = { type: 'string', required: true }
|
||||
|
||||
@@ -8,7 +8,7 @@ import ToolRegistry, {
|
||||
defineContentToolFixture, defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
|
||||
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
|
||||
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
|
||||
type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken,
|
||||
type JsonSchemaNode, type ToolDefinition, type ToolDispatchExecution, type ToolExecutionResult, type ToolExecutionToken,
|
||||
} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
@@ -1687,6 +1687,41 @@ describe('ToolRegistry', () => {
|
||||
}])
|
||||
})
|
||||
|
||||
it('schemas() snapshots deeply nested parameters without using structured-clone recursion', async () => {
|
||||
const ctx = await setup()
|
||||
const depth = 5_000
|
||||
let nested: JsonSchemaNode = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) nested = { oneOf: [nested, { type: 'null' }] }
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'deep-schema',
|
||||
parameters: { type: 'object', properties: { nested } },
|
||||
})
|
||||
|
||||
const projected = ctx.tools.schemas()[0]!.parameters as JsonSchemaNode
|
||||
|
||||
let cursor = projected.properties!.nested!
|
||||
let layers = 0
|
||||
while (cursor.oneOf !== undefined) {
|
||||
cursor = cursor.oneOf[0]!
|
||||
layers++
|
||||
}
|
||||
expect(layers).toBe(depth)
|
||||
expect(cursor).toEqual({ type: 'string' })
|
||||
})
|
||||
|
||||
it('rejects schema projection when a raw registration is not lossless JSON', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.tools.register({
|
||||
...echoTool,
|
||||
name: 'lossy-schema',
|
||||
parameters: { type: 'object', default: Number.NaN },
|
||||
})
|
||||
|
||||
expect(() => ctx.tools.schemas())
|
||||
.toThrow('tool "lossy-schema" parameters must be lossless JSON before schema projection')
|
||||
})
|
||||
|
||||
it('rejects a non-positive or non-finite registration timeout', async () => {
|
||||
const ctx = await setup()
|
||||
expect(() => ctx.tools.register({ ...echoTool, name: 'zero-timeout', timeoutMs: 0 }))
|
||||
|
||||
@@ -93,6 +93,17 @@ describe('jsonSchemaToTs', () => {
|
||||
expect(rendered).not.toContain('tool-*/ over')
|
||||
expect(rendered).toContain(String.raw`tool-*\/ over`)
|
||||
})
|
||||
|
||||
it('renders deeply nested unions without using the JavaScript call stack', () => {
|
||||
const depth = 5_000
|
||||
let schema: unknown = { type: 'string' }
|
||||
for (let index = 0; index < depth; index++) schema = { oneOf: [schema, { type: 'null' }] }
|
||||
|
||||
const rendered = jsonSchemaToTs(schema)
|
||||
|
||||
expect(rendered.startsWith('string | null')).toBe(true)
|
||||
expect(rendered.length).toBe('string'.length + depth * ' | null'.length)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderToolsSdk', () => {
|
||||
|
||||
Reference in New Issue
Block a user