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 632cf62ec7..fb6dcecc95 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: 23794226c8e236421a79fb2143ccb09095f1a287 -2026-07-31-code-mode-language-dispatch.zh.md: d2f868215181a99814c19ca4817582e96b396807 +2026-07-31-code-mode-language-dispatch.md: 9f001b8fad8ca954d9b0c3cdca0e7be4d3b9ce61 +2026-07-31-code-mode-language-dispatch.zh.md: 525bb8a97e4e6d9e00334d5425acd1491a9b3fc7 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 23794226c8..9f001b8fad 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 @@ -25,6 +25,8 @@ Both tables are read with `Object.hasOwn` before use so a language named `toStri `py-types.ts` renders the same unified tool-schema vocabulary `jsonSchemaToTs` covers, targeting Python: `jsonSchemaToPy` emits a type expression per JSON-schema node, and `renderToolsSdkPy` assembles named `TypedDict`s for each visible tool's arguments and canonical output plus a `tools` object with usage instructions equivalent to the TypeScript flavor. Unsupported raw constructs degrade rather than throwing during assembly, matching the TypeScript renderer's contract. The output is deterministic — lexicographic tool order, byte-identical text for an unchanged tool set — so the prompt stays prefix-cache-friendly. +`renderType` validates the whole schema once (`assertSupportedJsonSchema`) and then trusts it, wrapping the walk in one `try/catch` that degrades to `Any` — the same trusted-after-validation stance the sibling `ts-types` renderer takes at this typed same-process seam ([Trust TypeScript at typed same-process seams](../../../../AGENTS.md)). It deliberately carries NO defenses against a schema whose accessors mutate between reads (post-validation cycles, TOCTOU on `const`/`enum`, self-referential functions): the input is a first-party `defineTool` object literal that already passed validation, so such inputs are unreachable, and adding per-shape guards here would break symmetry with `ts-types` (which has none) for values the static interface forbids. `jsonSchemaToPy(schema: unknown)` accepts `unknown` and returns `Any` on a malformed schema — the Python counterpart of the TS flavor's `unknown` — but its contract is "degrade an unsupported schema", not "survive an adversarial mutating one". + ## Alternatives considered - **A `language` config field on `ToolRegistry`.** Deployment would then have two places to name the language (the loaded runtime and the tools config) that can disagree; the loaded runtime is the single source of truth, so the registry reads it rather than duplicating it. 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 d2f8682151..525bb8a97e 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 @@ -25,6 +25,8 @@ Code Mode 只生成一种 SDK 形态:TypeScript。`ToolRegistry` 为 `tools:sd `py-types.ts` 渲染 `jsonSchemaToTs` 所覆盖的同一套统一工具 schema 词汇,目标为 Python:`jsonSchemaToPy` 为每个 JSON-schema 节点发出一个类型表达式,`renderToolsSdkPy` 为每个可见工具的参数与规范输出装配具名 `TypedDict`,再加一个带用法说明的 `tools` 对象,与 TypeScript 形态等价。不支持的原始构造在装配时降级而非抛错,与 TypeScript 渲染器的契约一致。输出是确定性的——工具按字典序排列,工具集不变时文本逐字节相同——因此 prompt 保持 prefix-cache 友好。 +`renderType` 先用 `assertSupportedJsonSchema` 整树校验一次、随后信任它,用单个 `try/catch` 把整个遍历兜住并降级为 `Any`——与姊妹渲染器 `ts-types` 在这个 typed 同进程 seam 上采取的"校验后信任"姿态一致([Trust TypeScript at typed same-process seams](../../../../AGENTS.md))。它有意不设任何针对"访问器在多次读取间变值"的防御(校验后成环、`const`/`enum` 的 TOCTOU、自引用函数):输入是已通过校验的第一方 `defineTool` 对象字面量,这类输入不可达,而在此加逐形态守卫会为静态接口所禁止的值破坏与 `ts-types`(没有这类守卫)的对称。`jsonSchemaToPy(schema: unknown)` 接受 `unknown` 并对畸形 schema 返回 `Any`——TypeScript 形态 `unknown` 的对应物——但它的契约是"降级不支持的 schema",而非"扛住对抗性的可变 schema"。 + ## Alternatives considered - **在 `ToolRegistry` 上加一个 `language` 配置字段。** 那样部署方就会有两处命名语言(所加载的运行时与 tools 配置)且可能相互矛盾;所加载的运行时是唯一真相来源,故注册表读取它而不复制它。 diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index b989d7fc55..ef5a122dc4 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -20,17 +20,6 @@ import type { ToolSdkSchema } from './ts-types.ts' /** Property names that are valid bare Python identifiers; anything else is subscripted. */ const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ -/** - * Whether a schema value carries a trackable reference identity for the render - * walk's cycle detection. Both plain objects AND functions qualify: a function - * has `typeof 'function'` yet can carry own properties (`oneOf`, `items`) and - * reference itself, so a post-validation getter returning a self-referential - * function would otherwise bypass the object-only guard and loop forever. - */ -function hasIdentity(value: unknown): value is object { - return (typeof value === 'object' && value !== null) || typeof value === 'function' -} - /** * Python hard keywords: reserved everywhere, so a tool or field named * ``class`` or ``lambda`` is legal on the wire but not as an attribute @@ -67,8 +56,8 @@ function pad(indent: number): string { /** * Collector threaded through {@link renderType}: the emitted `TypedDict` class * declarations (nested classes precede the parent that references them), the - * class names already taken (for collision suffixing), and the `typing` - * symbols the render actually used. + * class names already taken (for collision suffixing), a per-base collision + * counter, and the `typing` symbols the render actually used. */ interface RenderState { readonly classes: string[] @@ -161,11 +150,10 @@ function allocateClassName(base: string, state: RenderState): string { } /** - * Render one validated scalar as Python literal text (`True`/`False`, `None`, - * JSON-quoted strings, bare numbers). A validated `const`/`enum` never carries - * a bare `null` on a non-`null` scalar type, but a post-validation stateful - * getter can re-read one as `null`, so `null` is spelled `None` rather than the - * JS `String(null)` = `"null"`. + * Render one validated scalar as Python literal text (`True`/`False`, + * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` + * type renders directly as `None`, and the unified validator rejects a null + * `const`/`enum` entry on every other scalar type. * * A beyond-safe-range integral number takes `BigInt` digits rather than * `String`: Python integers are arbitrary-precision, so the emitted digits ARE @@ -180,7 +168,6 @@ function allocateClassName(base: string, state: RenderState): string { function pyScalar(value: JsonSchemaScalar): string { if (value === true) return 'True' if (value === false) return 'False' - if (value === null) return 'None' if (typeof value === 'string') return JSON.stringify(value) if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) { return BigInt(value).toString() @@ -188,11 +175,6 @@ function pyScalar(value: JsonSchemaScalar): string { return String(value) } -/** Whether a value is a JSON scalar `Literal[...]` can spell (a re-read getter may return anything). */ -function isPyScalar(value: unknown): value is JsonSchemaScalar { - return value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string' -} - /** * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to * the broad type. Deliberately deviates from PEP 586, which restricts `Literal` @@ -203,24 +185,12 @@ function isPyScalar(value: unknown): value is JsonSchemaScalar { */ function renderConstrainedScalar(node: Record, broad: string, state: RenderState): string { if (Object.hasOwn(node, 'const')) { - // Snapshot the value with ONE read: a stateful getter can return different - // values across reads, so a separate check-read and spell-read could still - // pass the check and then spell a non-scalar (`Literal[[object Object]]`). - const value = node.const - if (!isPyScalar(value)) return broad state.typing.add('Literal') - return `Literal[${pyScalar(value)}]` + return `Literal[${pyScalar(node.const as JsonSchemaScalar)}]` } if (Object.hasOwn(node, 'enum')) { - const raw = node.enum - // `[...raw]` reads each element exactly once (elements may be accessor - // properties that change between reads); then check and spell that - // snapshot. Require non-empty: an emptied re-read would spell `Literal[]`, - // a Python SyntaxError that breaks the whole SDK. - const values: unknown[] | undefined = Array.isArray(raw) ? [...(raw as unknown[])] : undefined - if (values === undefined || values.length === 0 || !values.every(isPyScalar)) return broad state.typing.add('Literal') - return `Literal[${values.map(pyScalar).join(', ')}]` + return `Literal[${(node.enum as JsonSchemaScalar[]).map(pyScalar).join(', ')}]` } return broad } @@ -231,9 +201,10 @@ function renderConstrainedScalar(node: Record, broad: string, s * needs. `className` is the name to give an object node with properties (and * the prefix for its nested objects). Handles every unified schema construct — * `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`), - * `null` (→ `None`) — and degrades malformed or unsupported inputs to `Any` - * without throwing. {@link jsonSchemaToPy} is the context-free entry point; - * this is the collecting core. + * `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any` + * without throwing, the same trusted-after-validation stance as the sibling + * {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the + * context-free entry point; this is the collecting core. */ function renderType(schema: unknown, className: string, state: RenderState): string { interface Frame { @@ -247,46 +218,28 @@ function renderType(schema: unknown, className: string, state: RenderState): str childTypes: string[] entries: [string, unknown][] allocated?: string - validated: boolean } - const newFrame = (schema: unknown, className: string, validated: boolean): Frame => - ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [], validated }) - const frames: Frame[] = [newFrame(schema, className, false)] - // Ancestor schemas by reference identity — the frame stack IS the DFS path, - // so this set holds exactly the current node's ancestors. A stateful getter - // can mutate the graph after validation (an `items`/property that validated - // as a scalar but returns an ancestor at render time); without this, the walk - // would push frames forever. A repeated ancestor degrades to `Any` per the - // never-throw contract. Distinct nodes in a legitimately deep chain are all - // different references, so this stays O(1) per push and O(depth) memory. - // Both objects and functions are tracked (see {@link hasIdentity}). Out of - // scope: a getter fabricating a FRESH node per read never repeats an ancestor - // and is locally indistinguishable from a legitimately unbounded-depth schema - // (which this module supports), so cycle detection is the reachable best - // defense rather than a depth cap that would break the legitimate case. - const activeSchemas = new Set() - if (hasIdentity(schema)) activeSchemas.add(schema) - 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 - const typingFloor = new Set(state.typing) - /* 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 => { - const popped = frames.pop() - if (popped !== undefined && hasIdentity(popped.schema)) { - activeSchemas.delete(popped.schema) - } - const parent = frames.at(-1) - if (parent === undefined) result = type - else parent.childTypes.push(type) - } - + const newFrame = (schema: unknown, className: string): Frame => + ({ schema, className, phase: 'start', children: [], childIndex: 0, childTypes: [], entries: [] }) try { + // Validate the WHOLE tree once, then trust it — the same contract the + // sibling ts-types renderer follows at a typed same-process seam. Every + // node past this point is a validated JSON-schema node, so the walk reads + // its fields without re-checking. An unsupported or malformed schema throws + // here (before anything is emitted) and degrades to `Any`, the Python + // counterpart of the TS flavor's `unknown`. + assertSupportedJsonSchema(schema) + const frames: Frame[] = [newFrame(schema, className)] + let result: string | undefined + /* 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 => { + frames.pop() + const parent = frames.at(-1) + if (parent === undefined) result = type + else parent.childTypes.push(type) + } + while (frames.length > 0) { const frame = frames.at(-1) /* v8 ignore next -- the loop condition guarantees a current frame. */ @@ -298,19 +251,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str /* v8 ignore next -- childIndex is bounded by children.length. */ if (child === undefined) throw new Error('missing python render child') frame.childIndex++ - // A child schema already on the active path is a cycle a post- - // validation mutation introduced; degrade it to `Any` rather than - // recurse forever. A fresh reference joins the path (finish removes - // it); a value with no reference identity carries none to track. - if (hasIdentity(child.schema)) { - if (activeSchemas.has(child.schema)) { - state.typing.add('Any') - frame.childTypes.push('Any') - continue - } - activeSchemas.add(child.schema) - } - frames.push(newFrame(child.schema, child.className, true)) + frames.push(newFrame(child.schema, child.className)) continue } if (frame.kind === 'oneOf') { @@ -319,9 +260,9 @@ function renderType(schema: unknown, className: string, state: RenderState): str } /* 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. */ + // `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'}]`) continue } @@ -366,31 +307,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str } 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 - } - } const node = frame.schema as Record if (Object.hasOwn(node, 'oneOf')) { - // Snapshot the branches with ONE read (a getter can change them - // between reads). A re-read that is not a non-empty array would join to - // `''` (or drop branches), so degrade to `Any` instead. - const branches = node.oneOf - if (!Array.isArray(branches) || branches.length === 0) { - state.typing.add('Any') - finish('Any') - continue - } frame.kind = 'oneOf' - frame.children = (branches as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = (node.oneOf as unknown[]).map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) continue } if (!Object.hasOwn(node, 'type')) { @@ -416,13 +336,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str break } 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 - // `properties` (degraded to `Any` above), so the only non-map case - // 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]`. + // A missing `properties` is an empty property map, exactly as the + // unified validator and the TS renderer read it — NOT an unknown + // shape. 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 @@ -461,25 +379,16 @@ function renderType(schema: unknown, className: string, state: RenderState): str } } } + /* v8 ignore next -- every root frame produces one expression. */ + return result ?? 'Any' } catch { - // Reached by a render-phase throw the root validation could not catch: - // either a hostile stateful getter (a `type` that passes validation then - // throws on a later read) OR one of this module's own v8-ignored internal - // invariant errors (`missing python render child` etc.). Both degrade the - // whole node to `Any` — an internal renderer bug thus surfaces as a lost - // type rather than a loud crash during prompt assembly, the deliberate - // trade for the never-throw contract. Roll back the classes and typing - // symbols the discarded subtree added so the import line still lists - // exactly the symbols the surviving output uses; `usedClassNames`/counter - // retention is harmless (conservative uniqueness). - state.classes.length = classFloor - state.typing.clear() - for (const symbol of typingFloor) state.typing.add(symbol) + // An unsupported or malformed schema failed validation (before any + // emission), or an unreachable internal invariant tripped. Either degrades + // the node to `Any` rather than crashing prompt assembly — the Python + // counterpart of the TS flavor's `unknown` fallback. state.typing.add('Any') return 'Any' } - /* v8 ignore next -- every root frame produces one expression. */ - return result ?? 'Any' } /** @@ -488,10 +397,10 @@ function renderType(schema: unknown, className: string, state: RenderState): str * to `dict[str, Any]`: naming a `TypedDict` requires the render context that * {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`), * `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array` - * (`items` → `list[T]`) — and returns `Any` for anything else, without - * throwing. Type annotations in the emitted SDK are advisory: Python does not - * enforce them at runtime, matching the TS flavor's advisory-type stance. - * @param schema - the JSON-Schema node (any shape; hostile inputs degrade). + * (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed + * schema, matching the TS flavor's `unknown` fallback. Type annotations in the + * emitted SDK are advisory: Python does not enforce them at runtime. + * @param schema - the JSON-Schema node. * @returns the Python type text. */ export function jsonSchemaToPy(schema: unknown): string { diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 5bb10573be..89db13d852 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -51,285 +51,6 @@ 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. Assert - // the FIRST call's result: within it, root validation reads `type` once - // and the render phase reads it again (the throw), so this exercises the - // render-phase catch, not the validation-catch path. - let reads = 0 - const schema = { - get type() { - reads += 1 - if (reads <= 1) return 'string' - throw new Error('stateful getter') - }, - } - let first: string | undefined - expect(() => { first = jsonSchemaToPy(schema) }).not.toThrow() - expect(first).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):') - // The import line lists only symbols the surviving output uses: the - // discarded subtree's TypedDict/NotRequired must not leak into it. - expect(text).not.toContain('TypedDict') - expect(text).toContain('from typing import Any, Protocol') - }) - - 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('skips an already-taken counter suffix when a sibling object occupies it', () => { - // `phase` and `Phase` both CamelCase to the base `FooArgsPhase`; `phase2` - // independently allocates `FooArgsPhase2` first. When `Phase` collides, the - // counter's first candidate `FooArgsPhase2` is already taken, so the scan - // must advance to `FooArgsPhase3` (exercises the collision-skip loop). - const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } }) - const tool: ToolSdkSchema = { - name: 'foo', - description: 'Sibling objects with colliding class bases.', - parameters: { - type: 'object', - additionalProperties: false, - properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') }, - required: ['phase', 'phase2', 'Phase'], - }, - output: { type: 'string' }, - } - const text = renderToolsSdkPy([tool]) - expect(text).toContain('class FooArgsPhase(TypedDict):') - expect(text).toContain('class FooArgsPhase2(TypedDict):') - expect(text).toContain('class FooArgsPhase3(TypedDict):') - }) - - it('degrades to Any instead of looping when a stateful getter introduces a cycle after validation', () => { - // `items` validates as a scalar, then returns the root schema at render - // time — a cycle a post-validation mutation introduced. The walk must - // degrade to Any rather than push frames forever. - let itemReads = 0 - const root: Record = { type: 'array' } - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : root - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - // list[...] of a self-cycle: the inner cycle degrades to Any. - expect(out).toBe('list[Any]') - }) - - it('degrades to Any when a stateful getter returns a non-object child at render time', () => { - // `items` validates as a scalar node, then returns a bare string (a - // non-object) at render. The walk must handle a non-object child without - // tracking identity and degrade it, not throw. - let itemReads = 0 - const root: Record = { type: 'array' } - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : 'not-a-schema-object' - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - expect(out).toBe('list[Any]') - }) - - it('degrades to Any when a stateful getter returns a self-referential function as a child', () => { - // A function has typeof 'function' yet can carry own props and reference - // itself; the cycle guard must track it too, or the walk loops forever. - let itemReads = 0 - const root: Record = { type: 'array' } - const fn = Object.assign(function () {}, {}) as Record & (() => void) - ;(fn as Record).oneOf = [fn] - Object.defineProperty(root, 'items', { - enumerable: true, - get() { - itemReads += 1 - return itemReads <= 1 ? { type: 'string' } : fn - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(root) }).not.toThrow() - expect(out).toBe('list[Any]') - }) - - it('degrades a const that snapshots as a non-scalar to the broad type', () => { - // The single snapshot read returns an object (validation read returned a - // scalar); the check must degrade rather than spell Literal[[object Object]]. - let reads = 0 - const schema: Record = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? 'fixed' : {} - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('object Object') - }) - - it('snapshots const with one read so a third-read switch cannot spell a non-scalar', () => { - // A getter returning 'fixed' on the validation AND check reads but an - // object on a third read would defeat a separate check-read/spell-read. - // The render snapshots once, so it either spells the checked value or - // degrades — never Literal[[object Object]]. - let reads = 0 - const schema: Record = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 2 ? 'fixed' : {} - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out === 'str' || out === 'Literal["fixed"]').toBe(true) - expect(out).not.toContain('object Object') - }) - - it('degrades to the broad type when an enum getter re-reads as a non-array', () => { - // A validated enum array that re-reads as a non-array must degrade, not - // spread a non-iterable or spell a bad literal. - let reads = 0 - const schema: Record = { type: 'string' } - Object.defineProperty(schema, 'enum', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? ['a'] : 'not-an-array' - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - }) - - it('degrades to the broad type when an enum getter re-reads as an empty array', () => { - // A validated non-empty enum that re-reads as [] would spell Literal[] — a - // Python SyntaxError that breaks the whole SDK. Require non-empty at render. - let reads = 0 - const schema: Record = { type: 'string' } - Object.defineProperty(schema, 'enum', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? ['a'] : [] - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('Literal[]') - }) - - it('degrades the broad type when an enum element is an accessor that re-reads as a non-scalar', () => { - // `[...raw]` reads each element exactly once; the validation read saw a - // scalar, the spread read returns an object. The snapshot's every(isPyScalar) - // check must degrade rather than spell Literal[[object Object]]. - let elemReads = 0 - const arr: unknown[] = [] - Object.defineProperty(arr, '0', { - enumerable: true, - configurable: true, - get() { - elemReads += 1 - return elemReads <= 1 ? 'a' : {} - }, - }) - arr.length = 1 - const schema = { type: 'string', enum: arr } - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('str') - expect(out).not.toContain('object Object') - }) - - it('spells a const re-read as null with None, not the JS string "null"', () => { - let reads = 0 - const schema: Record = { type: 'string' } - Object.defineProperty(schema, 'const', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? 'fixed' : null - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - // Either the checked value spells, or a null re-read spells None — never "null". - expect(out === 'Literal["fixed"]' || out === 'Literal[None]').toBe(true) - expect(out).not.toContain('Literal[null]') - }) - - it('degrades a oneOf that re-reads as an empty array to Any, not an empty string', () => { - // oneOf validates as two branches, then returns [] at render; a naive join - // would produce '' (a missing type). Degrade to Any instead. - let reads = 0 - const schema: Record = {} - Object.defineProperty(schema, 'oneOf', { - enumerable: true, - get() { - reads += 1 - return reads <= 1 ? [{ type: 'string' }, { type: 'number' }] : [] - }, - }) - let out: string | undefined - expect(() => { out = jsonSchemaToPy(schema) }).not.toThrow() - expect(out).toBe('Any') - expect(out).not.toBe('') - }) - 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 @@ -549,6 +270,43 @@ describe('renderToolsSdkPy', () => { expect(text).toContain('class MyToolArgs2(TypedDict):') }) + it('caps class-name length so a deep single-field chain stays linear', () => { + // Child class names derive from their parent's, so without a cap the sum of + // names would be Theta(depth^2). MAX_CLASS_NAME_BASE (120) bounds each name. + 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: 'Deep chain.', parameters: schema, output: { type: 'string' } } + const text = renderToolsSdkPy([tool]) + 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('skips an already-taken counter suffix when a sibling object occupies it', () => { + // `phase` and `Phase` both CamelCase to base `FooArgsPhase`; `phase2` + // independently takes `FooArgsPhase2`, so `Phase`'s collision scan must + // advance to `FooArgsPhase3` (exercises the collision-skip loop). + const obj = (field: string) => ({ type: 'object' as const, additionalProperties: false, properties: { [field]: { type: 'string' } } }) + const tool: ToolSdkSchema = { + name: 'foo', + description: 'Sibling objects with colliding class bases.', + parameters: { + type: 'object', + additionalProperties: false, + properties: { phase: obj('a'), phase2: obj('b'), Phase: obj('c') }, + required: ['phase', 'phase2', 'Phase'], + }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + expect(text).toContain('class FooArgsPhase(TypedDict):') + expect(text).toContain('class FooArgsPhase2(TypedDict):') + expect(text).toContain('class FooArgsPhase3(TypedDict):') + }) + it('references the named TypedDict from a reserved/subscript tool too', () => { const tool: ToolSdkSchema = { name: 'class',