fix(tools): make py-types render total and bound deep class names
Address ds-review-bot v5/v6 review round 4: - renderType now holds the no-throw contract across the whole walk, not just root validation: a stateful getter that passes validation and then throws in the render phase degrades the node to Any, rolling back any classes the call had begun emitting, instead of escaping. - allocateClassName caps the accumulated base name. Child class names derive from their parent's, so an unbounded single-field object chain grew the sum of names to Theta(depth^2) (a 5000-deep schema produced a ~25MB SDK); the cap keeps total emitted text linear, the collision counter still makes truncated bases unique. - The language-dispatch note's Consequences first sentence and the zh guard paragraph are corrected: two table entries (not one), and full-width Chinese punctuation per translation-rules.md.
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 .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
|
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md
|
||||||
2026-07-31-code-mode-language-dispatch.md: c5643485f5ff9beda8d3f057379242fb4bcc7407
|
2026-07-31-code-mode-language-dispatch.md: 23794226c8e236421a79fb2143ccb09095f1a287
|
||||||
2026-07-31-code-mode-language-dispatch.zh.md: 889168698215560da1d15799f814d21cff25acf7
|
2026-07-31-code-mode-language-dispatch.zh.md: d2f868215181a99814c19ca4817582e96b396807
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
Adding a backend language is a table entry plus its renderer, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run.
|
Adding a backend language is two table entries — a `SDK_RENDERERS` renderer and a `RUN_CODE_FLAVORS` entry — plus the renderer itself, with no change to `agent-loop` or the registry structure. The two tables (`SDK_RENDERERS`, `RUN_CODE_FLAVORS`) must stay in step: a language present in one but not the other is a latent inconsistency the `Object.hasOwn` guards turn into a loud failure rather than a wrong-language prompt. The tool layer stays free of any concrete backend dependency, so it lands and is testable on master ahead of the Python protocol and backend; the cost is that a `python` runtime cannot actually be exercised end to end until that backend ships, so this PR's coverage is unit-level (the renderer output and the dispatch/rejection paths) rather than a real Python run.
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd
|
|||||||
- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。
|
- `SDK_RENDERERS`(index.ts)把语言映射到它的 `tools:sdk` 渲染器——`typescript → renderToolsSdk`、`python → renderToolsSdkPy`。`tools:sdk` 段读取所加载运行时的语言并选出渲染器;`requireCodeRuntime` 拒绝其语言不在表中的 `mode: code`/`both` 运行时,并列出已知语言。
|
||||||
- `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。
|
- `RUN_CODE_FLAVORS`(code-mode.ts)把语言映射到它那两条面向模型的 `run_code` 字符串(工具 `description` 与 `code` 参数描述),使一种语言的 SDK 段与它的传输 schema 始终一致。
|
||||||
|
|
||||||
两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。
|
两张表在使用前都以 `Object.hasOwn` 读取,这样名为 `toString`/`constructor` 的语言不会把继承自 `Object.prototype` 的成员解析成渲染器。两个守卫的可达性不同:`SDK_RENDERERS` 的段内守卫不可达,因为 `requireCodeRuntime` 已在同一回调更早处校验过同一张 `const` 表(它带 `/* v8 ignore */`);而 `RUN_CODE_FLAVORS` 的守卫是主要的、可公开到达的拒绝路径——在语言有渲染器却无 flavor 表项的运行时下读 `ctx.tools.schemas()` 即到达,且有测试覆盖。schema 发射通过 `peekRuntime()` 而非 `requireRuntime()` 读取运行时:`undefined`(无运行时,即永不喂给模型的 doc-catalog schema 采集)降级到 TypeScript flavor,而挂载了未知语言则 fail loud——这不是下方被否决的静默回退,那指的是为真实运行时发出错误语言的 SDK。新增一门后端语言就是两条表项加它的渲染器——不动 `agent-loop`,也不动注册表结构。
|
||||||
|
|
||||||
`code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。
|
`code-mode.ts` 只依赖运行时 seam(`@deepseek-ai/dsh-code-runtime`),绝不依赖具体后端;分发在运行时按 `runtime.language` 进行。因此工具层独立于协议和后端 PR 落地——它只需要 seam 的 `language` 字段,而该字段已在 master 上。
|
||||||
|
|
||||||
@@ -33,4 +33,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd
|
|||||||
|
|
||||||
## Consequences
|
## Consequences
|
||||||
|
|
||||||
新增一门后端语言就是一条表项加它的渲染器,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。
|
新增一门后端语言就是两条表项——一个 `SDK_RENDERERS` 渲染器加一个 `RUN_CODE_FLAVORS` 表项——再加渲染器本身,不动 `agent-loop`,也不动注册表结构。两张表(`SDK_RENDERERS`、`RUN_CODE_FLAVORS`)必须同步:某语言只在其一而不在另一是潜在的不一致,`Object.hasOwn` 守卫会把它变成一次 loud failure,而不是错误语言的 prompt。工具层不依赖任何具体后端,因此它能先于 Python 协议和后端在 master 上落地并可测;代价是在该后端发布前无法真正端到端跑一个 `python` 运行时,故本 PR 的覆盖是 unit 级(渲染器输出与分发/拒绝路径),而非真实的 Python 运行。
|
||||||
|
|||||||
@@ -121,9 +121,20 @@ function camelCase(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */
|
/** Reserve a unique class name, suffixing a counter on collision after CamelCase sanitization. */
|
||||||
|
/**
|
||||||
|
* Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
|
||||||
|
* The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
|
||||||
|
* derive from their parent's allocated name (`ParentChild`), so an unbounded
|
||||||
|
* schema of single-field objects would otherwise grow each name by one field
|
||||||
|
* per level and the sum of all names to Θ(depth²). Capping the base keeps each
|
||||||
|
* name — and the total emitted text — linear in depth; the collision counter
|
||||||
|
* still makes truncated bases unique.
|
||||||
|
*/
|
||||||
|
const MAX_CLASS_NAME_BASE = 120
|
||||||
function allocateClassName(base: string, state: RenderState): string {
|
function allocateClassName(base: string, state: RenderState): string {
|
||||||
let name = base
|
const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base
|
||||||
for (let n = 2; state.usedClassNames.has(name); n++) name = `${base}${n}`
|
let name = capped
|
||||||
|
for (let n = 2; state.usedClassNames.has(name); n++) name = `${capped}${n}`
|
||||||
state.usedClassNames.add(name)
|
state.usedClassNames.add(name)
|
||||||
return name
|
return name
|
||||||
}
|
}
|
||||||
@@ -202,6 +213,12 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated })
|
||||||
const frames: Frame[] = [newFrame(schema, className, false)]
|
const frames: Frame[] = [newFrame(schema, className, false)]
|
||||||
let result: string | undefined
|
let result: string | undefined
|
||||||
|
// The no-throw contract must hold across the WHOLE walk, not just the root
|
||||||
|
// validation: a hostile stateful getter (a `type` that returns a scalar on
|
||||||
|
// the first read and throws on a later one) reaches the render phase past
|
||||||
|
// validation. Any throw here degrades to `Any`, discarding classes this call
|
||||||
|
// partially emitted so no broken declaration escapes.
|
||||||
|
const classFloor = state.classes.length
|
||||||
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
/* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
|
||||||
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
|
||||||
const finish = (type: string): void => {
|
const finish = (type: string): void => {
|
||||||
@@ -211,6 +228,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
else parent.childTypes.push(type)
|
else parent.childTypes.push(type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
while (frames.length > 0) {
|
while (frames.length > 0) {
|
||||||
const frame = frames.at(-1)
|
const frame = frames.at(-1)
|
||||||
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
/* v8 ignore next -- the loop condition guarantees a current frame. */
|
||||||
@@ -364,6 +382,13 @@ function renderType(schema: unknown, className: string, state: RenderState): str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// A render-phase throw (a stateful getter that passed validation) degrades
|
||||||
|
// the whole node to `Any`; drop any classes this call had begun emitting.
|
||||||
|
state.classes.length = classFloor
|
||||||
|
state.typing.add('Any')
|
||||||
|
return 'Any'
|
||||||
|
}
|
||||||
/* v8 ignore next -- every root frame produces one expression. */
|
/* v8 ignore next -- every root frame produces one expression. */
|
||||||
return result ?? 'Any'
|
return result ?? 'Any'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,68 @@ describe('jsonSchemaToPy', () => {
|
|||||||
expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
|
expect(jsonSchemaToPy({ type: 'string', enum: [] })).toBe('Any')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('degrades to Any when a stateful getter throws in the render phase after passing validation', () => {
|
||||||
|
// A hostile `type` getter returns a scalar on the validation read, then
|
||||||
|
// throws on the render read. The no-throw contract must still hold across
|
||||||
|
// the whole walk, degrading the node to Any rather than escaping.
|
||||||
|
let reads = 0
|
||||||
|
const schema = {
|
||||||
|
get type() {
|
||||||
|
reads += 1
|
||||||
|
if (reads <= 1) return 'string'
|
||||||
|
throw new Error('stateful getter')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(() => jsonSchemaToPy(schema)).not.toThrow()
|
||||||
|
expect(jsonSchemaToPy(schema)).toBe('Any')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rolls back partial class declarations when a nested render-phase throw degrades a tool', () => {
|
||||||
|
// The throwing field must not leave a half-emitted TypedDict in the output.
|
||||||
|
let reads = 0
|
||||||
|
const hostileField = {
|
||||||
|
get type() {
|
||||||
|
reads += 1
|
||||||
|
if (reads <= 1) return 'string'
|
||||||
|
throw new Error('stateful getter')
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const tool: ToolSdkSchema = {
|
||||||
|
name: 'hostile',
|
||||||
|
description: 'Has a field whose getter throws on the render read.',
|
||||||
|
parameters: { type: 'object', additionalProperties: false, properties: { bad: hostileField as never }, required: ['bad'] },
|
||||||
|
output: { type: 'string' },
|
||||||
|
}
|
||||||
|
const text = renderToolsSdkPy([tool])
|
||||||
|
// The whole args render degrades to Any (a render-phase throw unwinds the
|
||||||
|
// entire renderType call); no partial TypedDict for it is declared.
|
||||||
|
expect(text).toContain('async def hostile(self, args: Any) -> str: ...')
|
||||||
|
expect(text).not.toContain('class HostileArgs(TypedDict):')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps class names and total output linear for a deep single-field object chain', () => {
|
||||||
|
// Child class names derive from their parent's; without a cap the sum of
|
||||||
|
// names is Theta(depth^2). Bound it so a deep schema stays linear.
|
||||||
|
const depth = 4000
|
||||||
|
let schema: Record<string, unknown> = { type: 'string' }
|
||||||
|
for (let i = 0; i < depth; i++) {
|
||||||
|
schema = { type: 'object', additionalProperties: false, properties: { inner: schema }, required: ['inner'] }
|
||||||
|
}
|
||||||
|
const tool: ToolSdkSchema = {
|
||||||
|
name: 'deep',
|
||||||
|
description: 'Deeply nested single-field chain.',
|
||||||
|
parameters: schema,
|
||||||
|
output: { type: 'string' },
|
||||||
|
}
|
||||||
|
const text = renderToolsSdkPy([tool])
|
||||||
|
// No emitted class name exceeds the cap plus a short collision suffix, so
|
||||||
|
// total text is O(depth) rather than O(depth^2) (a quadratic 4000-deep
|
||||||
|
// chain would be tens of MB).
|
||||||
|
const longestClassName = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0)
|
||||||
|
expect(longestClassName).toBeLessThanOrEqual(140)
|
||||||
|
expect(text.length).toBeLessThan(depth * 400)
|
||||||
|
})
|
||||||
|
|
||||||
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
it('emits exact digits for a beyond-safe-range integer literal', () => {
|
||||||
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
// Python integers are arbitrary-precision, so the emitted digits ARE the
|
||||||
// value the model programs against. `String(2 ** 60)` prints the rounded
|
// value the model programs against. `String(2 ** 60)` prints the rounded
|
||||||
|
|||||||
Reference in New Issue
Block a user