From d7b4b014eba1f0f692a03e3660ca3aba44f581cc Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Sun, 2 Aug 2026 14:31:29 +0800 Subject: [PATCH] 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. --- ...7-31-code-mode-language-dispatch.i18n.yaml | 4 +- .../2026-07-31-code-mode-language-dispatch.md | 2 +- ...26-07-31-code-mode-language-dispatch.zh.md | 4 +- packages/core/tools/src/py-types.ts | 277 ++++++++++-------- packages/core/tools/tests/py-types.spec.ts | 62 ++++ 5 files changed, 218 insertions(+), 131 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml index 6d43a50a7b..632cf62ec7 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.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 .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.zh.md: 889168698215560da1d15799f814d21cff25acf7 +2026-07-31-code-mode-language-dispatch.md: 23794226c8e236421a79fb2143ccb09095f1a287 +2026-07-31-code-mode-language-dispatch.zh.md: d2f868215181a99814c19ca4817582e96b396807 diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md index c5643485f5..23794226c8 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.md @@ -33,4 +33,4 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri ## 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. diff --git a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md index 8891686982..d2f8682151 100644 --- a/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-code-mode-language-dispatch.zh.md @@ -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` 运行时,并列出已知语言。 - `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 上。 @@ -33,4 +33,4 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd ## 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 运行。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 697df8ae8d..e6c20d1027 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -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 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 { - let name = base - for (let n = 2; state.usedClassNames.has(name); n++) name = `${base}${n}` + const capped = base.length > MAX_CLASS_NAME_BASE ? base.slice(0, MAX_CLASS_NAME_BASE) : base + let name = capped + for (let n = 2; state.usedClassNames.has(name); n++) name = `${capped}${n}` state.usedClassNames.add(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 }) const frames: Frame[] = [newFrame(schema, className, false)] 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 ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */ const finish = (type: string): void => { @@ -211,114 +228,115 @@ function renderType(schema: unknown, className: string, state: RenderState): str else parent.childTypes.push(type) } - while (frames.length > 0) { - const frame = frames.at(-1) - /* v8 ignore next -- the loop condition guarantees a current frame. */ - if (frame === undefined) break + try { + while (frames.length > 0) { + const frame = frames.at(-1) + /* v8 ignore next -- the loop condition guarantees a current frame. */ + if (frame === undefined) break - if (frame.phase === 'children') { - if (frame.childIndex < frame.children.length) { - const child = frame.children[frame.childIndex] - /* v8 ignore next -- childIndex is bounded by children.length. */ - if (child === undefined) throw new Error('missing python render child') - frame.childIndex++ - frames.push(newFrame(child.schema, child.className, true)) - continue - } - if (frame.kind === 'oneOf') { - finish(frame.childTypes.join(' | ')) - continue - } - /* jscpd:ignore-end */ - if (frame.kind === 'array') { + if (frame.phase === 'children') { + if (frame.childIndex < frame.children.length) { + const child = frame.children[frame.childIndex] + /* v8 ignore next -- childIndex is bounded by children.length. */ + if (child === undefined) throw new Error('missing python render child') + frame.childIndex++ + frames.push(newFrame(child.schema, child.className, true)) + continue + } + if (frame.kind === 'oneOf') { + finish(frame.childTypes.join(' | ')) + continue + } + /* jscpd:ignore-end */ + if (frame.kind === 'array') { // `list[A | B]` needs no parentheses in Python. Array frames always // schedule exactly one child, so its type is present. /* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */ - finish(`list[${frame.childTypes[0] ?? 'Any'}]`) + finish(`list[${frame.childTypes[0] ?? 'Any'}]`) + continue + } + // typeddict: assemble AFTER the children so any nested class this one + // references is already declared (declaration order = reference order). + const node = frame.node + const name = frame.allocated + /* v8 ignore next -- typeddict frames always set node and allocated at start. */ + if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') + const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) + const lines = [`class ${name}(TypedDict):`] + for (let index = 0; index < frame.entries.length; index++) { + const entry = frame.entries[index] + const fieldType = frame.childTypes[index] + /* v8 ignore next -- entries and childTypes correspond one-to-one. */ + if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') + const [field, fieldSchema] = entry + // The parent node passed assertSupportedJsonSchema, so every property + // value is a validated schema node (an object). + const description = describe(fieldSchema as object) + if (description !== undefined) lines.push(`${pad(1)}# ${description}`) + if (required.has(field)) { + lines.push(`${pad(1)}${field}: ${fieldType}`) + } else { + state.typing.add('NotRequired') + lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`) + } + } + // TypedDict syntax cannot express openness, so an open object states it + // in-band: the annotation is advisory either way, and Code Mode omits + // the native schemas, making this line the model's only signal that + // extra keys are accepted. + if (node.additionalProperties !== false) { + lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`) + } + // A closed empty object still needs a class body (`pass`) to be valid + // Python; the declared emptiness is the information. + if (lines.length === 1) lines.push(`${pad(1)}pass`) + state.classes.push(lines.join('\n')) + finish(name) continue } - // typeddict: assemble AFTER the children so any nested class this one - // references is already declared (declaration order = reference order). - const node = frame.node - const name = frame.allocated - /* v8 ignore next -- typeddict frames always set node and allocated at start. */ - if (node === undefined || name === undefined) throw new Error('missing typeddict frame state') - const required = new Set(Array.isArray(node.required) ? node.required.filter((n): n is string => typeof n === 'string') : []) - const lines = [`class ${name}(TypedDict):`] - for (let index = 0; index < frame.entries.length; index++) { - const entry = frame.entries[index] - const fieldType = frame.childTypes[index] - /* v8 ignore next -- entries and childTypes correspond one-to-one. */ - if (entry === undefined || fieldType === undefined) throw new Error('missing typeddict field type') - const [field, fieldSchema] = entry - // The parent node passed assertSupportedJsonSchema, so every property - // value is a validated schema node (an object). - const description = describe(fieldSchema as object) - if (description !== undefined) lines.push(`${pad(1)}# ${description}`) - if (required.has(field)) { - lines.push(`${pad(1)}${field}: ${fieldType}`) - } else { - state.typing.add('NotRequired') - lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`) + + frame.phase = 'children' + // Validate the WHOLE tree once at the root frame (the assertion walks it + // with an explicit stack); child frames are inside that validated tree, so + // re-asserting them would make a deep schema quadratic. + if (!frame.validated) { + try { + assertSupportedJsonSchema(frame.schema) + } catch { + state.typing.add('Any') + finish('Any') + continue } } - // TypedDict syntax cannot express openness, so an open object states it - // in-band: the annotation is advisory either way, and Code Mode omits - // the native schemas, making this line the model's only signal that - // extra keys are accepted. - if (node.additionalProperties !== false) { - lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`) + const node = frame.schema as Record + if (Object.hasOwn(node, 'oneOf')) { + frame.kind = 'oneOf' + frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + continue } - // A closed empty object still needs a class body (`pass`) to be valid - // Python; the declared emptiness is the information. - if (lines.length === 1) lines.push(`${pad(1)}pass`) - state.classes.push(lines.join('\n')) - finish(name) - continue - } - - frame.phase = 'children' - // Validate the WHOLE tree once at the root frame (the assertion walks it - // with an explicit stack); child frames are inside that validated tree, so - // re-asserting them would make a deep schema quadratic. - if (!frame.validated) { - try { - assertSupportedJsonSchema(frame.schema) - } catch { + if (!Object.hasOwn(node, 'type')) { state.typing.add('Any') finish('Any') continue } - } - const node = frame.schema as Record - if (Object.hasOwn(node, 'oneOf')) { - frame.kind = 'oneOf' - frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) - continue - } - if (!Object.hasOwn(node, 'type')) { - state.typing.add('Any') - finish('Any') - continue - } - switch (node.type) { - case 'string': finish(renderConstrainedScalar(node, 'str', state)); break - case 'number': finish(renderConstrainedScalar(node, 'float', state)); break - case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break - case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break - case 'null': finish('None'); break - case 'array': { - if (!Object.hasOwn(node, 'items')) { - state.typing.add('Any') - finish('list[Any]') + switch (node.type) { + case 'string': finish(renderConstrainedScalar(node, 'str', state)); break + case 'number': finish(renderConstrainedScalar(node, 'float', state)); break + case 'integer': finish(renderConstrainedScalar(node, 'int', state)); break + case 'boolean': finish(renderConstrainedScalar(node, 'bool', state)); break + case 'null': finish('None'); break + case 'array': { + if (!Object.hasOwn(node, 'items')) { + state.typing.add('Any') + finish('list[Any]') + break + } + // An array of objects names its item type after the array field. + frame.kind = 'array' + frame.children = [{ schema: node.items, className: frame.className }] break } - // An array of objects names its item type after the array field. - frame.kind = 'array' - frame.children = [{ schema: node.items, className: frame.className }] - break - } - case 'object': { + case 'object': { // A missing `properties` is an empty property map, exactly as the // unified validator and the TS renderer read it — NOT an unknown // shape. assertSupportedJsonSchema already rejected a non-object @@ -326,43 +344,50 @@ function renderType(schema: unknown, className: string, state: RenderState): str // left is omission. The openness of the resulting empty object is // decided below, so a closed empty object still declares an empty // TypedDict rather than a permissive `dict[str, Any]`. - const entries = Object.entries((node.properties ?? {}) as Record) - // An empty `className` marks the context-free `jsonSchemaToPy` entry: - // there is no naming context to declare into, so degrade. A field - // name that is not a legal Python attribute is inexpressible as a - // class-syntax `TypedDict` field, so such an object degrades whole. - // A leading-double-underscore non-dunder field (`__token`) would be - // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a - // different JSON key than the registered schema — degrade like any - // other inexpressible field name. - if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { - state.typing.add('Any') - finish('dict[str, Any]') + const entries = Object.entries((node.properties ?? {}) as Record) + // An empty `className` marks the context-free `jsonSchemaToPy` entry: + // there is no naming context to declare into, so degrade. A field + // name that is not a legal Python attribute is inexpressible as a + // class-syntax `TypedDict` field, so such an object degrades whole. + // A leading-double-underscore non-dunder field (`__token`) would be + // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a + // different JSON key than the registered schema — degrade like any + // other inexpressible field name. + if (className === '' || !entries.every(([name]) => IDENTIFIER.test(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) { + state.typing.add('Any') + finish('dict[str, Any]') + break + } + // An OPEN empty object is any dict; a CLOSED empty object declares an + // empty TypedDict so "no keys accepted" survives into the SDK. + if (entries.length === 0 && node.additionalProperties !== false) { + state.typing.add('Any') + finish('dict[str, Any]') + break + } + frame.kind = 'typeddict' + frame.node = node + frame.allocated = allocateClassName(frame.className, state) + state.typing.add('TypedDict') + frame.entries = entries + // frame.allocated was assigned two statements up; the ?? arm is for the type system only. + /* v8 ignore next -- allocated is always set before children are built. */ + frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` })) break } - // An OPEN empty object is any dict; a CLOSED empty object declares an - // empty TypedDict so "no keys accepted" survives into the SDK. - if (entries.length === 0 && node.additionalProperties !== false) { + /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ + default: { state.typing.add('Any') - finish('dict[str, Any]') - break + finish('Any') } - frame.kind = 'typeddict' - frame.node = node - frame.allocated = allocateClassName(frame.className, state) - state.typing.add('TypedDict') - frame.entries = entries - // frame.allocated was assigned two statements up; the ?? arm is for the type system only. - /* v8 ignore next -- allocated is always set before children are built. */ - frame.children = entries.map(([field, child]) => ({ schema: child, className: `${frame.allocated ?? ''}${camelCase(field)}` })) - break - } - /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ - default: { - state.typing.add('Any') - finish('Any') } } + } 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. */ return result ?? 'Any' diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 80ae2c2084..4131cbd571 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -51,6 +51,68 @@ describe('jsonSchemaToPy', () => { 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 = { 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', () => { // Python integers are arbitrary-precision, so the emitted digits ARE the // value the model programs against. `String(2 ** 60)` prints the rounded