Merge branch 'codex/code-mode-typed-results' into codex/code-mode-complete-result-card
This commit is contained in:
@@ -2,5 +2,5 @@
|
|||||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||||
# after editing either side, bring the other along and re-record with:
|
# after editing either side, bring the other along and re-record with:
|
||||||
# pnpm run verify-translation-pairing --write
|
# pnpm run verify-translation-pairing --write
|
||||||
2026-07-20-unified-json-value-schema-dsl.md: ab7bb268407ac26230283172ebb291de80412bf2
|
2026-07-20-unified-json-value-schema-dsl.md: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea
|
||||||
2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5
|
2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Tool parameters used a small author DSL while subagent/workflow structured outpu
|
|||||||
|
|
||||||
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
|
`dsh-tools` owns one JSON-value schema vocabulary with two representations. `ValueSchemaSpec` is the author form for any JSON root; `ParameterSchemaSpec` is its implicit object-property-map form with per-property `required: true`. `JsonSchemaNode` is the raw wire form. Both support string, finite number, integer, boolean, null, array, object, type-correct scalar `enum`/`const`, and exact-one `oneOf`; `{ type: 'json' }` is author-only sugar for an annotation-only unconstrained raw node.
|
||||||
|
|
||||||
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values.
|
An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue<S>` and `InferArgs<P>` derive TypeScript values from the same declarations that `valueSchemaSpecToJsonSchema()` and `parameterSchemaSpecToJsonSchema()` compile. `assertSupportedJsonSchema()` rejects unsupported or misplaced keywords, and `validateJsonSchemaValue()` enforces the accepted subset against the lossless `JsonValue` boundary: no `undefined`, negative zero, non-finite numbers, sparse arrays, cycles, exotic objects, functions, symbols, or other coercive values. Intrinsic plain Object and Array containers remain plain across JavaScript realms; subclasses remain exotic.
|
||||||
|
|
||||||
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
|
Object-rooting is a consumer rule rather than a vocabulary restriction. Subagent and workflow caller-defined structured outputs use `assertObjectJsonSchema()` and `ObjectJsonSchema`; tool outputs may use any root. Dynamic Cordis registrations rebuild realm-foreign schemas into host-owned JSON, preserve raw-wrapper openness, and require direct-DSL object openness before calling the same compiler.
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Status: implemented
|
|||||||
|
|
||||||
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
|
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
|
||||||
|
|
||||||
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue<S>` 和 `InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。
|
显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue<S>` 和 `InferArgs<P>` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。
|
||||||
|
|
||||||
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
|
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
|
|||||||
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
import type { CodeBindingFunction, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
|
||||||
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
import { snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
||||||
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts'
|
||||||
import { truncateJsonStringBytes } from './output-json.ts'
|
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from './output-json.ts'
|
||||||
|
|
||||||
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
/** Plugin config: every execution cap, changeable from `cordis.yml` (no hardcoded tunables). */
|
||||||
export interface Config {
|
export interface Config {
|
||||||
@@ -141,11 +141,6 @@ function parseWorkerMessage(raw: unknown): WorkerToHost | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Serialized byte size of one lossless JSON value. */
|
|
||||||
function jsonBytes(value: CodeJsonValue): number {
|
|
||||||
return Buffer.byteLength(JSON.stringify(value), 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One run's combined outer-output ledger; binding values never enter it. */
|
/** One run's combined outer-output ledger; binding values never enter it. */
|
||||||
class OutputLedger {
|
class OutputLedger {
|
||||||
private bytes = 2 // JSON serialization of the empty logs array: []
|
private bytes = 2 // JSON serialization of the empty logs array: []
|
||||||
@@ -155,9 +150,10 @@ class OutputLedger {
|
|||||||
|
|
||||||
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
/** Admit one exact log entry, or report that the hard cap was crossed. */
|
||||||
admit(text: string, sink: string[]): boolean {
|
admit(text: string, sink: string[]): boolean {
|
||||||
const cost = Buffer.byteLength(JSON.stringify(text), 'utf8') + (this.entries > 0 ? 1 : 0)
|
const separatorBytes = this.entries > 0 ? 1 : 0
|
||||||
if (this.bytes + cost > this.maxBytes) return false
|
const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
|
||||||
this.bytes += cost
|
if (stringBytes === undefined) return false
|
||||||
|
this.bytes += stringBytes + separatorBytes
|
||||||
this.entries += 1
|
this.entries += 1
|
||||||
sink.push(text)
|
sink.push(text)
|
||||||
return true
|
return true
|
||||||
@@ -165,46 +161,45 @@ class OutputLedger {
|
|||||||
|
|
||||||
/** Finalize a successful absent-or-JSON completion against the combined cap. */
|
/** Finalize a successful absent-or-JSON completion against the combined cap. */
|
||||||
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
success(logs: string[], value?: CodeJsonValue): CodeRunResult {
|
||||||
if (value !== undefined && this.bytes + jsonBytes(value) > this.maxBytes) return this.limit(logs)
|
if (value !== undefined && jsonValueBytesUpTo(value, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||||
return { logs, ...value !== undefined ? { value } : {} }
|
return { logs, ...value !== undefined ? { value } : {} }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
|
||||||
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
failure(logs: string[], error: CodeRunFailure): CodeRunResult {
|
||||||
if (this.bytes + Buffer.byteLength(JSON.stringify(error.message), 'utf8') > this.maxBytes) return this.limit(logs)
|
if (jsonStringBytesUpTo(error.message, this.maxBytes - this.bytes) === undefined) return this.limit(logs)
|
||||||
return { logs, error }
|
return { logs, error }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
|
||||||
limit(logs: string[]): CodeRunResult {
|
limit(logs: string[]): CodeRunResult {
|
||||||
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
|
||||||
const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8')
|
// The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
|
||||||
const retained = [...logs]
|
const messageBytes = fullMessage.length + 2
|
||||||
let retainedBytes = jsonBytes(retained)
|
const retained: string[] = []
|
||||||
|
let retainedBytes = 2
|
||||||
const logBudget = this.maxBytes - messageBytes
|
const logBudget = this.maxBytes - messageBytes
|
||||||
while (retained.length > 0 && retainedBytes > logBudget) {
|
for (const text of logs) {
|
||||||
const removed = retained.pop()
|
|
||||||
/* v8 ignore next -- the while guard proves pop cannot return undefined. */
|
|
||||||
if (removed === undefined) throw new Error('output ledger lost its final log entry')
|
|
||||||
const separatorBytes = retained.length > 0 ? 1 : 0
|
const separatorBytes = retained.length > 0 ? 1 : 0
|
||||||
retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + separatorBytes
|
const availableBytes = logBudget - retainedBytes - separatorBytes
|
||||||
const prefix = truncateJsonStringBytes(removed, logBudget - retainedBytes - separatorBytes)
|
const stringBytes = jsonStringBytesUpTo(text, availableBytes)
|
||||||
if (prefix.length > 0) {
|
if (stringBytes !== undefined) {
|
||||||
retained.push(prefix)
|
retained.push(text)
|
||||||
retainedBytes += Buffer.byteLength(JSON.stringify(prefix), 'utf8') + separatorBytes
|
retainedBytes += stringBytes + separatorBytes
|
||||||
break
|
continue
|
||||||
}
|
}
|
||||||
}
|
const prefix = truncateJsonStringBytes(text, availableBytes)
|
||||||
if (logBudget < 2) {
|
if (prefix.length > 0) {
|
||||||
retained.length = 0
|
const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
|
||||||
retainedBytes = 2
|
/* v8 ignore next -- truncateJsonStringBytes guarantees its returned prefix fits the same budget. */
|
||||||
|
if (prefixBytes === undefined) throw new Error('output ledger produced an oversized log prefix')
|
||||||
|
retained.push(prefix)
|
||||||
|
retainedBytes += prefixBytes + separatorBytes
|
||||||
|
}
|
||||||
|
break
|
||||||
}
|
}
|
||||||
const availableMessageBytes = this.maxBytes - retainedBytes
|
const availableMessageBytes = this.maxBytes - retainedBytes
|
||||||
// This fixed diagnostic is ASCII with no JSON escapes, so two bytes are
|
const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
|
||||||
// the surrounding quotes and every retained character costs one byte.
|
|
||||||
const message = messageBytes <= availableMessageBytes
|
|
||||||
? fullMessage
|
|
||||||
: fullMessage.slice(0, availableMessageBytes - 2)
|
|
||||||
return { logs: retained, error: { kind: 'output-limit', message } }
|
return { logs: retained, error: { kind: 'output-limit', message } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -410,12 +405,9 @@ export class WorkerCodeRuntime extends CodeRuntime {
|
|||||||
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
|
reply({ type: 'reply', id: message.id, ok: false, message: `unknown binding ${JSON.stringify(`${message.global}.${message.name}`)}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
let args: CodeJsonValue | undefined
|
// Structured clone has already removed accessors and proxies, so the
|
||||||
try {
|
// host can repeat the lossless snapshot without a reflective throw.
|
||||||
args = snapshotJsonValue(message.args) as CodeJsonValue | undefined
|
const args = snapshotJsonValue(message.args) as CodeJsonValue | undefined
|
||||||
} catch {
|
|
||||||
args = undefined
|
|
||||||
}
|
|
||||||
if (args === undefined) {
|
if (args === undefined) {
|
||||||
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
|
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */
|
/** JSON string-prefix accounting for the outer-output ledger. @module @deepseek-ai/dsh-code-runtime-worker/output-json */
|
||||||
|
|
||||||
|
import type { CodeJsonValue } from '@deepseek-ai/dsh-code-runtime'
|
||||||
|
|
||||||
/** Control characters with a two-byte short JSON escape instead of `\u00XX`. */
|
/** Control characters with a two-byte short JSON escape instead of `\u00XX`. */
|
||||||
const SHORT_ESCAPE_CODES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d])
|
const SHORT_ESCAPE_CODES = new Set([0x08, 0x09, 0x0a, 0x0c, 0x0d])
|
||||||
|
|
||||||
@@ -13,6 +15,69 @@ function serializedCharacterBytes(character: string): number {
|
|||||||
return Buffer.byteLength(character, 'utf8')
|
return Buffer.byteLength(character, 'utf8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure one JSON string without materializing its complete escaped form.
|
||||||
|
* @param text - the candidate string.
|
||||||
|
* @param maxBytes - largest serialized size the caller can admit.
|
||||||
|
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
|
||||||
|
*/
|
||||||
|
export function jsonStringBytesUpTo(text: string, maxBytes: number): number | undefined {
|
||||||
|
if (maxBytes < 2) return undefined
|
||||||
|
let bytes = 2
|
||||||
|
for (const character of text) {
|
||||||
|
bytes += serializedCharacterBytes(character)
|
||||||
|
if (bytes > maxBytes) return undefined
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measure one lossless JSON value without allocating its serialized form.
|
||||||
|
* @param value - already validated lossless JSON.
|
||||||
|
* @param maxBytes - largest serialized size the caller can admit.
|
||||||
|
* @returns Exact serialized bytes, or `undefined` as soon as the cap is crossed.
|
||||||
|
*/
|
||||||
|
export function jsonValueBytesUpTo(value: CodeJsonValue, maxBytes: number): number | undefined {
|
||||||
|
if (value === null) return maxBytes >= 4 ? 4 : undefined
|
||||||
|
if (typeof value === 'string') return jsonStringBytesUpTo(value, maxBytes)
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
const bytes = Buffer.byteLength(String(value), 'utf8')
|
||||||
|
return bytes <= maxBytes ? bytes : undefined
|
||||||
|
}
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
const bytes = value ? 4 : 5
|
||||||
|
return bytes <= maxBytes ? bytes : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
let bytes = 2
|
||||||
|
if (bytes > maxBytes) return undefined
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
for (let index = 0; index < value.length; index++) {
|
||||||
|
if (index > 0 && ++bytes > maxBytes) return undefined
|
||||||
|
const item = value[index]
|
||||||
|
if (item === undefined) return undefined
|
||||||
|
const itemBytes = jsonValueBytesUpTo(item, maxBytes - bytes)
|
||||||
|
if (itemBytes === undefined) return undefined
|
||||||
|
bytes += itemBytes
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
let entries = 0
|
||||||
|
for (const [key, item] of Object.entries(value)) {
|
||||||
|
if (entries > 0 && ++bytes > maxBytes) return undefined
|
||||||
|
const keyBytes = jsonStringBytesUpTo(key, maxBytes - bytes)
|
||||||
|
if (keyBytes === undefined) return undefined
|
||||||
|
bytes += keyBytes + 1
|
||||||
|
if (bytes > maxBytes) return undefined
|
||||||
|
const itemBytes = jsonValueBytesUpTo(item, maxBytes - bytes)
|
||||||
|
if (itemBytes === undefined) return undefined
|
||||||
|
bytes += itemBytes
|
||||||
|
entries += 1
|
||||||
|
}
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the longest code-point-aligned prefix whose JSON string encoding,
|
* Return the longest code-point-aligned prefix whose JSON string encoding,
|
||||||
* including its surrounding quotes, fits `maxBytes`.
|
* including its surrounding quotes, fits `maxBytes`.
|
||||||
@@ -23,7 +88,6 @@ function serializedCharacterBytes(character: string): number {
|
|||||||
*/
|
*/
|
||||||
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
|
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
|
||||||
if (maxBytes < 2) return ''
|
if (maxBytes < 2) return ''
|
||||||
if (Buffer.byteLength(JSON.stringify(text), 'utf8') <= maxBytes) return text
|
|
||||||
let bytes = 2
|
let bytes = 2
|
||||||
let end = 0
|
let end = 0
|
||||||
for (const character of text) {
|
for (const character of text) {
|
||||||
@@ -32,5 +96,5 @@ export function truncateJsonStringBytes(text: string, maxBytes: number): string
|
|||||||
bytes += cost
|
bytes += cost
|
||||||
end += character.length
|
end += character.length
|
||||||
}
|
}
|
||||||
return text.slice(0, end)
|
return end === text.length ? text : text.slice(0, end)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { truncateJsonStringBytes } from '../src/output-json.ts'
|
import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts'
|
||||||
|
|
||||||
describe('truncateJsonStringBytes', () => {
|
describe('truncateJsonStringBytes', () => {
|
||||||
it('returns a fitting string whole and rejects budgets without JSON quotes', () => {
|
it('returns a fitting string whole and rejects budgets without JSON quotes', () => {
|
||||||
expect(truncateJsonStringBytes('fits', 6)).toBe('fits')
|
expect(truncateJsonStringBytes('fits', 6)).toBe('fits')
|
||||||
expect(truncateJsonStringBytes('x', 1)).toBe('')
|
expect(truncateJsonStringBytes('x', 1)).toBe('')
|
||||||
|
expect(jsonStringBytesUpTo('fits', 6)).toBe(6)
|
||||||
|
expect(jsonStringBytesUpTo('fits', 5)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accounts every JSON escape and cuts only between complete code points', () => {
|
it('accounts every JSON escape and cuts only between complete code points', () => {
|
||||||
@@ -15,4 +17,43 @@ describe('truncateJsonStringBytes', () => {
|
|||||||
expect(truncateJsonStringBytes(text, budget)).toBe(prefix)
|
expect(truncateJsonStringBytes(text, budget)).toBe(prefix)
|
||||||
expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget)
|
expect(Buffer.byteLength(JSON.stringify(truncateJsonStringBytes(text, budget)), 'utf8')).toBe(budget)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('bounds hostile strings without materializing their complete escaped form', () => {
|
||||||
|
const stringify = vi.spyOn(JSON, 'stringify').mockImplementation(() => { throw new Error('must not stringify') })
|
||||||
|
try {
|
||||||
|
expect(jsonStringBytesUpTo('"'.repeat(10_000), 32)).toBeUndefined()
|
||||||
|
expect(truncateJsonStringBytes('"'.repeat(10_000), 32)).toBe('"'.repeat(15))
|
||||||
|
} finally {
|
||||||
|
stringify.mockRestore()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('jsonValueBytesUpTo', () => {
|
||||||
|
it('matches JSON serialization for every lossless value branch and stops at the cap', () => {
|
||||||
|
const value = {
|
||||||
|
empty: {},
|
||||||
|
nil: null,
|
||||||
|
yes: true,
|
||||||
|
no: false,
|
||||||
|
number: 1.5,
|
||||||
|
text: '"\n😀',
|
||||||
|
array: [1, 'x'],
|
||||||
|
}
|
||||||
|
const bytes = Buffer.byteLength(JSON.stringify(value), 'utf8')
|
||||||
|
|
||||||
|
expect(jsonValueBytesUpTo(value, bytes)).toBe(bytes)
|
||||||
|
expect(jsonValueBytesUpTo(value, bytes - 1)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo({}, 1)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo(null, 3)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo(10, 1)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo(false, 4)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo(new Array<never>(1), 10)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo([null], 5)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo([0, 0], 3)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo({ a: null, b: null }, 10)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo({ long: null }, 2)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo({ '': null }, 4)).toBeUndefined()
|
||||||
|
expect(jsonValueBytesUpTo({ a: null }, 9)).toBeUndefined()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -401,6 +401,22 @@ describe('WorkerCodeRuntime — hostile programs (real workers)', () => {
|
|||||||
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8')).toBeLessThan(200)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('bounds one oversized forged log while retaining its fitting escaped prefix', async () => {
|
||||||
|
const { runtime } = await setup({ maxOutputBytes: 96 })
|
||||||
|
const result = await runtime.run({
|
||||||
|
program: `
|
||||||
|
const { parentPort } = await import('node:worker_threads');
|
||||||
|
parentPort.postMessage({ type: 'log', text: '"'.repeat(1_000_000) });
|
||||||
|
for (;;) {}
|
||||||
|
`,
|
||||||
|
bindings: [],
|
||||||
|
})
|
||||||
|
expect(result.error).toEqual({ kind: 'output-limit', message: 'outer output exceeded 96 bytes' })
|
||||||
|
expect(result.logs).toHaveLength(1)
|
||||||
|
expect(result.logs[0]).toMatch(/^"+$/)
|
||||||
|
expect(Buffer.byteLength(JSON.stringify(result.logs), 'utf8') + Buffer.byteLength(JSON.stringify('outer output exceeded 96 bytes'), 'utf8')).toBeLessThanOrEqual(96)
|
||||||
|
})
|
||||||
|
|
||||||
it('drops a malformed forged done carrying both value and error', async () => {
|
it('drops a malformed forged done carrying both value and error', async () => {
|
||||||
const { runtime } = await setup()
|
const { runtime } = await setup()
|
||||||
const result = await runtime.run({
|
const result = await runtime.run({
|
||||||
|
|||||||
@@ -12,6 +12,18 @@
|
|||||||
*/
|
*/
|
||||||
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }
|
||||||
|
|
||||||
|
/** Whether an array uses one realm's intrinsic `Array.prototype`, not a subclass or forged prototype. */
|
||||||
|
function hasPlainArrayPrototype(value: unknown[]): boolean {
|
||||||
|
const prototype: unknown = Object.getPrototypeOf(value)
|
||||||
|
return Array.isArray(prototype) && Object.getPrototypeOf(Object.getPrototypeOf(prototype)) === null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether an object is a plain or null-prototype record from any JavaScript realm. */
|
||||||
|
function hasPlainObjectPrototype(value: object): boolean {
|
||||||
|
const prototype: unknown = Object.getPrototypeOf(value)
|
||||||
|
return prototype === null || Object.getPrototypeOf(prototype) === null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validate and detach lossless JSON in one read per property, so a stateful
|
* Validate and detach lossless JSON in one read per property, so a stateful
|
||||||
* getter cannot change between validation and copying. Accepts ordinary arrays,
|
* getter cannot change between validation and copying. Accepts ordinary arrays,
|
||||||
@@ -46,7 +58,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
|
|||||||
ancestors.add(current)
|
ancestors.add(current)
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(current)) {
|
if (Array.isArray(current)) {
|
||||||
if (Object.getPrototypeOf(current) !== Array.prototype) return undefined
|
if (!hasPlainArrayPrototype(current)) return undefined
|
||||||
const length = current.length
|
const length = current.length
|
||||||
// Every ordinary array owns `length`; dense indexed elements account
|
// Every ordinary array owns `length`; dense indexed elements account
|
||||||
// for the remaining keys. Anything else would be lost by JSON and by
|
// for the remaining keys. Anything else would be lost by JSON and by
|
||||||
@@ -62,8 +74,7 @@ export function snapshotJsonValue<T>(value: T): T | undefined {
|
|||||||
return snapshot
|
return snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
const prototype = Object.getPrototypeOf(current) as unknown
|
if (!hasPlainObjectPrototype(current)) return undefined
|
||||||
if (prototype !== Object.prototype && prototype !== null) return undefined
|
|
||||||
const snapshot: { [key: string]: JsonValue } = {}
|
const snapshot: { [key: string]: JsonValue } = {}
|
||||||
for (const key of Object.keys(current)) {
|
for (const key of Object.keys(current)) {
|
||||||
const item = visit((current as Record<string, unknown>)[key])
|
const item = visit((current as Record<string, unknown>)[key])
|
||||||
@@ -115,7 +126,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
|||||||
seen.add(value)
|
seen.add(value)
|
||||||
try {
|
try {
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
if (Object.getPrototypeOf(value) !== Array.prototype) return false
|
if (!hasPlainArrayPrototype(value)) return false
|
||||||
if (Reflect.ownKeys(value).length !== value.length + 1) return false
|
if (Reflect.ownKeys(value).length !== value.length + 1) return false
|
||||||
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
// Reject sparse arrays: a hole is skipped by `every`/`forEach` but
|
||||||
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
// JSON.stringify writes it as `null`, so `[1, , 3]` would round-trip
|
||||||
@@ -127,8 +138,7 @@ export function isJsonValue(value: unknown, seen: Set<object> = new Set()): bool
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
// Plain object only (reject Map/Set/Date/class instances).
|
// Plain object only (reject Map/Set/Date/class instances).
|
||||||
const proto = Object.getPrototypeOf(value) as unknown
|
if (!hasPlainObjectPrototype(value)) return false
|
||||||
if (proto !== Object.prototype && proto !== null) return false
|
|
||||||
return Object.values(value).every(v => isJsonValue(v, seen))
|
return Object.values(value).every(v => isJsonValue(v, seen))
|
||||||
} finally {
|
} finally {
|
||||||
seen.delete(value)
|
seen.delete(value)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { isJsonValue, snapshotJsonValue } from '@deepseek-ai/dsh-session'
|
import { isJsonValue, snapshotJsonValue, type JsonValue } from '@deepseek-ai/dsh-session'
|
||||||
|
|
||||||
describe('snapshotJsonValue', () => {
|
describe('snapshotJsonValue', () => {
|
||||||
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
it('copies the complete JSON scalar vocabulary and rejects unsupported scalars', () => {
|
||||||
@@ -36,6 +37,22 @@ describe('snapshotJsonValue', () => {
|
|||||||
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
|
expect(Object.getPrototypeOf(snapshot.list[0])).toBe(Object.prototype)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts intrinsic plain containers from another JavaScript realm', () => {
|
||||||
|
const foreign = runInNewContext('({ object: { nested: [1] }, array: [2, { ok: true }] })') as {
|
||||||
|
object: { nested: number[] }
|
||||||
|
array: JsonValue[]
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(isJsonValue(foreign.object)).toBe(true)
|
||||||
|
expect(isJsonValue(foreign.array)).toBe(true)
|
||||||
|
const objectSnapshot = snapshotJsonValue(foreign.object)!
|
||||||
|
const arraySnapshot = snapshotJsonValue(foreign.array)!
|
||||||
|
expect(objectSnapshot).toEqual({ nested: [1] })
|
||||||
|
expect(arraySnapshot).toEqual([2, { ok: true }])
|
||||||
|
expect(Object.getPrototypeOf(objectSnapshot)).toBe(Object.prototype)
|
||||||
|
expect(Object.getPrototypeOf(arraySnapshot)).toBe(Array.prototype)
|
||||||
|
})
|
||||||
|
|
||||||
it('reads each object value and array slot once while materializing', () => {
|
it('reads each object value and array slot once while materializing', () => {
|
||||||
class Exotic {
|
class Exotic {
|
||||||
readonly accepted = false
|
readonly accepted = false
|
||||||
@@ -77,10 +94,17 @@ describe('snapshotJsonValue', () => {
|
|||||||
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
|
Object.defineProperty(symbolDecorated, Symbol('extra'), { value: true })
|
||||||
const cyclic: Record<string, unknown> = {}
|
const cyclic: Record<string, unknown> = {}
|
||||||
cyclic.self = cyclic
|
cyclic.self = cyclic
|
||||||
|
const foreignExotics = runInNewContext(`(() => {
|
||||||
|
class Box { constructor() { this.value = 1 } }
|
||||||
|
class List extends Array {}
|
||||||
|
return [new Box(), new List(1)]
|
||||||
|
})()`) as [object, unknown[]]
|
||||||
|
|
||||||
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
|
expect(snapshotJsonValue(new ExoticObject())).toBeUndefined()
|
||||||
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
|
expect(snapshotJsonValue(new Map([['value', 1]]))).toBeUndefined()
|
||||||
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
|
expect(snapshotJsonValue(new ExoticArray(1))).toBeUndefined()
|
||||||
|
expect(snapshotJsonValue(foreignExotics[0])).toBeUndefined()
|
||||||
|
expect(snapshotJsonValue(foreignExotics[1])).toBeUndefined()
|
||||||
expect(snapshotJsonValue(sparse)).toBeUndefined()
|
expect(snapshotJsonValue(sparse)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(compensatedSparse)).toBeUndefined()
|
expect(snapshotJsonValue(compensatedSparse)).toBeUndefined()
|
||||||
expect(snapshotJsonValue(decorated)).toBeUndefined()
|
expect(snapshotJsonValue(decorated)).toBeUndefined()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { runInNewContext } from 'node:vm'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
assertObjectJsonSchema,
|
assertObjectJsonSchema,
|
||||||
@@ -187,6 +188,16 @@ describe('the enforced raw JSON Schema subset', () => {
|
|||||||
.toEqual(['schema.examples annotation must be lossless JSON data'])
|
.toEqual(['schema.examples annotation must be lossless JSON data'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts lossless annotation containers from another JavaScript realm', () => {
|
||||||
|
const schema = runInNewContext(`({
|
||||||
|
type: 'object',
|
||||||
|
default: { x: 1 },
|
||||||
|
examples: [[{ ok: true }]],
|
||||||
|
})`) as unknown
|
||||||
|
|
||||||
|
expect(() => { assertSupportedJsonSchema(schema) }).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
|
it('rejects cyclic/exotic schema structure but permits sibling reuse', () => {
|
||||||
const cyclic: Record<string, unknown> = { type: 'object' }
|
const cyclic: Record<string, unknown> = { type: 'object' }
|
||||||
cyclic.properties = { self: cyclic }
|
cyclic.properties = { self: cyclic }
|
||||||
|
|||||||
@@ -1807,6 +1807,25 @@ describe('defineTool validation (the runtime-validation Agent Note, part 1)', ()
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('defineTool presentation (presentCall / presentResult)', () => {
|
describe('defineTool presentation (presentCall / presentResult)', () => {
|
||||||
|
it('preserves inline enum and const literals in inferred arguments', () => {
|
||||||
|
defineTool({
|
||||||
|
name: 'literal-args',
|
||||||
|
description: 'literal arguments',
|
||||||
|
parameters: {
|
||||||
|
mode: { type: 'string', enum: ['read', 'write'], required: true },
|
||||||
|
attempt: { type: 'integer', const: 1 },
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
schema: { type: 'null' },
|
||||||
|
render: () => [],
|
||||||
|
},
|
||||||
|
async execute(args) {
|
||||||
|
expectTypeOf(args).toEqualTypeOf<{ mode: 'read' | 'write'; attempt?: 1 }>()
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
|
it('threads presentCall/presentResult onto the ToolDefinition with typed args', () => {
|
||||||
const tool = defineContentToolFixture({
|
const tool = defineContentToolFixture({
|
||||||
name: 'demo',
|
name: 'demo',
|
||||||
|
|||||||
Reference in New Issue
Block a user