diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
index f434c81118..0cd154e190 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.i18n.yaml
@@ -2,5 +2,5 @@
# 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:
# pnpm run verify-translation-pairing --write
-2026-07-20-unified-json-value-schema-dsl.md: ab7bb268407ac26230283172ebb291de80412bf2
-2026-07-20-unified-json-value-schema-dsl.zh.md: 479699fc2d58666b86861f0ea1db907ae4957dc5
+2026-07-20-unified-json-value-schema-dsl.md: 94c3f5aa5fcb84abddc58e8fd298188b3284f7ea
+2026-07-20-unified-json-value-schema-dsl.zh.md: d8362c2c9987689fbd812b6c16aae815f56062e1
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
index ab7bb26840..94c3f5aa5f 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.md
@@ -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.
-An explicit author object must declare `additionalProperties: true | false`. The implicit parameter root and raw JSON Schema preserve the standard open default. `InferValue` and `InferArgs
` 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` and `InferArgs
` 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.
diff --git a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
index 479699fc2d..d8362c2c99 100644
--- a/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
+++ b/.agents/notes/implemented/architecture/2026-07-20-unified-json-value-schema-dsl.zh.md
@@ -12,7 +12,7 @@ Status: implemented
`dsh-tools` 以两种表示形式统一管理一套 JSON 值 schema 词汇。`ValueSchemaSpec` 是可描述任意 JSON 根类型的作者侧形式;`ParameterSchemaSpec` 是其隐式对象属性映射形式,每个属性可标记 `required: true`。`JsonSchemaNode` 是原始协议表示。两种形式都支持字符串、有限数值、整数、布尔值、null、数组、对象、类型正确的标量 `enum`/`const`,以及要求恰好匹配一个分支的 `oneOf`;`{ type: 'json' }` 仅是作者侧语法糖,会编译为仅含注解、不施加约束的原始节点。
-显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。
+显式的作者侧对象必须声明 `additionalProperties: true | false`。隐式参数根对象和原始 JSON Schema 保留标准的默认开放语义。`InferValue` 和 `InferArgs
` 根据同一份声明推导 TypeScript 值,`valueSchemaSpecToJsonSchema()` 和 `parameterSchemaSpecToJsonSchema()` 也将这些声明编译为 JSON Schema。`assertSupportedJsonSchema()` 会拒绝不受支持或位置错误的关键字;`validateJsonSchemaValue()` 则以无损 `JsonValue` 边界校验受支持的子集,不允许 `undefined`、负零、非有限数、稀疏数组、循环引用、非普通对象、函数、symbol 及其他需要强制转换的值。内建的普通 Object 和 Array 容器跨 JavaScript 运行域后仍视为普通容器;其子类仍视为非普通对象。
对象根限制属于消费方规则,不属于 schema 词汇本身。subagent 和工作流中由调用方定义的结构化输出通过 `assertObjectJsonSchema()` 和 `ObjectJsonSchema` 保持对象根限制;工具输出可以使用任意根类型。动态 Cordis 注册会把跨 JavaScript 运行域传入的 schema 重建为当前运行时持有的 JSON,保留原始包装层的默认开放语义,并要求直接使用 DSL 声明的对象明确选择开放方式,然后再调用同一编译器。
diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts
index 0fa66dbd71..198bec694b 100644
--- a/packages/code-runtime/code-runtime-worker/src/index.ts
+++ b/packages/code-runtime/code-runtime-worker/src/index.ts
@@ -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 { snapshotJsonValue } from '@deepseek-ai/dsh-session'
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). */
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. */
class OutputLedger {
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(text: string, sink: string[]): boolean {
- const cost = Buffer.byteLength(JSON.stringify(text), 'utf8') + (this.entries > 0 ? 1 : 0)
- if (this.bytes + cost > this.maxBytes) return false
- this.bytes += cost
+ const separatorBytes = this.entries > 0 ? 1 : 0
+ const stringBytes = jsonStringBytesUpTo(text, this.maxBytes - this.bytes - separatorBytes)
+ if (stringBytes === undefined) return false
+ this.bytes += stringBytes + separatorBytes
this.entries += 1
sink.push(text)
return true
@@ -165,46 +161,45 @@ class OutputLedger {
/** Finalize a successful absent-or-JSON completion against the combined cap. */
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 } : {} }
}
/** Finalize a failure diagnostic, with output-limit taking precedence when combined bytes exceed the cap. */
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 }
}
/** Build the explicit output-limit failure while retaining a fitting prefix of the final log. */
limit(logs: string[]): CodeRunResult {
const fullMessage = `outer output exceeded ${this.maxBytes} bytes`
- const messageBytes = Buffer.byteLength(JSON.stringify(fullMessage), 'utf8')
- const retained = [...logs]
- let retainedBytes = jsonBytes(retained)
+ // The fixed diagnostic is ASCII, so every character is one byte plus the quotes.
+ const messageBytes = fullMessage.length + 2
+ const retained: string[] = []
+ let retainedBytes = 2
const logBudget = this.maxBytes - messageBytes
- while (retained.length > 0 && retainedBytes > logBudget) {
- 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')
+ for (const text of logs) {
const separatorBytes = retained.length > 0 ? 1 : 0
- retainedBytes -= Buffer.byteLength(JSON.stringify(removed), 'utf8') + separatorBytes
- const prefix = truncateJsonStringBytes(removed, logBudget - retainedBytes - separatorBytes)
- if (prefix.length > 0) {
- retained.push(prefix)
- retainedBytes += Buffer.byteLength(JSON.stringify(prefix), 'utf8') + separatorBytes
- break
+ const availableBytes = logBudget - retainedBytes - separatorBytes
+ const stringBytes = jsonStringBytesUpTo(text, availableBytes)
+ if (stringBytes !== undefined) {
+ retained.push(text)
+ retainedBytes += stringBytes + separatorBytes
+ continue
}
- }
- if (logBudget < 2) {
- retained.length = 0
- retainedBytes = 2
+ const prefix = truncateJsonStringBytes(text, availableBytes)
+ if (prefix.length > 0) {
+ const prefixBytes = jsonStringBytesUpTo(prefix, availableBytes)
+ /* 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
- // This fixed diagnostic is ASCII with no JSON escapes, so two bytes are
- // the surrounding quotes and every retained character costs one byte.
- const message = messageBytes <= availableMessageBytes
- ? fullMessage
- : fullMessage.slice(0, availableMessageBytes - 2)
+ const message = truncateJsonStringBytes(fullMessage, availableMessageBytes)
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}`)}` })
return
}
- let args: CodeJsonValue | undefined
- try {
- args = snapshotJsonValue(message.args) as CodeJsonValue | undefined
- } catch {
- args = undefined
- }
+ // Structured clone has already removed accessors and proxies, so the
+ // host can repeat the lossless snapshot without a reflective throw.
+ const args = snapshotJsonValue(message.args) as CodeJsonValue | undefined
if (args === undefined) {
reply({ type: 'reply', id: message.id, ok: false, message: 'binding arguments must be lossless JSON' })
return
diff --git a/packages/code-runtime/code-runtime-worker/src/output-json.ts b/packages/code-runtime/code-runtime-worker/src/output-json.ts
index dd3c3529f5..2110a4ab27 100644
--- a/packages/code-runtime/code-runtime-worker/src/output-json.ts
+++ b/packages/code-runtime/code-runtime-worker/src/output-json.ts
@@ -1,5 +1,7 @@
/** 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`. */
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')
}
+/**
+ * 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,
* including its surrounding quotes, fits `maxBytes`.
@@ -23,7 +88,6 @@ function serializedCharacterBytes(character: string): number {
*/
export function truncateJsonStringBytes(text: string, maxBytes: number): string {
if (maxBytes < 2) return ''
- if (Buffer.byteLength(JSON.stringify(text), 'utf8') <= maxBytes) return text
let bytes = 2
let end = 0
for (const character of text) {
@@ -32,5 +96,5 @@ export function truncateJsonStringBytes(text: string, maxBytes: number): string
bytes += cost
end += character.length
}
- return text.slice(0, end)
+ return end === text.length ? text : text.slice(0, end)
}
diff --git a/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts
index 8340d6e74c..36f6ebcfad 100644
--- a/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts
+++ b/packages/code-runtime/code-runtime-worker/tests/output-json.spec.ts
@@ -1,10 +1,12 @@
-import { describe, expect, it } from 'vitest'
-import { truncateJsonStringBytes } from '../src/output-json.ts'
+import { describe, expect, it, vi } from 'vitest'
+import { jsonStringBytesUpTo, jsonValueBytesUpTo, truncateJsonStringBytes } from '../src/output-json.ts'
describe('truncateJsonStringBytes', () => {
it('returns a fitting string whole and rejects budgets without JSON quotes', () => {
expect(truncateJsonStringBytes('fits', 6)).toBe('fits')
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', () => {
@@ -15,4 +17,43 @@ describe('truncateJsonStringBytes', () => {
expect(truncateJsonStringBytes(text, budget)).toBe(prefix)
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