docs(tools): scope the Python SDK validity standard to the grammar

The list-nesting cap guards against a tokenizer SyntaxError, which makes the
text not Python. A long `A | B | …` union is valid at any length and only
defeats CPython's compile-time C recursion (measured: 1,000 branches compile,
5,000 raise RecursionError); nothing compiles this block, and capping would
retire the deep-chain tests pinning the walk's linear time. Records that
boundary at the `oneOf` arm and in the Agent Note (both languages).

Also documents that the context-free degrade marker reads the call's
className rather than the frame's — frames propagate a derived name, so a
per-frame read would declare classes the caller cannot receive — and pins
that path with oneOf-of-objects and array-of-oneOf assertions.
This commit is contained in:
Chinesezjc
2026-08-05 15:59:15 +08:00
parent 0d17baae01
commit cc6e4d59fc
5 changed files with 38 additions and 5 deletions

View File

@@ -136,14 +136,18 @@ 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
* one bracket an annotation can add around the chain (`NotRequired[…]`).
* 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.
*
* A CPython grammar limit, not a deployment choice, so it is fixed rather than
* configurable. The sibling `ts-types` renderer needs no counterpart: nothing
* in the TypeScript grammar bounds nesting, and its SDK block is never type-
* checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B`
* chain and nested objects render as separate `class` statements, so neither
* accumulates open brackets at any depth.
* accumulates open brackets at any depth. The invariant this cap serves is
* grammatical validity; see the `oneOf` arm in {@link renderType} for the one
* interpreter limit deliberately left uncapped.
*/
const MAX_LIST_NESTING = 180
@@ -367,6 +371,18 @@ function renderType(schema: unknown, className: string, state: RenderState): str
frame.kind = 'oneOf'
// A union renders as `A | B` — no brackets of its own, so the branches
// inherit the enclosing depth unchanged.
//
// Union LENGTH is deliberately uncapped, unlike list nesting. The two
// limits are different in kind: >200 open brackets is a SyntaxError
// from the tokenizer, so the text is not Python; a long `A | B | …`
// chain is grammatically valid at any length and only defeats CPython's
// C-recursion when `compile()` walks the left-nested BinOp spine
// (measured: 1,000 branches compile, 5,000 raise RecursionError). This
// block is prompt text — nothing compiles it — so that limit costs
// nothing here, while capping would retire the deep-chain tests that
// pin the walk's linear time and the class-name propagation cap. The
// standard this renderer holds is grammatical validity, not
// compilability under one interpreter's stack.
frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth }))
continue
}
@@ -409,7 +425,11 @@ function renderType(schema: unknown, className: string, state: RenderState): str
// than a permissive `dict[str, Any]`.
const entries = Object.entries(node.properties ?? {})
// An empty `className` marks the context-free `jsonSchemaToPy` entry:
// there is no naming context to declare into, so degrade. A field
// there is no naming context to declare into, so degrade. This reads
// the CALL's className, not `frame.className`: the marker belongs to
// the whole walk, and frames propagate a derived name (a `oneOf`
// branch of the context-free root gets `Tool1`), so a per-frame read
// would declare classes the caller has no way to receive. 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

View File

@@ -249,6 +249,12 @@ describe('renderToolsSdkPy', () => {
],
})
expect(type).toBe('dict[str, Any] | str')
// Both branches objects, and the same shape reached through an array: the
// marker is the CALL's className, so a propagated frame name (`Tool1`) does
// not revive class declaration on a walk that has nowhere to declare into.
const object = { type: 'object', additionalProperties: false, properties: { ok: { type: 'boolean' } }, required: ['ok'] }
expect(jsonSchemaToPy({ oneOf: [object, object] })).toBe('dict[str, Any] | dict[str, Any]')
expect(jsonSchemaToPy({ type: 'array', items: { oneOf: [object, { type: 'string' }] } })).toBe('list[dict[str, Any] | str]')
})
it('suffixes a counter when two tools CamelCase to the same class base', () => {
@@ -539,6 +545,9 @@ describe('renderToolsSdkPy', () => {
// 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.)
// The resulting chain is intentionally uncapped, unlike list nesting: it is
// grammatically valid Python at any length, and only CPython's `compile()`
// recursion would reject it — see the `oneOf` arm in py-types.ts.
const depth = 100000
let deep: Record<string, unknown> = { type: 'string' }
for (let i = 0; i < depth; i++) deep = { oneOf: [deep, { type: 'null' }] }