@qilin-agent/tools 0.0.0-stage → 3.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,787 @@
1
+ /**
2
+ * PTC mode codegen — Python flavor. The pure projection from registered tool schemas to the
3
+ * Python SDK text the model programs against under `runtime.language === 'python'`. Sibling of
4
+ * {@link ./ts-types.ts | ts-types.ts}; the two files are two projections of the same registry
5
+ * store, keyed by the loaded {@link @qilin-agent/ptc-runtime#PtcRuntime.language | PTC
6
+ * runtime's language}.
7
+ *
8
+ * Under `mode: 'ptc'` the native tool schemas are omitted from the request, so this generated
9
+ * SDK is the model's ONLY source for each tool's argument names, required fields, types,
10
+ * descriptions, and canonical output shapes. Object-shaped arguments and outputs therefore render as one
11
+ * named `TypedDict` per tool (and per nested object), not an opaque `dict[str, Any]`, so the
12
+ * shape survives into the program under the mode that has nothing else to carry it.
13
+ * @module @qilin-agent/tools/src/py-types
14
+ */
15
+ import { assertSupportedJsonSchema } from "./json-schema.js";
16
+ /**
17
+ * The reference grammar's `xid_start xid_continue*` — the set
18
+ * `str.isidentifier()` accepts on a CPython whose Unicode tables match the
19
+ * engine's. See {@link isBareIdentifier} for what a version skew does.
20
+ */
21
+ const IDENTIFIER = /^[\p{XID_Start}_]\p{XID_Continue}*$/u;
22
+ /**
23
+ * Whether a name can be emitted as a bare Python identifier rather than
24
+ * routed to the subscript/`dict[str, Any]` path.
25
+ *
26
+ * Python identifiers are not ASCII: `路径` is as legal a field name as `path`,
27
+ * and rejecting it would degrade the whole enclosing object, dropping every
28
+ * field's name, requiredness, and type — information whose only source under
29
+ * `mode: 'ptc'` is this generated text.
30
+ *
31
+ * NFKC stability is a second and separate condition, because CPython
32
+ * normalizes identifiers at compile time while JSON keys are compared as
33
+ * written: `field` would be declared and reachable as `field`, so the SDK would
34
+ * advertise a key under a spelling the harness never accepts, and two keys
35
+ * that normalize together would collapse into one declaration. Those names
36
+ * take the subscript path, which carries their exact bytes.
37
+ *
38
+ * `IDENTIFIER` matches `str.isidentifier()` (measured on Node 22.23.1 vs
39
+ * CPython 3.9.6 tables): the equivalence holds inside the two versions' shared
40
+ * tables, and the skew characters below are exactly where that pair diverges.
41
+ * The predicate as a whole is deliberately stricter than `isidentifier()`,
42
+ * which does not test NFKC stability: `'field'.isidentifier()` is True and
43
+ * this returns false.
44
+ *
45
+ * Both conditions are evaluated against the ENGINE's Unicode tables, and the
46
+ * two sides are versioned independently — `\p{XID_Start}`/`\p{XID_Continue}`
47
+ * follow the running engine (Node 22.23.1 reports Unicode 17.0) while CPython
48
+ * follows its own (3.9.6 reports 13.0.0). The skew is not symmetric. A CPython
49
+ * older than the engine is the dangerous direction: a character added to either
50
+ * property since its tables (U+10570 Vithkuqi and U+1E290 Toto, 14.0; U+1E4D0
51
+ * Nag Mundari, 15.0; U+1C89 Cyrillic TJE, 16.0 — ages per `DerivedAge.txt`; all
52
+ * four are NFKC-stable and accepted here, and all four are `Cn` on that 3.9.6,
53
+ * which rejects them) is emitted bare and its tokenizer refuses the character,
54
+ * taking the whole SDK block down — the same parseability invariant
55
+ * {@link UNPRINTABLE}, {@link LONE_SURROGATE} and {@link MAX_LIST_NESTING}
56
+ * exist for. Both properties carry it: a character added only to `XID_Continue`
57
+ * passes the trailing `\p{XID_Continue}*` in a tail position and fails the same
58
+ * way — U+200C ZWNJ and U+200D ZWJ are that case, gaining `XID_Continue` in UCD
59
+ * 15.1 and absent from it in 13.0.0, 14.0.0 and 15.0.0, so `a\u{200C}b` is
60
+ * emitted bare here while `isidentifier()` is False on 3.9.6 and on 3.12.13
61
+ * (15.0.0). A CPython newer than the engine only routes a legal name to the
62
+ * subscript/`dict[str, Any]` path: less readable, still correct. The NFKC
63
+ * condition reduces to the same skew, since normalization stability guarantees
64
+ * an assigned character's normalization never changes afterwards.
65
+ *
66
+ * This predicate is not the only reader of engine tables. {@link camelCase}
67
+ * reads them at three further points — its split set, its head test, and its
68
+ * `toUpperCase()` case mapping — and this predicate's verdict gates none of
69
+ * them: a class name derived there reaches emitted text whenever any object
70
+ * shape in the tool's schema declares a `TypedDict`, including for a tool this
71
+ * predicate rejected. A tool named `zz-\u{1E4D0}x` with such parameters never
72
+ * reaches the skew here (the `-` rejects it outright) yet emits `class
73
+ * Zz\u{1E4D0}xArgs`, which that same 3.9.6 refuses — Nag Mundari arrived two
74
+ * releases after its tables. The case mapping is a separate table rather than
75
+ * an XID membership test, and it fails on names both conditions above accept:
76
+ * `\u{019B}` is XID_Start and NFKC-stable, so this predicate accepts it and
77
+ * `async def \u{019B}` compiles on 3.9.6, but Node uppercases it to
78
+ * `\u{A7DC}` — unassigned in that CPython, whose own `.upper()` is the identity
79
+ * here — and the declared `class \u{A7DC}Args` fails with `invalid
80
+ * non-printable character U+A7DC`. Closing the exposure therefore covers all
81
+ * four read points, not this predicate alone; it needs the target interpreter's
82
+ * version, which the backend reporting `language: 'python'` owns; the
83
+ * language-dispatch Agent Note records the deferral.
84
+ *
85
+ * The `ts-types` sibling keeps its own ASCII rule rather than sharing this
86
+ * one: ECMAScript identifiers are a different set (`$`) and are never
87
+ * normalized, so one predicate cannot be correct for both. ZWJ/ZWNJ are not
88
+ * part of that difference — both sets carry them on the engine's tables; what
89
+ * separates the two there is the CPython table version above.
90
+ * @param name - the raw schema field or tool name.
91
+ * @returns whether the name can be emitted bare.
92
+ */
93
+ function isBareIdentifier(name) {
94
+ return IDENTIFIER.test(name) && name.normalize('NFKC') === name;
95
+ }
96
+ /**
97
+ * Python hard keywords: reserved everywhere, so a tool or field named
98
+ * ``class`` or ``lambda`` is legal on the wire but not as an attribute
99
+ * (``tools.class`` would be a SyntaxError in the model program) and not as a
100
+ * class-syntax `TypedDict` field. Such a tool renders under subscript access
101
+ * and such an object degrades to ``dict[str, Any]`` — the model still reaches
102
+ * every tool and field without collisions.
103
+ * Soft keywords (``match``, ``case``, ``type``, ``_`` — the language
104
+ * reference's whole set) are deliberately ABSENT: each is special in exactly
105
+ * one syntactic position — a statement head (``match``, ``type``), a ``match``
106
+ * statement's clause head (``case``), or a pattern (``_``) — so ``match: str``
107
+ * as a field and ``async def match(...)`` as a method are both legal, and
108
+ * including them would needlessly degrade common search/regex tool fields to
109
+ * ``dict[str, Any]``. Underscore-leading names are handled separately, not
110
+ * here: a non-dunder ``__token`` name-mangles, a dunder present on
111
+ * ``object``/``type`` resolves before the proxy hook, and implicit
112
+ * special-method lookup bypasses the hook.
113
+ */
114
+ const RESERVED = new Set([
115
+ 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'await', 'break', 'class',
116
+ 'continue', 'def', 'del', 'elif', 'else', 'except', 'finally', 'for', 'from', 'global',
117
+ 'if', 'import', 'in', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise',
118
+ 'return', 'try', 'while', 'with', 'yield',
119
+ // Not a keyword, but CPython refuses to ASSIGN it at compile time
120
+ // (`SyntaxError: cannot assign to __debug__`), which is what a TypedDict
121
+ // field, a parameter name, and a keyword argument all are.
122
+ '__debug__',
123
+ ]);
124
+ /** `typing` symbols this module may emit, in the deterministic import order. */
125
+ const TYPING_ORDER = ['Any', 'Literal', 'NotRequired', 'Protocol', 'TypedDict'];
126
+ /** `indent`-deep line prefix (four spaces per level to match PEP 8 output). */
127
+ function pad(indent) {
128
+ return ' '.repeat(indent);
129
+ }
130
+ /**
131
+ * The `Cc` code points that survive the whitespace collapse in {@link describe}
132
+ * and have no printable form: the C0 controls, DEL, and the C1 controls. Only
133
+ * U+0009 to U+000D are absent, because ECMAScript `\s` already collapsed them —
134
+ * `\s` is TAB/VT/FF/SP/NBSP/ZWNBSP/Zs plus LF/CR/LS/PS, so no C1 code point is
135
+ * in it and the whole U+0080 to U+009F block reaches this rule intact. Those
136
+ * are not hypothetical input: they are what Windows-1252 bytes 0x80 to 0x9F
137
+ * (smart quotes, em dash) become when decoded as Latin-1.
138
+ * CPython rejects source containing a NUL outright
139
+ * (`SyntaxError: source code string cannot contain null bytes`), whether it
140
+ * sits in a docstring or in a comment, so one such byte anywhere in a schema
141
+ * description would make the whole generated SDK unparseable — under
142
+ * `mode: 'ptc'`, the model's only declaration of the tools. The rest are
143
+ * legal but invisible; escaping them with the same rule keeps the emitted text
144
+ * readable and the treatment uniform.
145
+ *
146
+ * The boundary is the category, not per-code-point addressability: `\xNN`
147
+ * addresses U+0000 to U+00FF, so one escape form covers `Cc` exactly. The
148
+ * invisible `Cf` formatting characters pass through by design — of them only
149
+ * U+00AD soft hyphen would fit `\xNN` at all, and escaping that one while
150
+ * U+200B ZWSP, U+200E/U+200F bidi marks, and U+2060 word joiner passed through
151
+ * would leave a rule that is neither category- nor addressability-shaped. The
152
+ * whole family is legal in both consumers, since only LF and CR terminate a
153
+ * Python string literal or a `#` comment. That set is the tokenizer's, not
154
+ * `str.splitlines()`': NEL (U+0085), LS (U+2028), and PS (U+2029) split a
155
+ * string at run time but do not end a physical line in source — measured on
156
+ * CPython 3.9.6 and 3.12.13, each accepted in both positions with the value
157
+ * round-tripping — so they are safe raw wherever they reach emitted text
158
+ * unescaped, which for all three is `JSON.stringify`, at two call sites:
159
+ * {@link pyScalar}'s literal path, and the subscript tool-name comment's own
160
+ * call, which a name carrying any of them always reaches, none being
161
+ * `XID_Continue`. The `description` path escapes NEL under the class above and
162
+ * folds LS and PS in {@link describe}'s `\s+` collapse, both being `\s`.
163
+ */
164
+ const UNPRINTABLE = /[\u0000-\u0008\u000e-\u001f\u007f-\u009f]/g;
165
+ /**
166
+ * Unpaired surrogate code points, escaped by {@link describe} as `\uNNNN` —
167
+ * its own form, since `\xNN` stops at U+00FF. The `u` flag is what makes this
168
+ * the LONE ones: in Unicode mode a well-formed pair is a single astral code
169
+ * point outside D800 to DFFF, so an emoji in a description survives untouched.
170
+ *
171
+ * This is the NUL case from {@link UNPRINTABLE}, not the invisible-character
172
+ * case. Python source must be UTF-8-encodable and a lone surrogate is not, so
173
+ * `compile()` raises `UnicodeEncodeError: surrogates not allowed` for one
174
+ * anywhere in the text — measured on 3.9 for a string literal and for a `#`
175
+ * comment alike. A raw or MCP tool description reaches this: `JSON.parse` on a
176
+ * wire `"\ud800"` escape yields exactly such a code point.
177
+ */
178
+ const LONE_SURROGATE = /[\ud800-\udfff]/gu;
179
+ /**
180
+ * The collapsed one-line `description` of a schema node (byte-stable across
181
+ * formatting churn), or `undefined` when the node carries none. Every caller
182
+ * passes an object — a validated property node, the `ToolSdkSchema` itself, or
183
+ * the `{ description }` wrapper {@link docLines} synthesizes — so only the
184
+ * description field needs guarding. A description that collapses
185
+ * to nothing (empty, or whitespace only) is `undefined` too: it documents the
186
+ * node no better than an absent one, and emitting it would leave an empty
187
+ * `"""` docstring or a bare `# ` line in the SDK. Only ECMAScript whitespace
188
+ * folds, so a description of whitespace plus one surviving control character is
189
+ * NOT absent: it collapses to that character's visible escape.
190
+ *
191
+ * Control characters left over after the whitespace collapse are rendered as
192
+ * their `\xNN` escapes (see {@link UNPRINTABLE}) and unpaired surrogates as
193
+ * their `\uNNNN` escapes (see {@link LONE_SURROGATE}); the escape's own backslash is
194
+ * emitted literally by both consumers, since {@link docLines} doubles it into a
195
+ * Python source escape and a `#` comment carries it verbatim.
196
+ */
197
+ function describe(schema) {
198
+ const description = schema.description;
199
+ if (typeof description !== 'string')
200
+ return undefined;
201
+ const collapsed = description
202
+ .replace(/\s+/g, ' ')
203
+ .replace(UNPRINTABLE, char => `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
204
+ .replace(LONE_SURROGATE, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`)
205
+ .trim();
206
+ return collapsed.length === 0 ? undefined : collapsed;
207
+ }
208
+ /**
209
+ * One-line docstring for a tool `description`, or no lines when there is none.
210
+ * Backslashes are doubled first, every quote is escaped, and a trailing
211
+ * backslash cannot survive: a description ending in `"` or an odd backslash
212
+ * would otherwise merge with (or escape) the closing triple quote and make
213
+ * the generated block — PTC mode's only SDK — syntactically invalid Python.
214
+ */
215
+ function docLines(description, indent) {
216
+ const collapsed = describe({ description });
217
+ if (collapsed === undefined)
218
+ return [];
219
+ const escaped = collapsed.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
220
+ return [`${pad(indent)}"""${escaped}"""`];
221
+ }
222
+ /**
223
+ * CamelCase a name into a Python type identifier: non-identifier characters
224
+ * split words, `_` splits too (it is `XID_Continue`, so the split set names it
225
+ * explicitly), and a head that cannot start an identifier takes a `Tool`
226
+ * prefix. Unicode survives, so a `路径` field yields `路径`-based class names
227
+ * instead of collapsing to the bare prefix. A character that is not
228
+ * `XID_Continue` splits even when it is a letter, so a name whose NFKC folding
229
+ * would leave the identifier set is not carried through — the split set is the
230
+ * grammar's, not an ASCII approximation of it.
231
+ *
232
+ * The result is NFKC-normalized: these names are generated, never matched
233
+ * against a JSON key, so normalizing is free here and keeps what CPython
234
+ * compiles identical to what is emitted — unlike {@link isBareIdentifier},
235
+ * which must reject unstable names outright. Normalizing AFTER the prefix
236
+ * decision is what makes that hold at the seam the prefix creates: `Tool` +
237
+ * a combining-mark head composes there (`U+0301` gives `Tooĺ`, U+013A), so
238
+ * normalizing only the un-prefixed part would emit a name CPython compiles to
239
+ * a different symbol. The second call is idempotent on the un-prefixed arm.
240
+ *
241
+ * The split set, the head test, and `toUpperCase()` all read the engine's
242
+ * Unicode tables, so this function carries the same version skew
243
+ * {@link isBareIdentifier} documents, by paths independent of it: a class name
244
+ * derived here reaches emitted text whenever any object shape in the tool's
245
+ * schema declares a `TypedDict`, and the predicate's verdict on the tool name
246
+ * does not gate that. The case mapping is the one that can fail on a name the
247
+ * predicate accepted; the worked example is there.
248
+ * @param raw - the schema field or tool name to derive from.
249
+ * @returns a class-name segment safe to emit.
250
+ */
251
+ function camelCase(raw) {
252
+ const joined = raw
253
+ .split(/[^\p{XID_Continue}]+|_+/u)
254
+ .filter(part => part.length > 0)
255
+ .map(part => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
256
+ .join('')
257
+ .normalize('NFKC');
258
+ return (/^\p{XID_Start}/u.test(joined) ? joined : `Tool${joined}`).normalize('NFKC');
259
+ }
260
+ /** Class-name base cap keeping each emitted name — and total text — linear in schema depth. */
261
+ const MAX_CLASS_NAME_BASE = 120;
262
+ /**
263
+ * Deepest `list[…]` nesting emitted into one annotation before the item type
264
+ * degrades to `Any`. CPython's tokenizer rejects a logical line holding more
265
+ * than 200 simultaneously-open brackets (`MAXLEVEL`, `SyntaxError: too many
266
+ * nested parentheses`), so an array chain deeper than that would render an SDK
267
+ * block that is not valid Python at all — the same failure the docstring
268
+ * escaping in {@link docLines} exists to prevent. 180 leaves headroom for the
269
+ * few brackets an annotation can add around the chain, all of which count
270
+ * toward the same limit. Per emission site, counting brackets open at the
271
+ * chain's innermost point:
272
+ *
273
+ * - Return annotation, `async def f(self, args: X) -> chain:` — 180 `list[`
274
+ * plus an innermost `Literal[`. The parameter list's `(` closed at the `)`
275
+ * before the `->`, so it is NOT open here: 181.
276
+ * - TypedDict field, `field: NotRequired[chain]` — a class-body line with no
277
+ * other open bracket, and its children start at `listDepth: 1` to reserve
278
+ * the `NotRequired[`, so 179 `list[` plus `Literal[`: 181. Required fields
279
+ * share that start for uniformity, spending one level of representable depth
280
+ * on a bracket they never emit.
281
+ * - Argument annotation, `async def f(self, args: chain) -> Y:` — the `(` IS
282
+ * still open around it: 180 `list[` plus `Literal[` plus the paren, 182, the
283
+ * worst case. Reachable only through a raw `register()` whose `parameters`
284
+ * is an array reached from the root through `oneOf` arms alone — the root
285
+ * array itself, or one nested under any depth of unions, since an arm
286
+ * inherits the enclosing depth unchanged (`A | B` opens no bracket). An
287
+ * object ancestor takes it out of this case: its fields restart the chain at
288
+ * the 181 site. `defineTool` compiles an object root, so the annotation is a
289
+ * bare TypedDict class name or a one-bracket `dict[str, Any]` when that
290
+ * object degrades — never a chain.
291
+ *
292
+ * A CPython grammar limit, not a deployment choice, so it is fixed rather than
293
+ * configurable. The sibling `ts-types` renderer needs no counterpart: nothing
294
+ * in the TypeScript grammar bounds nesting, and its SDK block is never type-
295
+ * checked. Only bracket nesting counts — a `oneOf` renders as a flat `A | B`
296
+ * chain and nested objects render as separate `class` statements, so neither
297
+ * accumulates open brackets at any depth. The invariant this cap serves is
298
+ * grammatical validity; see the `oneOf` arm in {@link renderType} for the one
299
+ * interpreter limit deliberately left uncapped.
300
+ */
301
+ const MAX_LIST_NESTING = 180;
302
+ /**
303
+ * Cap a class-name base at {@link MAX_CLASS_NAME_BASE} (see the callers for
304
+ * why capping keeps the render linear). `slice` counts UTF-16 code units, so
305
+ * an astral character straddling the boundary would be cut in half and leave a
306
+ * lone surrogate — not an identifier character, and not even well-formed text;
307
+ * drop it rather than emit it.
308
+ */
309
+ function capClassNameBase(base) {
310
+ if (base.length <= MAX_CLASS_NAME_BASE)
311
+ return base;
312
+ const capped = base.slice(0, MAX_CLASS_NAME_BASE);
313
+ return /[\uD800-\uDBFF]$/.test(capped) ? capped.slice(0, -1) : capped;
314
+ }
315
+ /**
316
+ * Reserve a unique class name from a base, suffixing `2`, `3`, … on collision.
317
+ * The base is capped at {@link MAX_CLASS_NAME_BASE} first: child class names
318
+ * derive from their parent's allocated name (`ParentChild`), so an unbounded
319
+ * schema of single-field objects would otherwise grow each name by one field
320
+ * per level and the sum of all names to Θ(depth²). Capping the base keeps each
321
+ * name — and the total emitted text — linear in depth. Collisions resume from
322
+ * the per-base counter in `state.nextClassCounter` rather than rescanning from
323
+ * `2`, so a deep chain sharing one capped base stays O(1) per allocation
324
+ * (amortized) instead of Θ(depth²) in time.
325
+ */
326
+ function allocateClassName(base, state) {
327
+ const capped = capClassNameBase(base);
328
+ let name = capped;
329
+ if (state.usedClassNames.has(name)) {
330
+ let n = state.nextClassCounter.get(capped) ?? 2;
331
+ while (state.usedClassNames.has(`${capped}${n}`))
332
+ n++;
333
+ name = `${capped}${n}`;
334
+ state.nextClassCounter.set(capped, n + 1);
335
+ }
336
+ state.usedClassNames.add(name);
337
+ return name;
338
+ }
339
+ /**
340
+ * Append a child-name segment to a parent class-name base, capping the result
341
+ * at {@link MAX_CLASS_NAME_BASE}. Capping AT PROPAGATION (not only inside
342
+ * {@link allocateClassName}) keeps each level O(1): a deep `oneOf`- or
343
+ * object-chain would otherwise carry an ever-growing ConsString down the tree
344
+ * and re-materialize it (via `.length`/`.slice`) at every level — Θ(depth²).
345
+ * The bounded base plus the collision counter still yields unique names.
346
+ *
347
+ * The join is NFKC-normalized because both sides are separately normalized yet
348
+ * their concatenation need not be: a base ending in a Hangul L jamo or LV
349
+ * syllable composes with a following V or T jamo head (`가` + `ᆨ` gives `각`),
350
+ * so the emitted class name would differ from the symbol CPython compiles, and
351
+ * two byte-distinct names could fold onto one — `usedClassNames` dedupes by the
352
+ * raw bytes, so the collision counter would not see it. Normalizing costs
353
+ * O(cap + segment) per level, the same order as the `slice` it feeds. The other
354
+ * two join points need no counterpart: `Args`/`Output` start with `A`/`O` and
355
+ * {@link allocateClassName}'s suffix is digits, none of which compose backwards.
356
+ */
357
+ function childClassName(base, segment) {
358
+ return capClassNameBase(`${base}${segment}`.normalize('NFKC'));
359
+ }
360
+ /**
361
+ * Render one validated scalar as Python literal text (`True`/`False`,
362
+ * JSON-quoted strings, bare numbers). `null` cannot reach here: the `null`
363
+ * type renders directly as `None`, and the unified validator rejects a null
364
+ * `const`/`enum` entry on every other scalar type.
365
+ *
366
+ * A beyond-safe-range integral number takes `BigInt` digits rather than
367
+ * `String`: Python integers are arbitrary-precision, so the emitted digits ARE
368
+ * the value the model programs against, and `String` can give a different
369
+ * integer than the double holds (`2 ** 60` prints the rounded `...847000`, not
370
+ * the exact `...846976`) or no integer literal at all (`1e21` prints `1e+21`).
371
+ * `String`'s rounding is not a bug in it: `Number::toString` emits the shortest
372
+ * decimal string that re-reads to the same double, then pads to the exponent
373
+ * with zeros (1 significant digit for `1e20`, 16 for `2 ** 60`) — and when the
374
+ * shortest string is shorter than the double's exact value, those padded digits
375
+ * name an integer no double holds. Passing one back would have to cross the
376
+ * argument boundary as a JSON number — a double again — so the SDK would
377
+ * document a value no program can pass. `BigInt` needs no case split: where
378
+ * `String` is already exact (`2 ** 53`, `1e20`) the two agree byte for byte,
379
+ * and where it is not, `BigInt` is the exact one. The TS flavor needs no
380
+ * counterpart at all: its literal is re-read by a JS parser back into the same
381
+ * double.
382
+ *
383
+ * `JSON.stringify` is also what keeps this path's output parseable, and it is
384
+ * the only thing that does. It covers both classes of hazard: the two kinds of
385
+ * code point CPython refuses anywhere in source — NUL among the C0 controls,
386
+ * and the whole D800–DFFF unpaired-surrogate block, escaped under ES2019
387
+ * well-formed stringification, which the engines range guarantees — and the
388
+ * ones that break this line in particular, a bare `"` closing the literal
389
+ * early, a trailing odd backslash eating the closing quote, and a bare LF/CR
390
+ * ending it before its terminator. The `description` path carries
391
+ * {@link UNPRINTABLE} and {@link LONE_SURROGATE} because nothing quotes it,
392
+ * and folds newlines in {@link describe}.
393
+ *
394
+ * That leans on a coincidence worth naming: every escape `JSON.stringify` can
395
+ * emit (`\"`, `\\`, `\b`, `\f`, `\n`, `\r`, `\t`, `\uXXXX`) is also a Python
396
+ * escape denoting the same character, so the emitted `Literal[...]` both
397
+ * parses and decodes back to the value the schema declared. DEL, the C1
398
+ * controls (NEL among them), and LS/PS (U+2028/U+2029) do reach it raw —
399
+ * legal but invisible, byte-for-byte as in the TS flavor; escaping them is a
400
+ * both-flavors change. Those last three are legal here for the reason
401
+ * {@link UNPRINTABLE} records: they are `str.splitlines()` boundaries, not
402
+ * tokenizer line terminators. The subscript tool-name comment quotes its name
403
+ * through its own call to the same `JSON.stringify`, never through this
404
+ * function, and inherits both halves — escapes and pass-throughs alike.
405
+ */
406
+ function pyScalar(value) {
407
+ if (value === true)
408
+ return 'True';
409
+ if (value === false)
410
+ return 'False';
411
+ if (typeof value === 'string')
412
+ return JSON.stringify(value);
413
+ if (typeof value === 'number' && Number.isInteger(value) && !Number.isSafeInteger(value)) {
414
+ return BigInt(value).toString();
415
+ }
416
+ return String(value);
417
+ }
418
+ /**
419
+ * Render a validated scalar `const`/`enum` as `Literal[...]`, falling back to
420
+ * the broad type. Deliberately deviates from PEP 586, which restricts `Literal`
421
+ * parameters to int/bool/str/bytes/enum/None: a non-integral number
422
+ * `const`/`enum` emits a float literal (`Literal[1.5]`) a strict checker would
423
+ * reject. An integral one does not deviate — {@link pyScalar} emits int digits,
424
+ * including for the beyond-safe-range values it widens through `BigInt`, and
425
+ * PEP 586 admits int parameters. Harmless either way — the stub is advisory
426
+ * prompt text, only required to parse — and keeping the exact value
427
+ * communicates the constraint to the model.
428
+ */
429
+ function renderConstrainedScalar(node, broad, state) {
430
+ if (node.const !== undefined) {
431
+ state.typing.add('Literal');
432
+ return `Literal[${pyScalar(node.const)}]`;
433
+ }
434
+ if (node.enum !== undefined) {
435
+ state.typing.add('Literal');
436
+ return `Literal[${node.enum.map(pyScalar).join(', ')}]`;
437
+ }
438
+ return broad;
439
+ }
440
+ /**
441
+ * Map one JSON-Schema node to a Python type expression, threading `state` to
442
+ * collect the `TypedDict` declarations and `typing` symbols a full render
443
+ * needs. `className` is the name to give an object node with properties (and
444
+ * the prefix for its nested objects). Handles every unified schema construct —
445
+ * `oneOf` (→ `X | Y`), `const`/`enum` (→ `Literal[...]`), `integer` (→ `int`),
446
+ * `null` (→ `None`) — and degrades an unsupported or malformed schema to `Any`
447
+ * without throwing, the same trusted-after-validation stance as the sibling
448
+ * {@link ./ts-types.ts | ts-types} renderer. {@link jsonSchemaToPy} is the
449
+ * context-free entry point; this is the collecting core.
450
+ */
451
+ function renderType(schema, className, state) {
452
+ const newFrame = (schema, className, listDepth) => ({ schema, className, phase: 'start', listDepth, children: [], childIndex: 0, childTypes: [], entries: [] });
453
+ try {
454
+ // Validate the WHOLE tree once, then trust it — the same contract the
455
+ // sibling ts-types renderer follows at a typed same-process boundary. Every
456
+ // node past this point is a validated JSON-schema node, so the walk reads
457
+ // its fields without re-checking. An unsupported or malformed schema throws
458
+ // here (before anything is emitted) and degrades to `Any`, the Python
459
+ // counterpart of the TS flavor's `unknown`.
460
+ assertSupportedJsonSchema(schema);
461
+ const frames = [newFrame(schema, className, 0)];
462
+ let result;
463
+ /* jscpd:ignore-start -- the explicit-stack walk skeleton deliberately parallels
464
+ ts-types.ts's renderSupportedSchema; the two sibling renderers keep symmetric shapes. */
465
+ const finish = (type) => {
466
+ frames.pop();
467
+ const parent = frames.at(-1);
468
+ if (parent === undefined)
469
+ result = type;
470
+ else
471
+ parent.childTypes.push(type);
472
+ };
473
+ while (frames.length > 0) {
474
+ const frame = frames.at(-1);
475
+ /* v8 ignore next -- the loop condition guarantees a current frame. */
476
+ if (frame === undefined)
477
+ break;
478
+ if (frame.phase === 'children') {
479
+ if (frame.childIndex < frame.children.length) {
480
+ const child = frame.children[frame.childIndex];
481
+ /* v8 ignore next -- childIndex is bounded by children.length. */
482
+ if (child === undefined)
483
+ throw new Error('missing python render child');
484
+ frame.childIndex++;
485
+ frames.push(newFrame(child.schema, child.className, child.listDepth));
486
+ continue;
487
+ }
488
+ if (frame.kind === 'oneOf') {
489
+ // Concatenate incrementally (template literal, not `Array.join`): V8
490
+ // builds a lazy ConsString, so a deep oneOf chain materializes once
491
+ // at the root instead of re-materializing the accumulated string at
492
+ // every level (which `join` would, making it Θ(depth²)). This matches
493
+ // the array arm's template-literal laziness and ts-types' composable-
494
+ // document approach — the whole walk stays linear in schema depth.
495
+ let union = '';
496
+ for (const [index, childType] of frame.childTypes.entries()) {
497
+ union = index === 0 ? childType : `${union} | ${childType}`;
498
+ }
499
+ finish(union);
500
+ continue;
501
+ }
502
+ /* jscpd:ignore-end */
503
+ if (frame.kind === 'array') {
504
+ // `list[A | B]` needs no parentheses in Python. Array frames always
505
+ // schedule exactly one child, so its type is present.
506
+ /* v8 ignore next -- the ?? arm needs a childless array frame, which start never builds. */
507
+ finish(`list[${frame.childTypes[0] ?? 'Any'}]`);
508
+ continue;
509
+ }
510
+ // typeddict: assemble AFTER the children so any nested class this one
511
+ // references is already declared (declaration order = reference order).
512
+ const node = frame.node;
513
+ const name = frame.allocated;
514
+ /* v8 ignore next -- typeddict frames always set node and allocated at start. */
515
+ if (node === undefined || name === undefined)
516
+ throw new Error('missing typeddict frame state');
517
+ const required = new Set(node.required);
518
+ const lines = [`class ${name}(TypedDict):`];
519
+ for (let index = 0; index < frame.entries.length; index++) {
520
+ const entry = frame.entries[index];
521
+ const fieldType = frame.childTypes[index];
522
+ /* v8 ignore next -- entries and childTypes correspond one-to-one. */
523
+ if (entry === undefined || fieldType === undefined)
524
+ throw new Error('missing typeddict field type');
525
+ const [field, fieldSchema] = entry;
526
+ // The parent node passed assertSupportedJsonSchema, so every property
527
+ // value is a validated schema node.
528
+ const description = describe(fieldSchema);
529
+ if (description !== undefined)
530
+ lines.push(`${pad(1)}# ${description}`);
531
+ if (required.has(field)) {
532
+ lines.push(`${pad(1)}${field}: ${fieldType}`);
533
+ }
534
+ else {
535
+ state.typing.add('NotRequired');
536
+ lines.push(`${pad(1)}${field}: NotRequired[${fieldType}]`);
537
+ }
538
+ }
539
+ // TypedDict syntax cannot express openness, so an open object states it
540
+ // in-band: the annotation is advisory either way, and `mode: 'ptc'`
541
+ // omits the native schemas, making this line the model's only signal
542
+ // that extra keys are accepted.
543
+ if (node.additionalProperties !== false) {
544
+ lines.push(`${pad(1)}# Additional keys beyond those declared are allowed.`);
545
+ }
546
+ // A closed empty object still needs a class body (`pass`) to be valid
547
+ // Python; the declared emptiness is the information.
548
+ if (lines.length === 1)
549
+ lines.push(`${pad(1)}pass`);
550
+ state.classes.push(lines.join('\n'));
551
+ finish(name);
552
+ continue;
553
+ }
554
+ frame.phase = 'children';
555
+ const node = frame.schema;
556
+ if (node.oneOf !== undefined) {
557
+ frame.kind = 'oneOf';
558
+ // A union renders as `A | B` — no brackets of its own, so the branches
559
+ // inherit the enclosing depth unchanged.
560
+ //
561
+ // Union LENGTH is deliberately uncapped, unlike list nesting. The two
562
+ // limits are different in kind: >200 open brackets is a SyntaxError
563
+ // from the tokenizer, so the text is not Python; a long `A | B | …`
564
+ // chain is grammatically valid at any length and only defeats CPython's
565
+ // C-recursion when `compile()` walks the left-nested BinOp spine
566
+ // (measured: 1,000 branches compile, 5,000 raise RecursionError). This
567
+ // block is prompt text — nothing compiles it — so that limit costs
568
+ // nothing here, while capping would retire the deep-chain tests that
569
+ // pin the walk's linear time and the class-name propagation cap. The
570
+ // standard this renderer holds is grammatical validity, not
571
+ // compilability under one interpreter's stack.
572
+ frame.children = node.oneOf.map((branch, index) => ({ schema: branch, className: childClassName(frame.className, `${index + 1}`), listDepth: frame.listDepth }));
573
+ continue;
574
+ }
575
+ if (node.type === undefined) {
576
+ state.typing.add('Any');
577
+ finish('Any');
578
+ continue;
579
+ }
580
+ switch (node.type) {
581
+ case 'string':
582
+ finish(renderConstrainedScalar(node, 'str', state));
583
+ break;
584
+ case 'number':
585
+ finish(renderConstrainedScalar(node, 'float', state));
586
+ break;
587
+ case 'integer':
588
+ finish(renderConstrainedScalar(node, 'int', state));
589
+ break;
590
+ case 'boolean':
591
+ finish(renderConstrainedScalar(node, 'bool', state));
592
+ break;
593
+ case 'null':
594
+ finish('None');
595
+ break;
596
+ case 'array': {
597
+ if (node.items === undefined) {
598
+ state.typing.add('Any');
599
+ finish('list[Any]');
600
+ break;
601
+ }
602
+ // Past MAX_LIST_NESTING another `list[` would push the annotation
603
+ // beyond CPython's open-bracket limit and make the whole SDK block
604
+ // unparseable, so the chain degrades here instead — an unusable
605
+ // annotation either way, and this one is valid Python.
606
+ if (frame.listDepth >= MAX_LIST_NESTING) {
607
+ state.typing.add('Any');
608
+ finish('Any');
609
+ break;
610
+ }
611
+ // An array of objects names its item type after the array field.
612
+ frame.kind = 'array';
613
+ frame.children = [{ schema: node.items, className: frame.className, listDepth: frame.listDepth + 1 }];
614
+ break;
615
+ }
616
+ case 'object': {
617
+ // A missing `properties` is an empty property map, exactly as the
618
+ // unified validator and the TS renderer read it — NOT an unknown
619
+ // shape. The openness of the resulting empty object is decided below,
620
+ // so a closed empty object still declares an empty TypedDict rather
621
+ // than a permissive `dict[str, Any]`.
622
+ const entries = Object.entries(node.properties ?? {});
623
+ // An empty `className` marks the context-free `jsonSchemaToPy` entry:
624
+ // there is no naming context to declare into, so degrade. This reads
625
+ // the CALL's className, not `frame.className`: the marker belongs to
626
+ // the whole walk, and frames propagate a derived name (a `oneOf`
627
+ // branch of the context-free root gets the index-derived name `1` —
628
+ // `childClassName` concatenates and caps, it does not go through
629
+ // `camelCase`), so a per-frame read would declare classes the caller
630
+ // has no way to receive, under a name that is not even a legal
631
+ // identifier: `class 1(TypedDict):`. A field
632
+ // name that is not a legal Python attribute is inexpressible as a
633
+ // class-syntax `TypedDict` field, so such an object degrades whole.
634
+ // A leading-double-underscore non-dunder field (`__token`) would be
635
+ // NAME-MANGLED inside class syntax (`_ClassName__token`), describing a
636
+ // different JSON key than the registered schema — degrade like any
637
+ // other inexpressible field name.
638
+ if (className === '' || !entries.every(([name]) => isBareIdentifier(name) && !RESERVED.has(name) && !(name.startsWith('__') && !name.endsWith('__')))) {
639
+ state.typing.add('Any');
640
+ finish('dict[str, Any]');
641
+ break;
642
+ }
643
+ // An OPEN empty object is any dict; a CLOSED empty object declares an
644
+ // empty TypedDict so "no keys accepted" survives into the SDK.
645
+ if (entries.length === 0 && node.additionalProperties !== false) {
646
+ state.typing.add('Any');
647
+ finish('dict[str, Any]');
648
+ break;
649
+ }
650
+ frame.kind = 'typeddict';
651
+ frame.node = node;
652
+ frame.allocated = allocateClassName(frame.className, state);
653
+ state.typing.add('TypedDict');
654
+ frame.entries = entries;
655
+ // A field annotation is its own logical line, so nesting restarts —
656
+ // at 1, reserving the bracket an optional field's `NotRequired[…]`
657
+ // wraps around it. frame.allocated was assigned three statements up;
658
+ // the ?? arm is for the type system only.
659
+ /* v8 ignore next -- allocated is always set before children are built. */
660
+ frame.children = entries.map(([field, child]) => ({ schema: child, className: childClassName(frame.allocated ?? '', camelCase(field)), listDepth: 1 }));
661
+ break;
662
+ }
663
+ /* v8 ignore next 4 -- assertSupportedJsonSchema narrowed this closed type union. */
664
+ default: {
665
+ state.typing.add('Any');
666
+ finish('Any');
667
+ }
668
+ }
669
+ }
670
+ /* v8 ignore next -- every root frame produces one expression. */
671
+ return result ?? 'Any';
672
+ }
673
+ catch {
674
+ // An unsupported or malformed schema failed validation (before any
675
+ // emission), or an unreachable internal invariant tripped. Either degrades
676
+ // the node to `Any` rather than crashing prompt assembly — the Python
677
+ // counterpart of the TS flavor's `unknown` fallback.
678
+ state.typing.add('Any');
679
+ return 'Any';
680
+ }
681
+ }
682
+ /**
683
+ * Map one JSON-Schema node to a context-free Python type expression from the
684
+ * `typing` module. Handles every unified schema construct — `object` (degraded
685
+ * to `dict[str, Any]`: naming a `TypedDict` requires the render context that
686
+ * {@link renderToolsSdkPy} supplies), `const`/`enum` (→ `Literal[...]`),
687
+ * `oneOf` (→ union), `string`/`number`/`integer`/`boolean`/`null`, `array`
688
+ * (`items` → `list[T]`) — and returns `Any` for an unsupported or malformed
689
+ * schema, matching the TS flavor's `unknown` fallback. Type annotations in the
690
+ * emitted SDK are advisory: Python does not enforce them at runtime.
691
+ * @param schema - the JSON-Schema node.
692
+ * @returns the Python type text.
693
+ */
694
+ export function jsonSchemaToPy(schema) {
695
+ // A throwaway state whose class collector never escapes: an object with
696
+ // properties has nowhere to declare its TypedDict and degrades to
697
+ // dict[str, Any]. renderToolsSdkPy drives the named-TypedDict path.
698
+ return renderType(schema, '', { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set() });
699
+ }
700
+ /** The fixed model-facing usage contract rendered above the declarations. */
701
+ const SDK_INSTRUCTIONS = `## Writing code for run_code
702
+
703
+ \`run_code\` takes two required arguments: \`description\`, a short summary of what the program does, and \`code\` — the body of an async Python function (top-level \`await\` and \`return\` both work). At run time exactly two of the names declared below are bound: \`tools\` and \`ToolCallError\`. Everything else is a STATIC STUB describing argument and return types — in particular the \`TypedDict\` classes do NOT exist at run time, so build arguments as plain \`dict\`/\`list\` JSON values: \`await tools.name({"field": 1})\`, never \`FooArgs(field=1)\`, which raises \`NameError\`. Inside the program:
704
+
705
+ - Call tools as \`await tools.name(args)\` — subscript access for exotic, reserved, or underscore-leading names: \`await tools["my-tool"](args)\`. Every call resolves to the tool's typed canonical JSON value (each method's return type below). Tool arguments must be lossless JSON.
706
+ - A FAILED tool call raises \`ToolCallError\`, whose \`toolName\` identifies the failed tool and whose message is human-readable — wrap in \`try/except\` to handle and continue.
707
+ - Independent read-only calls MAY overlap under \`asyncio.gather\` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with \`await\`.
708
+ - Emit the run's answer with \`print(...)\` and/or a top-level \`return <value>\`; the returned value must be lossless JSON. Only what you print and return is program output. A successful tool result containing an image is attached after the run so you can inspect it on the next step; every other intermediate result stays out of the conversation, so extract just what you need.
709
+
710
+ The available tools:`;
711
+ /**
712
+ * Render the full `tools:sdk` prompt section under `runtime.language ===
713
+ * 'python'`: the Python-flavored usage instructions plus one named `TypedDict`
714
+ * per tool argument or output object (and per nested object) and one awaitable
715
+ * method per visible tool on a `Tools` protocol — typed args in, the tool's
716
+ * canonical output value out — with a `tools: Tools` singleton the model calls
717
+ * into. The `typing` import line lists exactly the symbols the render used.
718
+ * Deterministic — tools are emitted in lexicographic name order, and class
719
+ * declarations precede the protocol in that same order (nested classes before
720
+ * the parent that references them), so an unchanged tool set produces
721
+ * byte-identical text across assemblies. The sort is not a total order on
722
+ * byte-equal names, so two schemas sharing a name would render in argument
723
+ * order; the caller's visible-capability map is keyed by name, so the input
724
+ * never carries a duplicate.
725
+ * @param schemas - the tool schemas plus canonical output schemas to declare
726
+ * (the caller excludes `run_code` itself).
727
+ * @returns the complete section text.
728
+ */
729
+ export function renderToolsSdkPy(schemas) {
730
+ const sorted = [...schemas].sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
731
+ const state = { classes: [], usedClassNames: new Set(), nextClassCounter: new Map(), typing: new Set(['Protocol']) };
732
+ // ONE ordered member stream, matching the documented lexicographic contract
733
+ // and the TypeScript flavor (which quotes exotic keys in place rather than
734
+ // partitioning them out). Interleaving is free here: a comment line between
735
+ // two `async def` lines is not a statement, so it changes nothing about how
736
+ // the class body parses.
737
+ const members = [];
738
+ let statements = 0;
739
+ for (const schema of sorted) {
740
+ const argType = renderType(schema.parameters, `${camelCase(schema.name)}Args`, state);
741
+ const outputType = renderType(schema.output, `${camelCase(schema.name)}Output`, state);
742
+ if (isBareIdentifier(schema.name) && !RESERVED.has(schema.name) && !schema.name.startsWith('_')) {
743
+ // A docstring only documents its method when it is the FIRST statement
744
+ // of that method's body. Emitted before the `async def` it would instead
745
+ // become the `Tools` class docstring (for the first tool) or a dead
746
+ // expression (for every later one), leaving every method undocumented —
747
+ // and under `mode: 'ptc'` this SDK is the model's only description of
748
+ // what a tool does. A docstring is a complete body, so the `...` stub is
749
+ // only for the description-less case.
750
+ const doc = docLines(schema.description, 2);
751
+ members.push(doc.length > 0
752
+ ? `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}:`
753
+ : `${pad(1)}async def ${schema.name}(self, args: ${argType}) -> ${outputType}: ...`);
754
+ members.push(...doc);
755
+ statements += 1;
756
+ }
757
+ else {
758
+ // Not reachable as ``tools.name`` — the model reaches it via
759
+ // ``tools[name]``. Exotic names and hard keywords are not legal
760
+ // attributes at all; an underscore-leading name (``_foo``) IS a legal
761
+ // attribute and is routed here anyway, because the forms that break
762
+ // split three ways — a non-dunder ``__token`` name-mangles at the CALL
763
+ // site, a dunder that exists on ``object``/``type`` (``__class__``,
764
+ // ``__doc__``) resolves before ``__getattr__`` ever runs, and implicit
765
+ // special-method lookup skips the hook entirely — and one rule over the
766
+ // whole family costs nothing while a per-form rule would have to
767
+ // enumerate them (see {@link RESERVED}). The stub lists it as a subscript comment
768
+ // (referencing the named TypedDicts too) so a reader sees what is
769
+ // accessible; runtime resolution goes through the proxy's __getitem__.
770
+ members.push(`${pad(1)}# tools[${JSON.stringify(schema.name)}](args: ${argType}) -> ${outputType}`);
771
+ const description = describe(schema);
772
+ if (description !== undefined)
773
+ members.push(`${pad(1)}# ${description}`);
774
+ }
775
+ }
776
+ // Subscript entries are COMMENTS, not statements: a class body of only
777
+ // comments fails to parse, so `pass` is required whenever no method was
778
+ // emitted — including the subscript-only tool set.
779
+ const bodyLines = statements > 0 ? members : [`${pad(1)}pass`, ...members];
780
+ const body = bodyLines.join('\n');
781
+ const imports = TYPING_ORDER.filter(symbol => state.typing.has(symbol));
782
+ const classBlock = state.classes.length > 0 ? `${state.classes.join('\n\n')}\n\n` : '';
783
+ const errorDeclaration = 'class ToolCallError(Exception):\n toolName: str';
784
+ const declaration = `from typing import ${imports.join(', ')}\n\n${errorDeclaration}\n\n${classBlock}class Tools(Protocol):\n${body}\n\ntools: Tools`;
785
+ return `${SDK_INSTRUCTIONS}\n\n\`\`\`python\n${declaration}\n\`\`\``;
786
+ }
787
+ //# sourceMappingURL=py-types.js.map