@qilin-agent/tools 0.0.0-stage → 3.1.3

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