diff --git a/packages/core/tools/src/py-types.ts b/packages/core/tools/src/py-types.ts index 487ea9ae8d..0c088e708c 100644 --- a/packages/core/tools/src/py-types.ts +++ b/packages/core/tools/src/py-types.ts @@ -136,9 +136,13 @@ const MAX_CLASS_NAME_BASE = 120 * nested parentheses`), so an array chain deeper than that would render an SDK * block that is not valid Python at all — the same failure the docstring * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the - * few brackets an annotation can add around the chain: `NotRequired[…]`, a - * `Literal[…]` item, and the `def` parameter list an argument annotation sits - * inside, for a worst case of 182. + * few brackets an annotation can add around the chain, all of which count + * toward the same limit: a `Literal[…]` item, plus exactly one of `NotRequired[…]` + * (a chain in a TypedDict field, whose class-body line has no other open + * bracket) or the `def` parameter list still open around a chain in a method's + * RETURN annotation — the two are mutually exclusive, so the worst case is 182. + * An argument annotation is always a bare TypedDict class name and opens + * nothing. * * A CPython grammar limit, not a deployment choice, so it is fixed rather than * configurable. The sibling `ts-types` renderer needs no counterpart: nothing @@ -557,10 +561,15 @@ export function renderToolsSdkPy(schemas: ToolSdkSchema[]): string { members.push(...doc) statements += 1 } else { - // Not a legal attribute name — the model reaches it via ``tools[name]``. - // The stub lists it as a subscript comment (referencing the named - // TypedDicts too) so a reader sees what is accessible; runtime resolution - // goes through the proxy's __getitem__. + // Not reachable as ``tools.name`` — the model reaches it via + // ``tools[name]``. Exotic names and hard keywords are not legal + // attributes at all; an underscore-leading name (``_foo``) IS a legal + // attribute and is routed here anyway, so one rule covers every + // underscore form rather than singling out the dunders that would + // name-mangle or resolve on ``object`` ahead of the proxy hook (see + // {@link RESERVED}). The stub lists it as a subscript comment + // (referencing the named TypedDicts too) so a reader sees what is + // accessible; runtime resolution goes through the proxy's __getitem__. members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`) const description = describe(schema) if (description !== undefined) members.push(`${pad(1)}# ${description}`) diff --git a/packages/core/tools/tests/py-types.spec.ts b/packages/core/tools/tests/py-types.spec.ts index 6dfbad9d0f..b60ee07e5e 100644 --- a/packages/core/tools/tests/py-types.spec.ts +++ b/packages/core/tools/tests/py-types.spec.ts @@ -465,7 +465,8 @@ describe('renderToolsSdkPy', () => { output: { type: 'string' }, } const text = renderToolsSdkPy([undescribedIdentifier, undescribedExotic]) - // Identifier method appears without a docstring line above it. + // Identifier method appears without a docstring in its body — hence the + // `: ...` stub, which a documented method replaces with the docstring. expect(text).toContain('async def plain(self, args: dict[str, Any]) -> str: ...') expect(text).not.toContain('"""') // Subscript entry appears without the "# ..." description follow-up. @@ -660,6 +661,27 @@ describe('renderToolsSdkPy', () => { expect(text).not.toContain('__debug__') }) + it('routes every underscore-leading tool name to subscript access', () => { + // `_foo` is a legal Python attribute, unlike an exotic name or a hard + // keyword, but the whole underscore family goes to `tools[name]` under one + // rule: `__meta__` resolves on `object` before the proxy's __getattr__ ever + // runs, and `__token` name-mangles at the CALL SITE inside the model's own + // class. `_foo` follows them so the rule needs no per-form exception. + const make = (name: string): ToolSdkSchema => ({ + name, + description: 'Leading underscore.', + parameters: parameterSchemaSpecToJsonSchema({}) as unknown as Record, + output: { type: 'string' }, + }) + const text = renderToolsSdkPy([make('_foo'), make('__meta__'), make('__token')]) + for (const name of ['_foo', '__meta__', '__token']) { + expect(text).toContain(`# tools[${JSON.stringify(name)}](args: dict[str, Any]) -> str`) + expect(text).not.toContain(`async def ${name}(`) + } + // No method emitted at all, so the class body needs the explicit `pass`. + expect(text).toContain(' pass\n') + }) + it('escapes quotes and backslashes in descriptions so the docstring stays valid Python', () => { // A description ending in `"` or an odd backslash would otherwise merge // with (or escape) the closing triple quote — and this block is Code