diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 6e69541fb9..39d9644420 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -149,6 +149,19 @@ function allocateClassName(base: string, state: RenderState): string { return name } +/** + * Append a child-name segment to a parent class-name base, capping the result + * at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside + * {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or + * object-chain would otherwise carry an ever-growing ConsString down the tree + * and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²). + * The bounded base plus the collision counter still yields unique names. + */ +function childClassName(base: string, segment: string): string { + const joined = `${base}${segment}` + return joined.length > MAX_CLASS_NAME_BASE ? joined.slice(0, MAX_CLASS_NAME_BASE) : joined +} + /** * Render one validated scalar as Python literal text (`True`/`False`, * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null` @@ -259,12 +272,12 @@ function renderType(schema: unknown, className: string, state: RenderState): str continue } if (frame.kind === 'oneOf') { - // Concatenate with `+` (not `Array.join`): V8 builds a lazy - // ConsString, so a deep oneOf chain materializes once at the root - // instead of re-materializing the accumulated string at every level - // (which `join` would, making it Θ(depth²)). This matches the array - // arm's template-literal laziness and ts-types' composable-document - // approach — the whole walk stays linear in schema depth. + // Concatenate incrementally (template literal, not `Array.join`): V8 + // builds a lazy ConsString, so a deep oneOf chain materializes once + // at the root instead of re-materializing the accumulated string at + // every level (which `join` would, making it Θ(depth²)). This matches + // the array arm's template-literal laziness and ts-types' composable- + // document approach — the whole walk stays linear in schema depth. let union = '' for (const [index, childType] of frame.childTypes.entries()) { union = index === 0 ? childType : `${union} | ${childType}` @@ -324,7 +337,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str const node = frame.schema if (node.oneOf !== undefined) { frame.kind = 'oneOf' - frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: `${frame.className}${index + 1}` })) + frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`) })) continue } if (node.type === undefined) { @@ -383,7 +396,7 @@ function renderType(schema: unknown, className: string, state: RenderState): str 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)}` })) + frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)) })) break } /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */ diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index aa0bc30cff..3cea474e8d 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -471,18 +471,54 @@ describe('renderToolsSdkPy', () => { it('renders a deeply nested oneOf chain in linear time (no per-level re-materialization)', () => { // Each level is a two-branch oneOf whose first branch recurses; joining the - // accumulated union string at every level would be Theta(depth^2). The `+` - // (ConsString) concatenation keeps it linear, like the array arm. - const depth = 20000 + // accumulated union string at every level would be Theta(depth^2). At this + // depth the quadratic path (~100,000^2 char copies) blows past vitest's 5s + // default, so this fails loud on a regression; the `+`/ConsString path is + // milliseconds. (Guard the depth explicitly so the assertions stay exact.) + const depth = 100000 let deep: Record = { type: 'string' } for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] } const type = jsonSchemaToPy(deep) - // depth levels of ` | None` appended to the innermost `str`. expect(type.startsWith('str | None')).toBe(true) expect(type.endsWith(' | None')).toBe(true) expect(type.length).toBe('str'.length + ' | None'.length * depth) }) + it('names a deep oneOf-of-object chain in linear time (bounded propagated class names)', () => { + // Every level is a oneOf whose first branch is a closed empty object (a + // named TypedDict) and recurses. Propagating the full ancestor path as the + // class name and slicing it in allocateClassName at every level would be + // Theta(depth^2); childClassName caps the propagated base so it stays + // linear. The quadratic path at this depth exceeds the 5s default. + const depth = 60000 + let deep: Record = { type: 'object', additionalProperties: false, properties: {} } + for (let i = 0; i < depth; i++) { + deep = { oneOf: [deep, { type: 'null' }] } + } + const tool: ToolSdkSchema = { name: 'deep', description: 'Deep oneOf-object chain.', parameters: { type: 'object', additionalProperties: false, properties: { root: deep }, required: ['root'] }, output: { type: 'string' } } + const text = renderToolsSdkPy([tool]) + // No emitted class name exceeds the cap (plus a short collision suffix). + const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longest).toBeLessThanOrEqual(140) + expect(text).toContain('class Tools(Protocol):') + }) + + it('caps the class name for a tool whose name exceeds the base length limit', () => { + // The root class base is `${CamelCase(name)}Args`; a very long tool name + // makes it exceed MAX_CLASS_NAME_BASE, so allocateClassName caps it. + const longName = `x_${'a'.repeat(200)}` + const tool: ToolSdkSchema = { + name: longName, + description: 'Long name.', + parameters: { type: 'object', additionalProperties: false, properties: { f: { type: 'string' } }, required: ['f'] }, + output: { type: 'string' }, + } + const text = renderToolsSdkPy([tool]) + const longest = [...text.matchAll(/^class (\w+)\(TypedDict\):/gm)].reduce((max, m) => Math.max(max, m[1]?.length ?? 0), 0) + expect(longest).toBeLessThanOrEqual(140) + expect(text).toContain('class Tools(Protocol):') + }) + it('emits pass for a subscript-only tool set (comments are not statements)', () => { const t: ToolSdkSchema = { name: 'my-exotic.tool',