fix(tools): make schema traversal stack-safe

This commit is contained in:
Tianyi Cui
2026-07-22 20:51:13 +08:00
parent c05b684d52
commit 34194f0cd5
15 changed files with 1143 additions and 344 deletions

View File

@@ -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 }]) {

View File

@@ -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 }

View File

@@ -8,7 +8,7 @@ import ToolRegistry, {
defineTool, JsonSchemaError, parameterSchemaSpecToJsonSchema, validateArgs, ToolArgsError, ToolNotFoundError,
TOOL_ABORTED, TOOL_ABORTED_BEFORE_DISPATCH,
type InferArgs, type JsonValue, type ParameterSchemaSpec, type PreToolDecision, type PostToolDecision,
type ToolDispatchExecution, type ToolExecutionResult,
type JsonSchemaNode, type ToolDispatchExecution, type ToolExecutionResult,
} from '@deepseek-ai/dsh-tools'
const testToolSignal = new AbortController().signal
@@ -1289,6 +1289,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 }))

View File

@@ -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', () => {