fix(code-mode): keep deep host boundaries iterative

This commit is contained in:
Tianyi Cui
2026-07-23 00:30:36 +08:00
parent 25d0ef5c6c
commit e35a419ba8
8 changed files with 144 additions and 21 deletions

View File

@@ -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-code-mode-typed-tool-returns.md: f902aa4ff8cdaa0979f09850ea15deede5fdf576
2026-07-20-code-mode-typed-tool-returns.zh.md: 4bdec90960e270f5be03b0a4c30d7c829dd27f6a
2026-07-20-code-mode-typed-tool-returns.md: bcaefb196d12660177ad2bbc9c20ad71f6537eae
2026-07-20-code-mode-typed-tool-returns.zh.md: 72946f4cc1ab7569fc1e3f90785debf8314f1d35

View File

@@ -49,7 +49,7 @@ declare const tools: {
### Binding values and failures
Before dispatch the bridge snapshots binding arguments as lossless JSON and makes independent clones for execution and the durable summary event. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
Before dispatch the bridge snapshots binding arguments as lossless JSON and snapshots the detached value again for an independent durable summary event. Host-side detachment, immutable execution, and output-schema projection all use iterative traversals rather than nested structured clone or recursive freezing. `undefined`, non-finite numbers, `-0`, sparse arrays, cycles, functions, and exotic objects reject that call before the tool runs. Successful dispatch returns `ToolExecutionResult.value`; Native `content`, metadata, and internal error information do not cross to the program.
The worker exposes the actual `ToolCallError` constructor used for `tools` binding failures, so `error instanceof ToolCallError` works. The error has the standard `Error` message plus the exact `toolName`; it deliberately omits `ToolFailure.info`, error codes, and Native content. This is an exception contract for control flow, not a failure union for programmatic classification.

View File

@@ -49,7 +49,7 @@ declare const tools: {
### 绑定值与失败
分发前,桥接层会把绑定参数快照为无损 JSON并为执行和持久摘要事件分别创建独立副本。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`Native `content`、元数据和内部错误信息不会传入程序。
分发前,桥接层会把绑定参数快照为无损 JSON再对分离后的值生成一次快照,供独立的持久摘要事件使用。宿主侧的值分离、执行数据的不可变处理与输出 schema 投影均采用迭代遍历,而不使用嵌套结构化克隆或递归冻结。`undefined`、非有限数、`-0`、稀疏数组、循环引用、函数和非普通对象都会使该调用在工具运行前 reject。成功分发会返回 `ToolExecutionResult.value`Native `content`、元数据和内部错误信息不会传入程序。
worker 暴露的是真正用于 `tools` 绑定失败的 `ToolCallError` 构造函数,因此 `error instanceof ToolCallError` 能够成立。该错误包含标准的 `Error` 消息和确切的 `toolName`,并有意省略 `ToolFailure.info`、错误代码与 Native 内容。这是一项用于控制流的异常契约,而不是供程序分类的失败联合。

View File

@@ -85,9 +85,9 @@ function summarize(text: string, cwd: string | undefined): string {
}
/**
* Snapshot one binding call's argument as lossless JSON, then clone it into
* independent dispatch/log values so a tool mutation cannot desynchronize the
* durable event from what was called.
* Snapshot one binding call's argument as lossless JSON, then snapshot that
* detached value again so dispatch and logging stay independent without
* reintroducing structured-clone's platform-specific nesting limit.
*/
function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unknown } {
let snapshot: JsonValue | undefined
@@ -99,7 +99,12 @@ function jsonNormalizeArgs(value: unknown): { dispatched: unknown; logged: unkno
if (snapshot === undefined) {
throw new Error('tool arguments must be lossless JSON (call the tool with an arguments object, e.g. `{}`)')
}
return { dispatched: structuredClone(snapshot), logged: structuredClone(snapshot) }
const logged = snapshotJsonValue(snapshot)
/* v8 ignore next -- snapshot is already a detached lossless JSON value. */
if (logged === undefined) {
throw new Error('tool arguments could not be detached for durable logging')
}
return { dispatched: snapshot, logged }
}
/** Two-space JSON presentation, matching the existing shallow `run_code` text contract. */

View File

@@ -864,10 +864,17 @@ export class ToolRegistry extends Service {
private sdkSchemas(scope?: ScopeKey): ToolSdkSchema[] {
return [...this.view(scope).visible.values()]
.filter(definition => definition.name !== RUN_CODE_NAME)
.map((definition): ToolSdkSchema => ({
...this.schemaOf(definition, true),
output: structuredClone(definition.output.schema),
}))
.map((definition): ToolSdkSchema => {
const output = snapshotJsonValue(definition.output.schema)
/* v8 ignore next -- registration already validated and retained this schema as lossless JSON. */
if (output === undefined) {
throw new Error(`tool "${definition.name}" output schema must be lossless JSON before SDK projection`)
}
return {
...this.schemaOf(definition, true),
output,
}
})
}
/** Project one definition onto the model-facing schema fields. */

View File

@@ -7,7 +7,7 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime'
import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime'
import ToolRegistry, { CodeRunFailedError, RUN_CODE_NAME, TOOL_ABORTED_BEFORE_DISPATCH, defineContentToolFixture, defineTool } from '@deepseek-ai/dsh-tools'
import type { Config, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Config, JsonSchemaNode, PostToolDecision, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { JsonValue, SessionEventMap } from '@deepseek-ai/dsh-session'
@@ -130,6 +130,30 @@ describe('mode-aware wire contribution', () => {
expect(sdk?.text).not.toContain('run_code:')
})
it('projects deeply nested output schemas into the Code Mode SDK without structured-clone recursion', async () => {
const { ctx, systemPrompt } = await setup({ mode: 'code' })
let output: JsonSchemaNode = { type: 'string' }
for (let depth = 0; depth < 5_000; depth++) {
output = { oneOf: [output, { type: 'null' }] }
}
ctx.tools.register({
name: 'deep_output',
description: 'Return a deeply nested output union.',
parameters: { type: 'object', properties: {} },
output: {
schema: output,
render: (_args, value) => [{ type: 'text', text: typeof value === 'string' ? value : 'null' }],
},
execute() { return Promise.resolve('ok') },
})
const assembly = await systemPrompt.assemble()
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text
expect(sdk).toContain('deep_output: Record<string, JsonValue>;')
expect(sdk).toContain('deep_output: string | null')
})
it.each(['code', 'both'] as const)('treats expert assembly output as authoritative in mode %s', async (mode) => {
const { ctx, systemPrompt } = await setup({ mode })
registerEcho(ctx)
@@ -811,6 +835,57 @@ describe('the run_code dispatch bridge', () => {
expect(events.filter(event => event.type === 'tool/code-dispatch')).toEqual([])
})
it('dispatches and durably logs binding arguments deeper than the structured-clone call stack', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const depth = 5_000
let observedDepth = 0
let observedLeaf: JsonValue | undefined
ctx.tools.register(defineTool({
name: 'deep_args',
description: 'Measure a deeply nested JSON argument.',
parameters: { nested: { type: 'json', required: true } },
output: {
schema: { type: 'integer' },
render: (_args, value) => [{ type: 'text', text: String(value) }],
},
execute(args) {
let cursor = args.nested
while (Array.isArray(cursor)) {
if (cursor.length !== 1) throw new Error('expected one item per nesting layer')
observedDepth++
cursor = cursor[0]!
}
observedLeaf = cursor
return Promise.resolve(observedDepth)
},
}))
const session = new Session(SessionId('deep-code-arguments'))
const agent = { session } as Agent
runtime.behavior = async (request) => {
let nested: JsonValue = 'leaf'
for (let index = 0; index < depth; index++) nested = [nested]
const value = await request.bindings[0]!.functions.deep_args!({ nested })
return { logs: [], value }
}
const result = await runCode(ctx, 'return tools.deep_args(...)', { agent })
expect(result.isError).toBe(false)
expect(result.isError ? undefined : result.value).toEqual({ logs: [], result: depth })
expect({ observedDepth, observedLeaf }).toEqual({ observedDepth: depth, observedLeaf: 'leaf' })
const dispatch = session.events.find(event => event.type === 'tool/code-dispatch')
if (dispatch === undefined) throw new Error('expected a durable tool/code-dispatch event')
const logged = dispatch.data.arguments as { nested: JsonValue }
let loggedDepth = 0
let loggedCursor = logged.nested
while (Array.isArray(loggedCursor)) {
if (loggedCursor.length !== 1) throw new Error('expected one logged item per nesting layer')
loggedDepth++
loggedCursor = loggedCursor[0]!
}
expect({ loggedDepth, loggedCursor }).toEqual({ loggedDepth: depth, loggedCursor: 'leaf' })
})
it('gives the tool and durable log the same immutable argument value', async () => {
const { ctx, runtime } = await setup({ mode: 'code' })
const { agent, events } = fakeAgent()

View File

@@ -58,7 +58,8 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
}
/**
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
* Deep-freeze a value in place with an iterative traversal, guarding cycles,
* so later mutation throws without imposing a JavaScript call-stack depth cap.
* {@link AbortSignal} objects are deliberately skipped because they are the
* request's live cancellation channel and freezing them breaks abort.
* @param value - the value to freeze in place.
@@ -66,16 +67,31 @@ export function isAgentLoopRequest(request: GenerateOptions): boolean {
*/
export function deepFreeze<T>(value: T): T {
const seen = new WeakSet<object>()
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
if (node instanceof AbortSignal) return
if (seen.has(node)) return
const pending: (
| { kind: 'visit'; node: unknown }
| { kind: 'property'; source: Record<string, unknown>; key: string }
)[] = [{ kind: 'visit', node: value }]
while (pending.length > 0) {
const task = pending.pop()
/* v8 ignore next -- the loop condition guarantees one pending task. */
if (task === undefined) continue
if (task.kind === 'property') {
pending.push({ kind: 'visit', node: task.source[task.key] })
continue
}
const node = task.node
if (node === null || typeof node !== 'object') continue
if (node instanceof AbortSignal) continue
if (seen.has(node)) continue
seen.add(node)
Object.freeze(node)
for (const key of Object.keys(node)) {
walk((node as Record<string, unknown>)[key])
const keys = Object.keys(node)
for (let index = keys.length - 1; index >= 0; index--) {
const key = keys[index]
/* v8 ignore next -- the loop is bounded by the captured key count. */
if (key === undefined) continue
pending.push({ kind: 'property', source: node as Record<string, unknown>, key })
}
}
walk(value)
return value
}

View File

@@ -56,6 +56,26 @@ describe('deepFreeze', () => {
deepFreeze(cyclic)
expect(Object.isFrozen(cyclic)).toBe(true)
})
it('freezes nesting deeper than the JavaScript call stack', () => {
const depth = 5_000
const root: unknown[] = []
let cursor = root
for (let index = 0; index < depth; index++) {
const child: unknown[] = []
cursor.push(child)
cursor = child
}
deepFreeze(root)
cursor = root
for (let index = 0; index < depth; index++) {
expect(Object.isFrozen(cursor)).toBe(true)
cursor = cursor[0] as unknown[]
}
expect(Object.isFrozen(cursor)).toBe(true)
})
})
describe('agent-loop request identity', () => {